Approximate Text Deduplication — The MinHash LSH Index #8820
Replies: 3 comments 2 replies
|
Really appreciate if you have any thoughts on this @yanghua @Xuanwo @jackye1995 @BubbleCal @wjones127 @westonpace :) |
|
Some questions and comments:
|
|
Hi @BubbleCal, thanks a lot for the review — it materially improved the design. Where we landed (proposal text updated accordingly): 1. Tokenizer — agreed. We'll reuse the FTS tokenizer API/stack, and put the full tokenizer config into the index params (persisted in index details, replayed verbatim at query time). 2. 3. u32 limit — agreed, fixed. 4. Thanks again — happy to hear if these resolutions look good to you, and feel free to ping me anytime if there's anything else worth discussing. |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
1. Requirements & Background
1.1 The problem: exact matching cannot find "near-duplicates"
Datasets are full of records whose content is essentially the same but whose bytes differ. The canonical example:
Dedup by exact hash:
Dedup by set similarity: cut the text into shingles (sliding windows of n consecutive tokens) and treat it as a set,
The catch: Jaccard is a set-vs-set comparison; the naive approach is pairwise — O(n²), infeasible on corpora with hundreds of millions of rows. Even a single query — "find all documents similar to this one" — requires a full scan computing Jaccard row by row. That is exactly what an index should solve.
1.2 Why this matters for Lance
Lance positions itself as the columnar format for ML workloads, and near-duplicate detection is the first step of AI data engineering. Deduplication has been repeatedly shown to directly affect model quality — near-duplicates in training corpora cause memorization, eval-set contamination, and inflated effective data volume. Mainstream LLM data pipelines (e.g. fuzzy dedup of web corpora) use MinHash as the core dedup technique. These workloads land naturally on Lance:
Today Lance users must export data to external tools (Spark MinHashLSH, home-grown pipelines) and write results back — the data leaves the lake once and the semantics fracture once. With a built-in MinHash LSH index, dedup / similarity retrieval becomes one native query:
1.3 How this differs from embedding-based dedup
The two answer different questions. MinHash detects literal duplication — "the same content with a few edits" (reposts with footers, mirror pages, copy-paste with tweaks) — with no model, pure CPU, and an interpretable Jaccard score.
Embedding-based dedup detects semantic duplication — "the same thing said differently" (paraphrases, translations) — and requires a model to embed the whole corpus plus a vector index to maintain.
LLM pipelines may run MinHash first to cheaply remove the bulk of literal duplicates, layering semantic dedup on top as needed. In Lance, semantic dedup is already covered by the existing vector indexes; this design supplies the other half — a model-free, literal near-duplicate index built directly on the text column.
2. Design
2.1 Core principle 1: MinHash — compress a set into a fixed-length signature
Step 1: turn each row of text into a set (tokenize + n-gram sliding-window shingles). Step 2: prepare k mutually independent hash functions π₁..πₖ; each hash function is applied to every element of the set, and the minimum across elements becomes one position of the signature — k functions give k positions:
Why it works (one-line intuition): a hash function amounts to a random ordering over "all possible shingles"; signature[i] records "the front-runner of the set under that ordering (its hash value)". For two sets A and B, signature[i] is equal ⟺ the global front-runner of A∪B under that ordering falls inside A∩B — and that probability is exactly
|A∩B| / |A∪B|, i.e. Jaccard itself. k independent functions are k independent votes, hence:k independent permutations are k independent samples, with error ~ O(1/√k). Text of arbitrary length is compressed into a fixed-length signature that is comparable position-by-position, mergeable, and indexable.
How the values are computed (the persisted contract):
Tokenize with the FTS tokenizer (lower-cased, stemming and stop-word removal off by default), form
shingle_size-token shingles (a text shorter than one shingle is a single shingle; a text with no tokens has no signature and is not indexed), hash each shingle once with xxh64 (seed 42) into a 64-bit base hashx.The k "functions" are k cheap 64-bit linear transforms
πᵢ(x) = aᵢ·x + bᵢ mod 2⁶⁴(aᵢ odd), with(aᵢ, bᵢ)derived deterministically from SplitMix64 seeded by 42. The 64-bit minimummᵢis taken over all shingles.Each minimum is stored as 16 bits:
signature[i] = (cᵢ · mᵢ mod 2⁶⁴) >> 48with a per-position odd multipliercᵢfrom the same generator. This is b-bit MinHash: comparing 16 bits per position instead of 64 keeps the estimator unbiased up to a collision term ≤ 2⁻¹⁵ per position, far below the 1/k resolution of the estimate, and shrinks the signature table fourfold.Why the multiply-shift and not just the high bits of
mᵢ: a minimum over n shingles is an order statistic, not a uniform value — its high 16 bits are all zero with probability1 − e^(−n/65536), so long texts would look identical to each other. Multiply-shift with a random odd multiplier is 2-universal: for any two distinct minima the stored values collide with probability ≤ 2⁻¹⁵ regardless of how small the minima are.Every constant above (seed, the derivation of
aᵢ, bᵢ, cᵢ, the shingle separator, the hash function) is pinned bysignature_version; changing any of them bumps the version so that segments built by different versions can never be compared.2.2 Core principle 2: LSH banding — make similar rows collide on purpose
With signatures we still need to avoid pairwise comparison. The trick: split the signature into b bands of r elements each (k = b × r) and hash each band as a whole into one key; two rows become a candidate pair as soon as any one band key matches. The hit probability
P = 1 − (1 − sʳ)ᵇis an S-shaped curve of the similarity s:Low-similarity rows almost never collide (small candidate set → fast); high-similarity rows almost always collide (high recall). Tuning (b, r) shifts the threshold: with k=64 / b=8 (r=8) it is ≈ 0.77, with k=96 / b=16 (r=6) ≈ 0.63. Lowering r raises recall for mid-similarity pairs but also lets the mass of low-similarity rows (which share common words and boilerplate) into the candidate set — at a billion rows r=4 admits ~10⁻⁴ of them, i.e. 10⁵ candidates per query — so r=6..8 is the practical range.
2.3 Why retrieval needs a refine step
Banding only answers "who collided" — it produces no score: all candidates look alike, cannot be ranked, and the middle of the S-curve produces false positives. Retrieval is therefore always two-phase:
This dictates that the index must store two kinds of data — which is where the file design below comes from.
2.4 Index file design
Each index segment consists of two files (both standard Lance files):

Two key trade-offs:
2.5 End-to-end flow
Write (index build / delta):
Query (top-k):

The IO upper bound of a typical query is crisp: one scattered read of ≤ 2b band pages (cold) + one batched candidate-signature read; everything else stays in memory, and a prewarmed segment answers from memory alone. Step ⑤ deserves emphasis: the estimated Jaccard depends only on the two signatures themselves and on no global statistics, so top-k merging across segments is exact by nature — cleaner multi-segment semantics than corpus-statistics-based scoring (e.g. BM25).
Cache residency: a query caches the band pages it reads, so pages warm up on demand; signatures are only brought in by
prewarm, chunk by chunk (as many as the index cache holds — the cache reports its capacity), then band pages if room remains. Residency is therefore proportional to the cache, never all-or-nothing: with a 32 GiB cache a 100M-row segment answers in ~11 µs at the index layer; with 8 GiB (60% of the chunks) the median stays there while the tail pays one scattered read. On an object store the resident numbers are the same and every non-resident access costs one round trip (§4).2.6 Parameters
num_hashes(k)num_bands(b)shingle_sizetokenizerlance.table.InvertedIndexDetails;custom_stop_wordsandlance_tokenizerare rejected because the details message cannot record them, and a query must rebuild the tokenizer from the index aloneThere is no
seedparameter: the hash seed (42) and every derived constant are part ofsignature_version, so an index is fully described by the four parameters above plus that version.2.7 Format contract
The durable contract — what a reader needs to answer queries against files written by any conforming writer — is small and lives in
protos/index.protoanddocs/src/format/index/scalar/minhash_lsh.md(submitted as a dedicated format PR ahead of the implementation, per the format-change process):signatures.lance_rowid: u64,signature: FixedSizeList<u16, num_hashes>(full-zip structural encoding, no compression, so row i = doc_id i sits at a fixed offset); schema metadataminhash_lsh_params(JSON of the four parameters) andminhash_lsh_index_versionbands.lanceband_key: u64,doc_id: u32(no compression), sorted by (band_key, doc_id); schema metadataminhash_lsh_params,minhash_lsh_index_version,minhash_lsh_num_docs,minhash_lsh_page_rows(4096) andminhash_lsh_page_table_buffer(index of the global buffer holding one little-endian u64 max key per page). A reader takes all of its metadata from this file_distance = 1 − (equal positions / num_hashes), ties broken by row idfragment_bitmapnames the fragments it indexes; rows outside every segment are not in the index (§3.2 says what the query does about them); a segment opened after a deferred compaction remaps its stored row ids through the fragment-reuse index before any mask, filter or rebuild sees themindex_version(file layout) andsignature_version(signature semantics) are independent; both are 0 while the format is unstable; a reader rejects a segment whoseindex_versionis newer than it knows or whosesignature_versiondiffers from its ownThis supersedes the layout sketched in the first draft of the discussion (posting rows per bucket, u32 signatures).
3. Integration with Lance's Index Framework
3.1 Index build/load: existing paths, minimal new machinery
MinHashLshIndexDetailsinprotos/index.proto(§2.7)add_pluginline inIndexPluginRegistry; plugin nameMinHashLshBasicTrainer: the usualvalue + _rowidstream in, the two index files outIndexStoreinto_indices/{uuid}/, isomorphic to every scalar indexcreate_scalar_index(column, IndexConfig(index_type="minhashlsh", parameters={...}))through the existing generic entry — no binding changesTwo trait methods were added with default implementations, so no existing plugin or cache changes:
ScalarIndexPlugin::validate_new_segments_against_existing(the segment-consistency gate of §3.4) andCacheBackend::capacity_bytes(letsprewarmsize residency to the cache).3.2 Top-k query: under the
nearestsimilarity-search, zero new APIsMinHash retrieval is, at its core, similarity top-k — isomorphic to vector search (ranked by similarity, k is the only semantic parameter, scores independent of corpus statistics) — not text-relevance search (BM25). It therefore hangs under the
nearestumbrella, with no new entry point, only one new query object:The result column follows
_distance(= 1 − estimated Jaccard; Jaccard distance is a true metric, lower-is-better matches vector-search convention, anddistance_rangecan later provide "similarity ≥ threshold" range queries for free).The only thing a user has to learn is one class name. The implementation side is purely additive as well — one new peer exec node on the query side, the existing plugin machinery on the index side:
Three semantic guarantees, each with a clear anchor point in the source:
FtsQueryenum, so it can never end up insideBooleanQuery/BoostQuerybeing summed or max-ed with BM25 scores (different scales) — no validation code needed; setting it together with a vector or FTS query is an explicit plan-time error.optimize_indiceshas run;fast_search=Trueopts into index-only results (the weak-consistency mode vector search already offers). A column with no MinHash index at all is an explicit error naming the required index type — never a silent full scan.Existing FTS and vector query objects, exec nodes, and behavior are untouched.
3.3 Lifecycle: the standard segment mechanism
The segmented lifecycle of existing scalar indexes applies directly, with nothing special-cased:
signatures.lanceis streamed (rows of retired fragments dropped through the framework's old-data filter, row ids rewritten through a compaction mapping when remapping), band keys are recomputed from the stored signatures, and the external sort writes the new segment. The text is never tokenized again, so merging costs the same as writing the index once, and one pipeline servesoptimize_indices, explicit segment merges and post-compaction remaps alike;_rowid(row address or stable row id). Deleted rows are masked at query time; after a deferred compaction the segment is opened with the fragment-reuse index, and the stored row ids are remapped through it both when answering queries and before any rebuild applies its filter — so a merge after a deferred compaction keeps every live row;fragment_bitmapdeclares the fragments it indexes; rows outside every segment's coverage are served by the flat node (§3.2) until the next optimize.3.4 Distributed indexing & parameter consistency
Parameter consistency (the precondition for comparability):
signature_versionin itsMinHashLshIndexDetails, and the index is only comparable if all segments agree; that agreement is enforced rather than assumed:validate_new_segments_against_existing, with a byte-equality fast path) — parameter drift is stopped at write time instead of surfacing as silently wrong query results. Comparing parsed parameters rather than bytes keeps a segment written by a newer Lance that serializes an extra default field from being mistaken for drift.Distributed build: the initial full build assigns fragments to workers, each producing an uncommitted segment; a final commit validates and atomically registers them:
Workers need zero coordination and zero communication — doc_ids are segment-local and the hash functions derive deterministically from the shared parameters, so there is no global structure to exchange; scores depend on no corpus statistics, so cross-segment top-k merging is exact by nature. Incremental builds commit new delta segments for newly added fragments only; existing segments are retained automatically.
Scale limit: doc_ids are segment-local u32 (a cap of 4.29B rows per segment — a 1.1 TB signature table at k=128, far beyond any sane single-segment size). The build validates the row count and fails with clear guidance to build fragment-scoped uncommitted segments and commit them together — the same mechanism as the distributed flow above, equally usable on a single machine. Refine never materializes a whole segment: resident chunks are scored in memory, the rest in bounded scattered batches or one streaming pass (§2.5 ④), so query memory does not grow with the segment or with the size of a duplicate cluster.
4. Measured (reference implementation)
Data. Synthetic corpus modeled on natural text so the index meets realistic buckets and edits: Zipf word frequencies over a 50K vocabulary; text lengths of 6–240 words (30% snippets, 60% paragraphs, 10% articles, mean 40); a third of the texts carry one of 200 shared boilerplate phrases; 20% of the texts are near duplicates of an earlier text with heavy-tailed cluster sizes (up to ~300K copies at 1B) and edits from exact copies to 30% replaced words plus truncations. Every document is a pure function of its id, so the source of any near duplicate and its exact Jaccard are known without reading the dataset. 1M, 100M and 1B rows (210 GB of text at 1B), k=64 / b=8 / 3-token shingles, 200 queries per measurement, k=10.
Machine. 64-core Xeon 8457C, 247 GB RAM, one ~190 MB/s virtual disk (local), and Volcengine ToS S3 in the same region (cloud; index and data on the bucket, spill on the local disk). The OS page cache is dropped before every phase.
All reactions