Blog · Architecture & benchmarks

Polign and DuckDB: Shared retrieval

September 24, 2026 · Anup Talwalkar · Polign

I kept getting asked how Polign compares with DuckDB, so I put this comparison together. My focus is agent memory with Polign Recall, and helping multiple agents share a knowledge base through vector and semantic retrieval.

Polign Recall is the agent memory layer built on polign_db, which provides the underlying storage and retrieval service. Sharing that memory across agents raises practical questions, how do they read and write the same data and coordinate work? This post looks at how Polign (0.7.1) handles those questions, then compares its vector retrieval with DuckDB on one million vectors. The benchmark measures the database layer. I will have a separate post about evaluating Recall's memory behavior.

Shared service · Coordination · Cross-node reads · Retrieval and operations · Benchmark

The short answer

Use DuckDB when

  • your application can own an embedded database file
  • the vector index fits in RAM
  • you need SQL joins, aggregates and Parquet alongside vector search

Use Polign when

  • agents and workers on different machines need a shared retrieval service
  • you want to separate serving nodes from durable storage and coordinate background work
  • you need metadata filters, keyword search and hybrid retrieval alongside vector search

DuckDB across processes

While one process held the DuckDB database open for writing, a second process failed to open it in either read-write or read-only mode, with a conflicting-file-lock error. DuckDB's concurrency documentation describes the direct-file choices as one writing process, or multiple read-only processes with no writer. Multiple threads within the owning process are supported, as the throughput runs demonstrate.

The documentation also describes Quack, a client-server protocol in beta, and DuckLake with a PostgreSQL catalog for coordinated access. We did not test either, including their interaction with HNSW. Polign exposes HTTP and gRPC server interfaces directly; multiple clients can use them to read and write a shared collection.

From an application database to a shared service

Consider an agent application with a web server handling requests, a worker ingesting documents, and several agents reading the resulting memory. They may run on different machines and restart independently. Alongside search quality, the application needs a durable place for writes, a way to serve reads from another node, and rules for who updates the index.

With Polign's -store deployment, the write log, persisted segments and index metadata live in the configured store. Serving nodes keep caches and working state that can be rebuilt from it. Multiple writable servers can use the same store; adding another server does not require making its local disk the authoritative copy of the database. Across machines, use a shared object store. Two private fs: directories are separate stores.

Two serving nodes, one durable store

Applications, workers and agents
↓ HTTP / gRPC through application routing or a gateway

Polign node A

Reads and writes
Local caches
↕

Polign node B

Reads and writes
Local caches
↕

Shared object store
Write log · segments · index metadata · coordination records

Your application or gateway routes requests; your deployment platform provisions the nodes.

You can also run a dedicated read tier with -read-only and read-only bucket credentials. Those nodes reject mutations and do no persistence or maintenance. They refresh published segment generations, so they can lag the writable nodes' latest log entries. A replacement writable node opens the same store and rebuilds its serving state from persisted data and the retained write tail. Recovery time depends on that state and the storage service; the process-restart timings later in this post are local measurements.

How the nodes coordinate

The object store supplies both durable storage and the conditional writes used for coordination. This deployment does not require a separate log broker or coordination service. Three mechanisms handle different jobs:

  1. Ordering incoming writes. A write is acknowledged after its log batch reaches the store. Writers claim log sequence numbers with conditional object creation; a writer that loses a claim retries. Writes for a collection share a log partition and are replayed in order. This lets several processes append without overwriting each other's log entries. A conditional schema claim also makes concurrent first writes agree on a collection's dimension and distance metric before they are logged. This ordering does not provide a transaction spanning multiple collections, and contention or an unavailable store can still cause a write to fail.
  2. Assigning background work. Persistence turns the log into segments; maintenance compacts and builds indexes. Bucket-backed leases select an active worker for each guarded job while others stand by. A standby can acquire an expired lease if its holder disappears. The default lease lifetime is 30 seconds, with takeover also depending on polling and storage latency. Lease safety relies on bounded clock skew and stopping guarded work when the lease is no longer valid. This is distinct from the ability of several nodes to accept application writes.
  3. Assigning collections to serving nodes. Optional -node-id and -peers configuration enables deterministic collection placement using rendezvous hashing. Every node must agree on membership. In the 0.7.1 server CLI, each collection has one assigned owner in this mode; requests to another node return an owner hint, surfaced as HTTP 421 or gRPC FailedPrecondition. The client or gateway must route accordingly. This distributes collections across a fleet; it does not split every query across all nodes.

The Go service layer also has configurable owner sets, draining and load-based expansion of a busy collection's owner set. These are integration controls: the 0.7.1 server CLI does not expose all of them, and its peer configuration is static. Fleet discovery, membership changes and machine autoscaling need an external deployment or control layer. The architecture guide describes the placement model.

What happens when the next read goes to another node?

An acknowledgement establishes that the write reached the store. Another serving node may still be catching up. Writable nodes merge a log-tail overlay with persisted search results, but remote writes are discovered by polling. After an idle period, that polling interval can reach 10 seconds, plus storage latency; a successful write in the same process wakes its own readers immediately.

For a workflow such as “the worker writes a document, then an agent on another host reads it,” Polign 0.7.1 provides an explicit HTTP contract. Store-backed, cold-first writes return X-Polign-Write-Token. Send that receipt as X-Polign-Require-Write-Token on a subsequent point read or vector-only query. The receiving node must establish that its serving state covers the write before answering.

The coverage check waits up to five seconds, or the client's earlier cancellation. If coverage cannot be established, it returns 503 with a retry hint instead of silently answering from state that predates the write. A read-only node can satisfy the token once its loaded persisted index includes that write. The token does not accelerate replication or freeze a snapshot: later updates and deletes can still be visible, and approximate search can still miss a neighbour. This contract currently covers HTTP point reads and vector-only queries, not text/hybrid queries or gRPC convenience calls.

These capabilities were checked against the 0.7.1 release source and documentation. The measurements below test a single local serving node. They do not measure fleet scaling, lease takeover, cross-host freshness or object-store performance.

What we measured on one machine

At about 0.98 recall, Polign's p50 latency was 4.5 ms and DuckDB's was 5.8 ms. With eight clients, Polign served 650 queries a second and DuckDB 930. The measurements help choose a local serving configuration; the architecture above addresses how the service is shared and operated.

What we measured

Cohere 1M: exactly 1,000,000 vectors, 768 dimensions, cosine distance, on a machine with 12 CPU cores and 24 GiB RAM. Polign 0.7.1 from its release tag, serving a local directory store (fs:) with the hot tier off. DuckDB 1.5.5 with VSS build b833341, default HNSW construction settings and a persistent database file. We built both indexes from scratch and recomputed exact ground truth for 1,000 top-10 queries. Search ran before either system received additional writes. Nothing here was measured against object storage.

Loading the data

Step DuckDB Polign
Load and build 2 m 54 s 5 m 49 s
What is included 23 s loading Arrow into FLOAT[768]; 2 m 31 s building HNSW; checkpoint IVF-PQ training, import, compaction and packing
On disk 7.54 GiB
table plus index
6.35 GiB
store files, including full vectors and PQ codes

These are one-time builds of the same corpus. DuckDB takes 2 m 54 s from an Arrow table through a checkpointed HNSW index. Polign takes 5 m 49 s from the binary vector file through trained and packed IVF-PQ segments. Each system retains the full vectors as well as its search index. Build and disk-size measurements are single trials.

Search latency and recall

Each row pairs a DuckDB setting with a Polign setting at similar measured recall, ordered from lower to higher recall. These are approximate matches; the exact recall for each engine is shown. The final row compares the highest recall tested for each. Latency is one client issuing queries serially. Throughput comes from separate 15-second runs with eight client threads.

Recall comparison DuckDB Polign
~0.94–0.95 ef_search 64 (default)
Recall@10: 0.9397
p50 / p99: 4.4 / 6.7 ms
QPS, 8 clients: 1,111
nprobe 32
Recall@10: 0.9482
p50 / p99: 2.9 / 5.4 ms
QPS, 8 clients: 967
~0.98 ef_search 256
Recall@10: 0.9789
p50 / p99: 5.8 / 8.2 ms
QPS, 8 clients: 930
nprobe 64
Recall@10: 0.9786
p50 / p99: 4.5 / 7.5 ms
QPS, 8 clients: 650
~0.99 ef_search 512
Recall@10: 0.9886
p50 / p99: 9.6 / 15.8 ms
QPS, 8 clients: 710
default (nprobe 124)
Recall@10: 0.9904
p50 / p99: 7.3 / 10.6 ms
QPS, 8 clients: 374
Highest tested recall ef_search 1024
Recall@10: 0.9933
p50 / p99: 10.9 / 20.5 ms
QPS, 8 clients: 511
nprobe 250
Recall@10: 0.9962
p50 / p99: 13.2 / 17.5 ms
QPS, 8 clients: 212

Approximate-search entries are medians of three rounds of 1,000 queries per setting, with shuffled setting order and alternating engine order. QPS is also the median of three runs. The two remaining sweep settings are shown below to preserve the full results; they are not a matched pair.

Additional setting Recall@10 p50 p99 QPS, 8 clients
DuckDB, ef_search 128 0.9675 5.0 ms 8.6 ms 1,085
Polign, nprobe 128 0.9910 7.5 ms 11.2 ms 367

DuckDB without an index (exact scan) reached 1.0000 recall, with 294 ms p50 and 632 ms p99. This was a fresh single run of 20 queries before index creation; its p99 has a small sample and its throughput was not measured.

At roughly 0.98 recall, Polign nprobe 64 reaches 0.9786 recall at 4.5 ms p50; DuckDB ef_search 256 reaches 0.9789 at 5.8 ms. With eight clients the same settings serve 650 and 930 queries a second respectively. Single-query latency and throughput under concurrent load are separate measurements; choose the column that matches your application's traffic.

At those settings, p50 across the three rounds ranged from 4.5 to 5.3 ms for Polign and 5.6 to 5.8 ms for DuckDB. Eight-client throughput ranged from 624 to 667 QPS for Polign and 861 to 992 QPS for DuckDB.

Polign's automatic setting probes 124 of the 1,000 IVF cells and re-ranks 100 candidates against their full vectors. It reaches 0.9904 recall here. The explicit 250-probe setting reaches 0.9962. DuckDB's largest tested ef_search, 1024, reaches 0.9933. These are measured points on each system's speed/recall curve, not their maximum possible recall. Neither HNSW construction parameters nor Polign's rerank pool were swept.

DuckDB queries use precomputed vector literals in SQL and the verified HNSW_INDEX_SCAN plan. Polign queries use precomputed Python vector lists over gRPC. Timings include request execution and result handling, but exclude construction of those literals and lists. This is a comparison of these application paths, not isolated index kernels.

Memory and startup

Moment DuckDB Polign
Open/start to first answer 3.66 s 1.14 s
RSS after first answer (median and range) 1.05 GiB
1.04–5.93 GiB across starts
87 MiB
86–88 MiB across starts
RSS after eight-client load 3.50–3.96 GiB 0.61–0.67 GiB

Startup and first-answer RSS are medians of three fresh processes using each engine's default search setting. DuckDB's timer includes opening the file, loading VSS and answering the query. Polign's includes starting the server, connecting and answering. OS caches were not cleared, so these are warm-cache starts, not cold-disk measurements.

RSS means resident process memory. The DuckDB measurement includes its Python host; the Polign measurement is the server alone. Polign's separate Python client used 87–90 MiB under load. The load ranges span samples after all settings in all three rounds; they are not peak measurements. Neither figure includes the operating system's file cache or compressed-out pages. macOS memory compression was active during this run. We did not impose a memory limit or isolate the machine from its other applications.

DuckDB's VSS documentation says its HNSW index must fit in RAM and is outside the buffer manager and memory_limit. Polign reads the probed cells' compressed codes and fetches full vectors for reranking. Its segment cache was the default 256 MiB. The measured RSS difference is specific to this corpus and cache configuration; it does not establish a minimum machine size or how memory grows at larger scales.

Writes and durability

1,000 single-record writes, one client DuckDB Polign
p50 / p99 3.3 / 11.0 ms 10.4 / 80.3 ms
Writes per second, serial 259 56
Last write found by next search yes yes
Inserted records found as top-1 at default search settings 991 / 1,000 1,000 / 1,000
Stored records verified byte-for-byte 1,000 / 1,000 1,000 / 1,000

This table uses a matched batch of 1,000 new vectors, added after an initial 1,000-write batch: both systems start this trial with 1,001,000 records. DuckDB used precomputed SQL literals, one autocommit insert per row into the indexed table; Polign used one gRPC put per row. All inserted records were present with exact float32 payloads in both systems. The table separately reports whether approximate search returned each vector as its own top-1 result at the default settings. An ANN miss is distinct from a missing stored record. Both engines answered a query before write timing started. These write timings are one run, including client overhead.

The Python call path matters. In the initial matched batch, DuckDB inserts using bound vector parameters measured 37.9 ms p50 and 26 writes/s; Polign measured 10.1 ms and 55 writes/s. The table uses the literal path to avoid making Python parameter binding the headline database comparison. Literal formatting is outside the timer, as in the search test; scientific notation forces DOUBLE literals before the FLOAT cast to preserve the input float32 values. Both batches' stored vectors were checked byte-for-byte. These are application-path timings, not isolated storage-engine timings.

Polign appends a write to the configured store's log before acknowledging it. DuckDB uses its write-ahead log, but its VSS persistence documentation identifies incomplete recovery support for custom indexes and possible index corruption or data loss after an unexpected shutdown with uncommitted changes. Persisting HNSW requires hnsw_enable_experimental_persistence = true. We enabled it and did not crash-test DuckDB.

Using both

DuckDB can export ids and vectors as Parquet for Polign to import:

-- in DuckDB
COPY (SELECT 'doc-' || id AS id, v FROM items) TO 'items.parquet' (FORMAT parquet);

# then load it into a Polign store
polign-import -store fs:./memory -collection items \
  -metric cosine -vector-column v items.parquet

We ran this path on 20,000 benchmark rows and checked every imported id and vector byte-for-byte. This makes it possible to keep SQL analysis in DuckDB and serve a shared vector collection through Polign.

How we ran it

Build and write timings are single trials; the repeated search results describe variability on this machine with one index build per engine.

What this test does not cover

Try it

Start with the shape of the application: who writes, which machines read, how fresh those reads need to be, and where the durable data should live. DuckDB is a useful home for embedded SQL analysis and vector search. Polign provides the shared serving layer, storage-backed coordination and retrieval APIs described here. Then use the benchmark to pick an initial search setting and measure your own workload. The getting started guide begins with a local Polign store; the architecture guide explains the serving model.