DuckDB-VSS: LSH-Based VSS Join Optimization

April 17, 2026 · View on GitHub

This project has been forked from the duckdb-vss extension. It accelerates vector similarity search (VSS) joins using Locality Sensitive Hashing (LSH) index.

Problem Statement

The existing vss_join function performs top-k nearest neighbor joins by computing a cross-join between tables A and B. This has two key limitations:

  • Quadratic time complexity — O(|A| × |B|) distance computations, which becomes prohibitive at scale
  • No cost amortization — each row in A performs an independent full scan of B, causing cache thrashing

Solution

We implement lsh_join using random hyperplane LSH with multi-probe neighboring buckets:

  • Hash-based partitioning — vectors are hashed into buckets using K random hyperplanes; similar vectors tend to land in the same bucket
  • Cost amortization — hash tables are built once over the right table and reused for all queries in the left table
  • Near-linear complexity — only candidate vectors from matched buckets are distance-ranked, not the full right table
  • Multi-probe — 1-bit-flip neighbors are also probed to recover similar vectors that fall just across a boundary

Supported metrics: l2sq (Euclidean), cosine / cos (cosine distance), ip (inner product).

Function Signature

lsh_join(
    left_table  VARCHAR,   -- name of the query table
    right_table VARCHAR,   -- name of the corpus table
    left_col    VARCHAR,   -- vector column in left_table
    right_col   VARCHAR,   -- vector column in right_table
    k           INTEGER,   -- number of nearest neighbors to return
    metric      VARCHAR,   -- 'l2sq' | 'cosine' | 'cos' | 'ip'
    num_tables  INTEGER,   -- (optional) number of hash tables L; default 10
    num_bits    INTEGER    -- (optional) bits per hash K; default 10
)

Usage Example:

SELECT * FROM lsh_join('queries', 'corpus', 'vec', 'vec', 10, 'l2sq');

-- Override defaults
SELECT * FROM lsh_join('queries', 'corpus', 'vec', 'vec', 10, 'l2sq',
                       num_tables=20, num_bits=8);

Success Criteria

MetricTargetConfig
Recall@10≥ 85%K=8, L=10, 100K rows, l2sq
Recall@10≥ 90%K=8, L=20, 100K rows, l2sq
Speedup vs brute force≥ 10×K=8, L=10, 100K rows, l2sq

Code Changes

LSH-related code is newly added. The changes to existing codebase are the build system wiring and the extension registration.

New files

FileDescription
src/include/lsh/lsh.hppPublic header: LSHIndex class declaration
src/lsh/lsh_index.hppInternal header: distance functions, TopK, hyperplane layout
src/lsh/lsh_index.cppIndex implementation: build, probe, topK; AVX-512/AVX2 distance kernels
src/lsh/lsh_join.hppTable function header: binds data and global state structs
src/lsh/lsh_join.cppDuckDB table function: bind, global init and execute pipeline
src/lsh/CMakeLists.txtBuild config: AVX-512 / AVX2+FMA flags scoped to lsh_index.cpp
test/sql/lsh/test_lsh_join.testSQL regression tests
benchmark/bench-lsh-join.sqlBenchmark script
benchmark/analyze-results.ipynbNotebook to analyze results
scripts/download-datasets.shDownloads SIFT1M and GloVe-100 datsets
scripts/convert-to-parquet.pyConverts dataset files to Parquet to work with DuckDB

Modified files

FileChange
CMakeLists.txtAdded lsh subdirectory
src/vss_extension.cppRegistered lsh_join table function in the extension entry point

Step 1 — Build the Extension

make

This produces:

./build/release/duckdb                          # DuckDB shell with extension loaded
./build/release/test/unittest                   # test runner
./build/release/extension/vss/vss.duckdb_extension

Run the shell:

./build/release/duckdb

Run the SQL tests:

make test

Step 2 — Download Datasets

bash scripts/download_datasets.sh

Downloads:

DatasetSizeDestination
SIFT1M (128-d geometric vectors)~161 MBbenchmark/data/sift/
GloVe Twitter 27B 100d (text embeddings)~1.4 GBbenchmark/data/glove/

Existing files are skipped automatically.


Step 3 — Convert to Parquet

Requires pyarrow:

pip install pyarrow
# SIFT1M base vectors (1M × 128)
python3 scripts/convert_to_parquet.py \
    --input  benchmark/data/sift/sift_base.fvecs \
    --output benchmark/data/sift/sift_base.parquet \
    --format fvecs

# SIFT1M query vectors (10K × 128)
python3 scripts/convert_to_parquet.py \
    --input  benchmark/data/sift/sift_query.fvecs \
    --output benchmark/data/sift/sift_query.parquet \
    --format fvecs

# SIFT1M ground truth (10K queries × 100 true neighbors)
python3 scripts/convert_to_parquet.py \
    --input  benchmark/data/sift/sift_groundtruth.ivecs \
    --output benchmark/data/sift/sift_groundtruth.parquet \
    --format ivecs

# GloVe-100 embeddings (1.2M × 100)
python3 scripts/convert_to_parquet.py \
    --input  benchmark/data/glove/glove.twitter.27B.100d.txt \
    --output benchmark/data/glove/glove_100d.parquet \
    --format glove_txt

Output schemas:

  • fvecs / glove_txtid INT32, vec FLOAT[]
  • ivecs (ground truth) → query_id INT32, neighbors INT32[]

Step 4 — Run Benchmarks

mkdir -p benchmark/results
./build/release/duckdb -unsigned -init benchmark/bench_lsh_join.sql
FileExperiment
exp1-speedup.csvSpeedup vs brute force at 10K / 100K / 1M rows
exp2-recall-vs-L.csvRecall@10 and query time vs number of hash tables L
`exp3-pareto.csv$\text{Recall}@10 \text{vs} \text{QPS} \text{across} \text{K} \times \text{L} \text{grid}
$exp4-glove.csv`Cross-dataset validation on GloVe-100 (cosine metric)

Note: The 1M-row brute-force run in Experiment 1 can take 10–30 minutes depending on hardware. Comment out the _bf_1m block in the SQL file to skip it.


Step 5 — Analyze Results

cd benchmark
jupyter notebook analyze_results.ipynb
ExperimentFigure
Exp 1Grouped bar chart: BF vs LSH query time across table sizes
Exp 2Bar chart: query time vs L (shows linear cost scaling)
Exp 3Pareto scatter: Recall@10 vs QPS, colored by K
Exp 4aBar chart: BF vs LSH time on GloVe-100
Exp 4bLine plot: Recall@10 vs L on GloVe-100
SummaryPASS / FAIL table for all success criteria