MinHash LSH Index (Near-Duplicate Search)¶
Experimental
This index is an experimental format feature. Its details message and its files may change incompatibly without a separate vote; after such a change the index must be rebuilt, the data itself is never rewritten. The feature is removed if its stabilization vote does not pass. Design discussion: lance#8820.
The MinHash LSH index finds near duplicates of a text: rows whose content is essentially the same as the query with a few edits, such as reposts with a different footer, mirror pages, or copy-pasted records with small changes. It scores rows by the estimated Jaccard similarity of their token shingle sets, a literal measure of overlap that needs no model. Unlike the Full-Text Search index, which ranks rows by term relevance, and unlike a vector index, which finds rows that mean the same thing, this index answers "which rows are copies of this one".
The index is approximate: candidates are found with locality sensitive hashing (LSH) over MinHash signatures and ranked by comparing signatures, so the reported similarity is an estimate, and rows whose similarity is below the index's threshold are usually not found.
High-Level Architecture¶
Every indexed text is turned into a fixed-length signature of num_hashes
values, and the signature is split into num_bands bands whose hashes are
the lookup keys. Two rows become candidates for each other when they share at
least one band key; candidates are then ranked by the fraction of equal
signature values.
text
| tokenize, shingle, hash
v
+-----------------------------------------+
| signature: num_hashes 16-bit values | 1 - (fraction of equal values)
+-----------------------------------------+ = Jaccard distance
| split into num_bands bands, hash each
v
band keys ------------> bands table (candidate lookup: rows sharing a key)
signature ------------> signature table (ranking the candidates)
The index uses Lance's Segmented Index architecture: each segment covers a disjoint group of fragments and holds its own bands table and signature table. Scores depend only on the two signatures being compared, never on corpus statistics, so results from different segments merge exactly.
+----------------------------------------+
| Lance Dataset |
| (Disjoint groups of Fragments 0..N) |
+----------------------------------------+
|
v
+----------------------------------------+
| Segmented Index |
| +-----------+ +-----------+ +-------+ |
| | Segment 1 | | Segment 2 | | ... | |
| | bands + | | bands + | | | |
| | signatures| | signatures| | | |
| +-----------+ +-----------+ +-------+ |
+----------------------------------------+
Index Details¶
message MinHashLshIndexDetails {
/* Number of MinHash values per signature (k): in [1, 4096] and a multiple
* of `num_bands`.
*/
uint32 num_hashes = 1;
/* Number of LSH bands (b), in [1, 256]. Each band covers
* `num_hashes / num_bands` consecutive signature values.
*/
uint32 num_bands = 2;
/* Number of consecutive tokens joined into one shingle; at least 1. A text
* with fewer tokens forms one shingle of all of them.
*/
uint32 shingle_size = 3;
/* The tokenizer, recorded as a full text search index records it and
* rebuilt from this message alone at query time. Required. Fields that only
* shape full text search postings (`with_position`, `block_size`,
* `posting_format_version`, `document_granularity`) are carried unchanged
* and ignored. The details must identify the tokenizer completely, so
* settings the message cannot record (custom stop words, document-level
* text extraction) and tokenizers that load data from outside Lance (the
* jieba and lindera dictionaries) are rejected.
*/
lance.table.InvertedIndexDetails tokenizer = 4;
/* Version of the signature procedure: the token streams of the tokenizers
* that ship with Lance, shingling, hashing, the permutation coefficients
* and their seed, compression and band keys. Bumped whenever any of them
* changes in a way that makes old and new signatures incomparable; a reader
* rejects a version it does not implement. Independent of the index file
* format version.
*/
uint32 signature_version = 5;
}
| Parameter | Default | Effect |
|---|---|---|
num_hashes |
128 | Signature length, 1 to 4096 and a multiple of num_bands. More hashes make the similarity estimate more precise (error about 1 / sqrt(num_hashes)) and the signature table larger (8 + 2 * num_hashes bytes per row). |
num_bands |
16 | Number of bands, 1 to 256. With num_hashes it sets the similarity threshold (1 / num_bands) ^ (num_bands / num_hashes). The bands table costs 12 * num_bands bytes per row. |
shingle_size |
3 | Tokens per shingle. Shorter shingles tolerate more edits but let unrelated texts that share common phrases look alike; 5 suits long documents, 2 very short texts. |
tokenizer |
full text search default, without stemming and stop-word removal | The tokenizer, recorded as the Full-Text Search index records it. Stemming and stop-word removal are off by default because merging different words inflates similarity. |
signature_version |
0 | Version of the signature procedure, including its hash seed. Managed by Lance, not a user parameter. |
Reference points: the probability that a row with true Jaccard similarity J
becomes a candidate is 1 - (1 - J^r)^b with b = num_bands and
r = num_hashes / num_bands.
num_hashes / num_bands |
Threshold | Found at J = 0.9 | Found at J = 0.7 | Bytes per row |
|---|---|---|---|---|
| 64 / 8 | 0.77 | 99% | 38% | 232 |
| 128 / 16 (default) | 0.71 | 99.99% | 61% | 456 |
| 96 / 16 | 0.63 | 100% | 87% | 392 |
Bands of fewer than six values admit a noticeable share of unrelated rows into the candidate set on tables of billions of rows and are better left to offline use.
A query is tokenized from the details alone, so the details must identify the
tokenizer completely: settings the Full-Text Search details cannot record
(custom stop words, document-level text extraction) and tokenizers that load
dictionaries from the deployment (jieba/*, lindera/*) are rejected. For
CJK text, ngram or icu gives deterministic shingles.
Signature Generation¶
The build side and the query side run the same procedure, so a query is comparable with every stored row. The exact procedure, its constants and known-answer vectors are in Version 0 Reference:
- Tokenize the text with the configured tokenizer.
- Form shingles of
shingle_sizeconsecutive tokens. A text with fewer tokens thanshingle_sizeforms one shingle of all its tokens; a text with no tokens (NULL, empty, whitespace only) has no signature, is not indexed, and never appears in results. - Hash every shingle to 64 bits with a fixed seed.
- Apply
num_hashesfixed permutations and keep the minimum of each over all shingles of the text. - Store each minimum as a 16-bit value. A minimum shrinks as the text grows,
so its raw high bits would agree between any two long texts; the stored
value is a multiply-shift hash of the minimum, which keeps the chance that
two different minima collide negligible against the
1 / num_hashesresolution of the estimate.
Band keys are hashes of the num_hashes / num_bands consecutive values of a
band, prefixed with the band number so that the keys of one band are
contiguous.
The estimated Jaccard similarity of two signatures is the fraction of
positions whose values are equal; the reported _distance is
1 - estimated similarity. Every constant of the procedure is fixed by
signature_version, and every segment of an index must carry identical
details, so signatures written by different segments or at query time are
always comparable. Changing a parameter requires rebuilding the index.
Storage Layout¶
Each segment consists of two Lance files:
signatures.lance— one row per indexed document: its_rowidand its signature as a fixed-size list ofnum_hashes16-bit values. The row number is the segment-local document id, so a segment holds at most2^32documents; larger tables use several segments. Rows are fixed width and stored without compression, so one document's signature is a single ranged read at a known offset.bands.lance— one row per (band key, document id) pair, sorted by band key and then document id. A bucket is the run of rows sharing a key. The file is divided into logical pages of a fixed number of rows recorded in the file (4096 as written), and a page table holding the largest key of every page is stored in a global buffer of the file and kept in memory when the index is open, so the pages of any bucket are known without reading the file.
Both files repeat the serialized index details and the file format version as schema metadata; a segment is opened only when both files match the details of the index. The file schemas, metadata keys and the page table encoding are in Version 0 Reference.
Segments, Appends and Merging¶
- Building:
create_indexreads the text column, signs every row and writes one segment. Distributed builds create one uncommitted segment per worker over disjoint fragment sets and commit them together; the commit rejects segments whose details differ. - Unindexed appends: fragments appended after the build are not covered
by any segment.
optimize_indicesadds a segment over them. Until then a query still searches those rows, signing them on the fly with the index parameters, unless the scan asks for indexed rows only (fast_search). - Segment merging: merging segments rebuilds one segment from the stored
signatures without tokenizing the text again — the signatures of the
surviving rows are concatenated, band keys are recomputed and the bands
table is rewritten. A merge whose result would exceed
2^32documents is rejected and the segments stay separate. - Deletes and compaction: deleted rows are filtered at query time; after a compaction the stored row ids are remapped like those of every other scalar index.
Query Evaluation¶
When a search is submitted (nearest = MinHashQuery(text, column)):
- The query text is signed with the parameters recorded in the index, and its band keys are computed.
- In every segment, each band key is looked up through the page table, which yields the pages of the key's bucket; the pages of all buckets are fetched through the page cache, the missing ones with bounded scattered reads. The document ids in the buckets form the candidate set.
- The candidates' signatures are read — from memory when resident, otherwise with scattered reads, or with a sequential scan when the candidates cover a large share of the segment — and each candidate's Jaccard distance to the query is computed. Rows removed by filters or deletions are skipped.
- Each segment keeps its
limitclosest rows; rows not covered by any segment are scored the same way by the query engine. - The per-segment results are merged by distance, and the
limitclosest rows are returned with their_distance.
Compatibility¶
- A reader that does not know this index opens a dataset that has one normally: the index is listed under a name derived from the type URL of its details and is not used by queries. Creating the index sets no reader or writer feature flag.
- A writer that does not create the index changes nothing for readers that know it.
- Dropping the feature is dropping the index: its files live only under the
index's own
_indices/{uuid}/directory, and no data file, manifest field or other index is rewritten.
Version 0 Reference¶
This section is normative for signature_version = 0 and
minhash_lsh_index_version = 0: an independent implementation must reproduce
the signatures, band keys and files defined here exactly. All arithmetic is on
unsigned 64-bit integers modulo 2^64, and every multi-byte value is
little-endian.
Signature Procedure¶
- Tokenize the text with the configured tokenizer. A token is the UTF-8 bytes of the token text the tokenizer emits, in emission order.
- Shingle: a shingle is
shingle_sizeconsecutive tokens joined with the single byte0x1F; a text ofntokens hasn - shingle_size + 1shingles, one starting at every token position. A text with fewer tokens thanshingle_sizebut at least one token forms one shingle of all its tokens. A text with no tokens (NULL, empty, whitespace only, or every token discarded by the tokenizer) has no signature. - Hash every shingle to 64 bits:
x = XXH64(shingle bytes, seed = 42). - Coefficients come from a SplitMix64 generator whose state
sstarts at -
One draw is:
s = s + 0x9E3779B97F4A7C15 z = s z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9 z = (z ^ (z >> 27)) * 0x94D049BB133111EB draw = z ^ (z >> 31)The draws are consumed in this order: for
i = 0 .. num_hashes - 1,a_i = draw() | 1and thenb_i = draw(); after all pairs, fori = 0 .. num_hashes - 1,c_i = draw() | 1. The coefficients depend onnum_hashesonly. 5. Permute and take the minima:m_i = min over all shingles x of (a_i * x + b_i), comparing the full 64-bit values. 6. Compress each minimum to 16 bits:signature[i] = (c_i * m_i) >> 48. 7. Band keys: the signature is split intonum_bandsbands ofr = num_hashes / num_bandsconsecutive values; bandjholdssignature[j * r .. (j + 1) * r)and its key iswhere the band bytes are the band's
rvalues, each written as 2 little-endian bytes, in signature order.
The estimated Jaccard similarity of two signatures is the number of positions
i with equal signature[i] divided by num_hashes; _distance is
1 - estimated similarity, and rows at equal distance are ordered by row id.
Known-Answer Vectors¶
An implementation must reproduce these values, computed with
num_hashes = 4, num_bands = 2, shingle_size = 2, the default tokenizer
and signature_version = 0.
Coefficients:
i |
a_i |
b_i |
c_i |
|---|---|---|---|
| 0 | 0xbdd732262feb6e95 |
0x28efe333b266f103 |
0x5705b8770b3d7dd5 |
| 1 | 0x47526757130f9f53 |
0x581ce1ff0e4ae394 |
0x9e54d738297f77af |
| 2 | 0x09bc585a244823f3 |
0xde4431fa3c80db06 |
0x3474724a775b19bf |
| 3 | 0x37e9671c45376d5d |
0xccf635ee9e9e2fa4 |
0x7e348a0e451650bf |
Text "The quick brown fox" tokenizes to the, quick, brown, fox:
| Shingle bytes | XXH64 |
|---|---|
the 0x1F quick |
0x4c0b0d36203ce55f |
quick 0x1F brown |
0x09c7ae895d82b184 |
brown 0x1F fox |
0x68e7f4b1be7b4c1f |
i |
m_i |
signature[i] |
|---|---|---|
| 0 | 0x0959767d77eafad7 |
0xb00f |
| 1 | 0x464e4457275cd2a1 |
0x59d6 |
| 2 | 0x50f41804abaa5973 |
0x511a |
| 3 | 0x8944bd01067b09e7 |
0xcd2c |
Band keys: 0x00f30f3501d458ea (band 0), 0x01aec5df28d04915 (band 1).
Text "Fox" has one token, fewer than shingle_size, so its only shingle is
fox with XXH64 0x07c0130ab04388d5; the minima are 0x2bb8ade505081afc,
0x0a273c6ff9a78ba3, 0x802f20573838dc35, 0x1ef46a8d372c9605, the
signature is 0x2970 0xd6db 0x65f8 0xadfc, and the band keys are
0x003cf6e803312eca and 0x017c5504fbc83f36.
The texts "" and " \n" have no tokens and therefore no signature.
Signature File Schema¶
signatures.lance holds one row per indexed document. Row i is document
i of the segment; rows are written in document id order and never
reordered.
pa.schema(
[
pa.field("_rowid", pa.uint64(), nullable=False),
pa.field(
"signature",
pa.list_(pa.field("item", pa.uint16(), nullable=False), num_hashes),
nullable=False,
metadata={
"lance-encoding:structural-encoding": "fullzip",
"lance-encoding:compression": "none",
},
),
],
metadata={
"minhash_lsh_details": "<hexadecimal serialized MinHashLshIndexDetails>",
"minhash_lsh_index_version": "0",
},
)
_rowid is the row id of the indexed row as the dataset hands it to the
index: the row address, or the stable row id when the dataset has stable row
ids enabled, like every scalar index. signature is the signature of the
Signature Procedure; its values are never null (the
Lance schema does not record the nullability of a fixed-size list item, so a
reader treats a null value as corruption of the file). The field metadata of
signature selects the full-zip structural encoding without compression, so
every row occupies the same 2 * num_hashes bytes and a reader fetches the
signature of document i by row number as one ranged read.
Bands File Schema¶
bands.lance holds one row per (band key, document id) pair, sorted by
band_key and then doc_id, ascending.
pa.schema(
[
pa.field(
"band_key",
pa.uint64(),
nullable=False,
metadata={"lance-encoding:compression": "none"},
),
pa.field(
"doc_id",
pa.uint32(),
nullable=False,
metadata={"lance-encoding:compression": "none"},
),
],
metadata={
"minhash_lsh_details": "<hexadecimal serialized MinHashLshIndexDetails>",
"minhash_lsh_index_version": "0",
"minhash_lsh_num_docs": "<decimal document count>",
"minhash_lsh_page_rows": "4096",
"minhash_lsh_page_table_buffer": "<decimal global buffer index>",
},
)
band_key is the key of step 7 of the Signature Procedure;
doc_id is the segment-local document id, the row number in
signatures.lance. Both columns are stored without compression, so any run
of rows is one ranged read per column. The file is divided into logical pages
of minhash_lsh_page_rows rows: page p holds rows
[p * page_rows, (p + 1) * page_rows), and the last page may be shorter. A
bucket may span any number of pages.
Schema Metadata¶
| Key | File | Value |
|---|---|---|
minhash_lsh_details |
both | Lower-case hexadecimal encoding of the serialized MinHashLshIndexDetails the segment was built from. A reader decodes it and requires it to describe, field by field, the same details as the index metadata. |
minhash_lsh_index_version |
both | Decimal file layout version; 0 for the layout described here. Independent of signature_version. |
minhash_lsh_num_docs |
bands |
Decimal number of documents of the segment; equals the row count of signatures.lance. |
minhash_lsh_page_rows |
bands |
Decimal number of rows per logical page; positive. Writers use 4096. |
minhash_lsh_page_table_buffer |
bands |
Decimal index of the global buffer of bands.lance that holds the page table. |
Page Table¶
The page table is a global buffer of bands.lance holding
ceil(num_rows / page_rows) little-endian UInt64 values; entry p is the
largest band_key of page p, that is the key of its last row. It is read
when the segment is opened and kept in memory while the index is open. The
bucket of key K lies in the pages from the first page whose entry is >= K
through the first page whose entry is > K (or the last page when there is
none); the pages strictly between hold nothing but that bucket. Both
positions are binary searches over the table, so a bucket's page range is
known without reading the file.
Opening a Segment¶
A reader opens a segment in this order and treats a failed check as corruption of the named file, except where noted:
- Parse the index details and check the ranges of Index Details.
- Open both files. In each, the schema must match its definition above
exactly in field names, types, nullability and list size, which must equal
num_hashes;minhash_lsh_detailsmust be present, decode, validate and equal the index details field by field; andminhash_lsh_index_versionmust be present. A version greater than the one the reader implements is rejected as unsupported, not as corruption. - In
bands.lance,minhash_lsh_page_rowsmust be positive andminhash_lsh_num_docsmust equal the row count ofsignatures.lance. - Read the page table buffer; its length must be a multiple of 8 and its
entry count must be
ceil(num_rows / page_rows)for the row count ofbands.lance.
While answering queries, a page that does not decode to non-null UInt64
and UInt32 columns, and a doc_id that is not below
minhash_lsh_num_docs, are corruption of bands.lance; a signature batch
with a null value is corruption of signatures.lance.
The fragments a segment covers are recorded in the index metadata of the
dataset, never derived from the stored _rowid values (which do not identify
fragments once stable row ids are enabled).