fix(mem_wal): exact PK dedup for LSM vector search and point lookup#6856
fix(mem_wal): exact PK dedup for LSM vector search and point lookup#6856jackye1995 wants to merge 5 commits into
Conversation
…point lookup Same primary key written multiple times into one memtable, or into both a memtable and an older generation, used to leak through to the user as distinct rows. Two paths were affected: - Vector search: HNSW indexes every insert as its own graph node, so KNN could return both V1 and V2 of the same PK from a single source. - Point lookup (active arm): `FilterExec + LIMIT 1` over an insert-ordered scan returned the oldest match among duplicates. Vector search now runs each per-source KNN through `LsmSourceTagExec`, which appends `(_memtable_gen, _freshness)`. A single `LsmGlobalPkDedupExec` over the union picks the row with the largest tuple per PK — newer generations win, ties fall to the normalized within-source order (larger `_rowid` for the active arm; flipped via `u64::MAX - _rowid` for flushed arms to compensate for the reverse-write convention). This replaces the older two-step `WithinSourceDedupExec` + bloom-based `FilterStaleExec` design and is exact (no false-positive recall loss, no top-k under-fill, no missing-bloom footgun). Point lookup keeps a `WithinSourceDedupExec(KeepMaxFreshness)` on the active arm only; `CoalesceFirstExec` already short-circuits cross-source selection so global dedup would conflict. Flushed and base arms still rely on `LIMIT 1` under the reverse-write / forward-write conventions respectively. Removed: `FilterStaleExec`, `GenerationBloomFilter`, and the `LsmVectorSearchPlanner::with_bloom_filter[s]` API — no longer needed now that dedup is exact. New tests pin: duplicate PK within one active memtable (both planners), duplicate PK across generations (vector search), and the partition coalesce ahead of `LsmGlobalPkDedupExec` that keeps active-memtable rows from being silently dropped.
33cf21c to
a6e7d82
Compare
…gh LSM vector search The LSM vector search planner now accepts a base dataset reference and appends a TakeExec after the global PK dedup + sort. This allows the final top-k rows to materialize any user-projected columns that were not part of the per-source KNN output, fetching from the base dataset by _rowid. Also plumbs refine_factor as a parameter on plan_search() so callers can enable base-table refine (over-fetch k*factor candidates, re-rank with exact distances). Memtable arms use exact HNSW and are unaffected. Both Python and Java bindings are updated with the new parameter.
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
…point-lookup benchmarks Remove the duplicated criterion-based mem_wal_read.rs and mem_wal_vector.rs benchmarks. Replace with two standalone CLI benchmarks that produce JSON output for panel-style trend analysis: - mem_wal_vector_bench: KNN search across LSM levels using real 384-dim embeddings from lance-format/fineweb-edu, IVF-RQ base table index, recall verification against brute-force ground truth. - mem_wal_point_lookup_bench: PK-based point lookups across base table, flushed generations, and active memtable. Both accept --flushed-generations (0/1/2) and --max-memtable-rows (100k/500k/1M) for sweeping the full matrix. Results are written as individual JSON files for aggregation.
The hf:// URI scheme for accessing lance-format/fineweb-edu requires network access that may not be available or reliable on all environments. Switch to deterministic synthetic 384-dim embeddings using the same cluster+noise scheme as mem_wal_hnsw_bench.rs. This makes the bench self-contained with no external dependencies.
hamersaw
left a comment
There was a problem hiding this comment.
IIUC this algorithm is basically parallelize top-K from each source and then dedup based on freshness. When exploring this elsewhere we noted the bug where a high ranking row is bumped out of the top-K by an un-fresh row and ends up with incorrect results. For a concrete example:
active memtable
PK=0, _distance=10
PK=1, _distance=11
PK=2, _distance=12
flushed memtable (l0 cache) gen 0
PK=0, _distance=1
PK=3, _distance=2
PK=4, _distance=3
If we we a top-K of 2 and dedup on this we have active memtable returning PK 0, 1 and flushed memtable returning PK 0, 3. the dedup results in keys 0, 3 returned. When really, it should be 3, 4 -- both from flushed memtable.
The various mitigations we discussed were:
(1) overfetching each source: we can have some bound that overfetches - don't love it.
(2) incremental refill: if a lower tier source has keys that are update we re-query it to ensure top-K non-updated keys - don't love it.
TBH I thought the bloom filter approach was reasonable to stop duplicates between sources. My thought was that within a source can we simply add a deletion vector to the flushed memtable (l0 cache) on write so that the read tooling automatically removes duplicates without having to rebuild indexes on write.
|
Good catch — this is a real limitation of per-source KNN with post-merge dedup. I looked at how other systems handle this: Existing approaches:
All production systems either use a unified index (heavy write cost) or push deletion state to old segments at write time (light write cost, but breaks pure LSM). None do pure read-time resolution. Two mitigations we should support: (1) Overfetch (simple, good for latency-sensitive cases) Same approach as Elasticsearch: each source fetches (2) Pre-search bloom filter exclusion node (most accurate) This is a new idea that's strictly better than the old The new approach flips the bloom filter to pre-search: before each source runs its KNN, the bloom filters from all newer generations are combined into an exclusion set and passed into the KNN search. During graph traversal (HNSW or IVF), any candidate whose PK matches the exclusion bloom filter is skipped — it never occupies a top-k slot, so no displacement occurs. Key properties:
The implementation would pass a bloom-filter-backed predicate into each source's |
|
Lots of great information here, thanks for doing the dive. My thoughts:
|
MemWAL LSM Vector Search & Point Lookup Benchmark ResultsBenchmarked the new exact PK dedup pipeline across LSM levels with two storage backends (S3 and local NVMe) and varying memtable configurations. Setup: 1M-row base table with IVF-RQ index (256 partitions, 8-bit), 384-dim vectors. Each run has 1 active memtable at 50% capacity plus 0/1/2 flushed generations on disk. Vector Search (k=10, nprobes=20)Local NVMe (m6id.8xlarge)
S3 (m7i.12xlarge)
Observations: On NVMe, latency is stable (~8ms p50) regardless of memtable count or flushed layers — the LSM merge adds negligible overhead on local disk. On S3, latency is 10-20x higher (80-166ms p50) dominated by network round-trips, with tail latency up to 592ms when flushed layers are added. Point LookupLocal NVMe
S3
Observations: Active memtable lookups are fastest when data is warm in memory (sub-1ms on NVMe, sub-2ms on S3 with large memtables). On NVMe, flushed layer reads are fast (1.4-4.7ms) since flushed datasets are small. On S3, adding flushed layers causes a dramatic slowdown — from ~66ms to ~264-319ms (4-5x) — because each flushed generation is probed over the network. Follow-ups1. Recall verification with real embeddings These benchmarks used synthetic clustered embeddings to isolate performance characteristics. To get meaningful recall@k measurements, we need to run against a real embedding dataset. The key question is whether the exact PK dedup pipeline preserves recall compared to a single-table baseline when memtable HNSW results are merged with IVF-RQ base results across generations. 2. Parallel per-generation probing The current LSM scan probes flushed generations sequentially. This is the primary reason S3 point lookup latency jumps 4-5x when flushed layers are added — each generation requires its own network round-trip, and the cost is additive. When the key lives in the base table (the common case), every flushed generation must be probed and miss before the base is reached. Parallelizing probes (fire all generations concurrently, cancel on first hit for point lookup, merge for vector search) would reduce latency from |
A primary key written multiple times into one active memtable used to leak through to the user as distinct rows: FilterExec + LIMIT 1 over an insert-ordered scan returned the oldest match among duplicates. The active arm now runs a WithinSourceDedupExec(KeepMaxFreshness) that collapses by PK, keeping the freshest row. Flushed and base arms still rely on LIMIT 1 under the reverse-write / forward-write conventions. Split from lance-format#6856 (lance-format#6856). Co-Authored-By: Jack Ye <yezhaoqin@gmail.com>
A primary key written multiple times into one memtable, or into both a memtable and an older generation, used to leak through as distinct rows: HNSW indexes every insert as its own graph node, so KNN could return both V1 and V2 of the same PK from a single source. Each per-source KNN now runs through LsmSourceTagExec, which appends (_memtable_gen, _freshness). A single LsmGlobalPkDedupExec over the union keeps the row with the largest tuple per PK — newer generations win, ties fall to the normalized within-source order. This replaces the bloom-based FilterStaleExec design and is exact (no false-positive recall loss, no top-k under-fill). After global dedup + sort + top-k, a TakeExec materializes any user-projected columns not in the per-source KNN output by fetching from the base dataset via _rowid. plan_search() also accepts refine_factor so callers can enable base-table refine. Exposed in Python and Java bindings. Removes FilterStaleExec and GenerationBloomFilter. Split from lance-format#6856 (lance-format#6856). Co-Authored-By: Jack Ye <yezhaoqin@gmail.com>
|
As discussed offline, I'm splitting this up into focused PRs so we can push the progress forward quickly. I need the benchmark template from this PR to reuse for FTS benchmarking, so I'd like to land the pieces incrementally:
Each split PR carries the relevant commits with |
Split from #6856 — vector-search portion. A primary key written multiple times into one memtable, or into both a memtable and an older generation, used to leak through as distinct rows: HNSW indexes every insert as its own graph node, so KNN could return both V1 and V2 of the same PK from a single source. Each per-source KNN now runs through `LsmSourceTagExec`, which appends `(_memtable_gen, _freshness)`. A single `LsmGlobalPkDedupExec` over the union keeps the row with the largest tuple per PK — newer generations win, ties fall to the normalized within-source order. This replaces the bloom-based `FilterStaleExec` design and is exact (no false-positive recall loss, no top-k under-fill). After global dedup + sort + top-k, a `TakeExec` materializes any user-projected columns not in the per-source KNN output by fetching from the base dataset via `_rowid`. `plan_search()` also accepts `refine_factor` so callers can enable base-table refine. Exposed in the Python and Java bindings. Removes `FilterStaleExec` and `GenerationBloomFilter`. Part of splitting #6856 into focused PRs. Co-authored with @jackye1995. Co-authored-by: Jack Ye <yezhaoqin@gmail.com>
A primary key written multiple times into one active memtable used to leak through to the user as distinct rows: FilterExec + LIMIT 1 over an insert-ordered scan returned the oldest match among duplicates. The active arm now runs a WithinSourceDedupExec(KeepMaxRowAddr) that collapses by PK, keeping the row with the newest row address. Flushed and base arms still rely on LIMIT 1 under the reverse-write / forward-write conventions. PK resolution and row hashing are shared via a new exec::pk module so WithinSourceDedupExec and LsmGlobalPkDedupExec resolve and hash a key identically. Split from lance-format#6856 (lance-format#6856). Co-Authored-By: Jack Ye <yezhaoqin@gmail.com>
A primary key written multiple times into one active memtable used to leak through to the user as distinct rows: FilterExec + LIMIT 1 over an insert-ordered scan returned the oldest match among duplicates. The active arm now runs a WithinSourceDedupExec(KeepMaxRowAddr) that collapses by PK, keeping the row with the newest row address. Flushed and base arms still rely on LIMIT 1 under the reverse-write / forward-write conventions. PK resolution and row hashing are shared via a new exec::pk module so WithinSourceDedupExec and LsmGlobalPkDedupExec resolve and hash a key identically. Split from lance-format#6856 (lance-format#6856). Co-Authored-By: Jack Ye <yezhaoqin@gmail.com>
Split from #6856 — point-lookup portion. A primary key written multiple times into one active memtable used to leak through to the user as distinct rows: `FilterExec + LIMIT 1` over an insert-ordered scan returned the *oldest* match among duplicates. The active arm now runs `WithinSourceDedupExec(KeepMaxFreshness)`, which collapses by PK and keeps the freshest row. Flushed and base arms still rely on `LIMIT 1` under the reverse-write / forward-write conventions. Part of splitting #6856 into focused PRs. Co-authored with @jackye1995. Co-authored-by: Jack Ye <yezhaoqin@gmail.com>
|
Thinking about this the distributed top-K could have a stale reads problem as well. Where we update a row that doesn't fit the filter, return the old row, and it's not deduped because the new one isn't returned in an upstream set. This is a tough problem. |
…rks (#6882) Final piece of the #6856 split. Replaces the duplicated criterion-based `mem_wal_read.rs` and `mem_wal_vector.rs` benchmarks with two standalone CLI benchmarks that emit JSON output for panel-style trend analysis: - `mem_wal_vector_bench`: KNN search across LSM levels with deterministic synthetic 384-dim embeddings, IVF-RQ base table index, and recall verification against brute-force ground truth. - `mem_wal_point_lookup_bench`: PK-based point lookups across the base table, flushed generations, and active memtable. Both accept `--flushed-generations` and `--max-memtable-rows` for sweeping the full matrix; results are written as individual JSON files. This is the reusable bench template I want to extend for FTS benchmarking. Depends on the LSM vector-search API (`with_dataset` / `refine_factor`) landed in #6881, so it's the last of the three split PRs. Part of splitting #6856 into focused PRs. Co-authored with @jackye1995. --------- Co-authored-by: Jack Ye <yezhaoqin@gmail.com>
Duplicate primary keys written into one memtable or across generations leaked through as distinct rows in vector search and point lookup.
Exact PK dedup — replaces the bloom-filter-based
FilterStaleExecwith an exact pipeline:LsmSourceTagExectags each row with(_memtable_gen, _freshness)LsmGlobalPkDedupExecdoes single-pass cross-source PK dedup keeping the freshest row per PKWithinSourceDedupExec(KeepMaxFreshness)on the active armRemoves
FilterStaleExec,GenerationBloomFilter, and the bloom-filter building from transaction commit.Post-rerank TakeExec — the planner now accepts a base
Datasetreference. After global PK dedup + sort + top-k, aTakeExecmaterializes any user-projected columns not in the per-source KNN output by fetching from the base dataset via_rowid.Refine factor plumbing —
plan_search()now acceptsrefine_factorso callers can enable base-table refine (over-fetchk * factorcandidates, re-rank with exact distances). Memtable arms use exact HNSW and are unaffected. Exposed in both Python and Java bindings.