Measuring how vector quantization error propagates through a Retrieval-Augmented Generation pipeline.
This project integrates TurboQuant — a theoretically grounded online vector quantization algorithm — into a full RAG pipeline and measures the compression-quality tradeoff at every stage: from raw MSE distortion, through retrieval recall, to downstream QA exact match. It provides the first empirical Pareto frontier of compression ratio vs QA accuracy for TurboQuant on MS-MARCO + NQ-open.
Course project — AMS 691, Spring 2026.
The TurboQuant paper benchmarks quantization quality using ANN recall on embedding datasets. What it does not measure is whether those quantization-level improvements actually matter for the task that embeddings are built for: answering questions. This project fills that gap by running a controlled experiment where quantization error is the only variable and the downstream metric is QA exact match.
| Method | Description | Compression |
|---|---|---|
| FP32 Oracle | Exact FAISS IndexFlatIP, no quantization | 1× |
| TurboQuant-MSE | Random orthogonal rotation + Lloyd-Max scalar quantization (MSE-optimal, Algorithm 1) | 8–32× |
| TurboQuant-IP | Two-stage QJL (1-bit random projection) + MSE residual quantizer (IP-optimal, Algorithm 2) | 8–16× |
| FAISS-PQ | Product Quantizer baseline (M=48 subspaces, 8 bits/centroid) | 32× |
Bit widths swept: b ∈ {1, 2, 3, 4} for TurboQuant variants.
MS-MARCO 500k passages
│
▼
all-MiniLM-L6-v2 embeddings (dim=384, L2-normalised)
│
├──► TurboQuant-MSE encode ┐
├──► TurboQuant-IP encode ├── RetrievalIndex (unified interface)
├──► FAISS-PQ encode │
└──► FP32 store ┘
│
▼
NQ-open validation queries (1 000 questions)
│
▼
Retrieve top-5 passages (Recall@{1,5,10}, MRR@10)
│
▼
Flan-T5-base reader → answer generation
│
▼
Exact Match, Token F1
All experiments run on an NVIDIA L4 GPU (24 GB VRAM, GCP us-central1-a).
| Method | Bits | Compression | Recall@10 | EM | EM 95% CI |
|---|---|---|---|---|---|
| FP32 Oracle | 32 | 1× | 0.0490 | 0.082 | [0.065, 0.099] |
| TurboQuant-MSE | 4 | 8× | 0.0490 | 0.085 | [0.068, 0.102] |
| TurboQuant-MSE | 3 | 10.7× | 0.0482 | 0.081 | [0.064, 0.098] |
| TurboQuant-MSE | 2 | 16× | 0.0486 | 0.077 | [0.060, 0.094] |
| TurboQuant-MSE | 1 | 32× | 0.0482 | 0.076 | [0.060, 0.092] |
| TurboQuant-IP | 4 | 8× | 0.0478 | 0.079 | [0.062, 0.096] |
| FAISS-PQ | 8 | 32× | 0.0468 | 0.076 | [0.060, 0.092] |
TurboQuant-MSE 4-bit achieves statistically equivalent QA accuracy to the FP32 oracle (overlapping 95% CIs) at 8× compression and 8.7× lower query latency.
| Bits | MSE_norm | Drop vs previous |
|---|---|---|
| 1 | 0.363 | — |
| 2 | 0.117 | 3.1× |
| 3 | 0.034 | 3.4× |
| 4 | 0.009 | 3.8× |
Distortion decreases ~3.5× per bit — consistent with the 6 dB/bit bound from quantization theory, validating the Lloyd-Max codebook.
| Method | Query latency | vs Oracle |
|---|---|---|
| FP32 Oracle (FAISS, CPU) | 3.4 ms | 1× |
| TurboQuant-MSE (GPU matmul) | 0.39 ms | 8.7× faster |
| FAISS-PQ (CPU) | 6.4 ms | 1.9× slower |
Note: TurboQuant ran on GPU; FAISS-PQ on CPU (faiss-gpu unavailable for Python 3.12). Latency comparison is indicative, not directly apples-to-apples.
All plots are in experiments/plots/:
| File | Description |
|---|---|
pareto_frontier_ci.pdf | Compression ratio vs EM with 95% CI error bars |
error_propagation.pdf | Distortion → Recall@10 → EM propagation chain |
distortion_cliff.pdf | QA accuracy vs bit-width with cliff marker |
distortion_validation.pdf | MSE & IP distortion vs bits (compare to TurboQuant paper Fig. 3) |
recall_sweep.pdf | Recall@1/5/10 across all methods × bit-widths |
efficiency.pdf | Memory footprint and query latency |
summary_table_ci.csv | Full metrics table with 95% CIs, ready to paste into paper |
TurboQuantRAG/
├── configs/
│ └── experiment.yaml # all hyperparameters
├── src/
│ ├── quantization/
│ │ ├── codebooks.py # Lloyd-Max codebook (Beta distribution, 200 iters, 2M samples)
│ │ ├── turboquant.py # TurboQuantMSE and TurboQuantIP implementations
│ │ └── faiss_pq.py # FAISS-PQ wrapper and FlatFP32Index oracle
│ ├── retrieval/
│ │ ├── index.py # RetrievalIndex unified abstraction (add/search/profile)
│ │ ├── embedder.py # all-MiniLM-L6-v2 batch embedder
│ │ └── metrics.py # Recall@k, MRR@k
│ ├── data/
│ │ ├── msmarco.py # MS-MARCO v2.1 loader + QA pair loader
│ │ └── nq_open.py # NQ-open validation loader
│ ├── qa/
│ │ ├── reader.py # Flan-T5-base seq2seq reader
│ │ └── metrics.py # Exact Match, Token F1 (SQuAD-style normalisation)
│ └── pipeline.py # RAGPipeline end-to-end class
├── scripts/
│ ├── build_index.py # Step 1: embed 500k passages, save .npy
│ ├── validate_distortion.py # Step 2: compute MSE/IP distortion for all methods × bits
│ ├── run_retrieval.py # Step 3: retrieval sweep, saves retrieval_results.json
│ └── run_qa.py # Step 4: QA sweep, saves qa_results.json
├── notebooks/
│ └── analysis.ipynb # all plots + summary table (run after experiments)
├── experiments/
│ ├── results/ # JSON result files + logs
│ └── plots/ # generated PDFs and CSVs
├── setup_l4.sh # one-shot GPU instance setup script
└── requirements.txt
git clone https://github.com/<your-username>/TurboQuantRAG.git
cd TurboQuantRAG
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
The experiments require a GPU with ≥16 GB VRAM. Tested on NVIDIA L4 (24 GB).
# On the GPU instance
bash setup_l4.sh
setup_l4.sh installs Python 3.12, creates a venv, and installs all dependencies. If faiss-gpu is unavailable for your Python version, install faiss-cpu instead and set device: cpu in configs/experiment.yaml.
Run the four steps in order. Each step caches its output so reruns are fast.
source .venv/bin/activate
# Step 1 — embed 500k passages (~25 min on L4)
python scripts/build_index.py --config configs/experiment.yaml
# Step 2 — distortion validation (~2 min)
python scripts/validate_distortion.py --config configs/experiment.yaml
# Step 3 — retrieval sweep, 5000 NQ-open queries (~5 min on L4)
python scripts/run_retrieval.py --config configs/experiment.yaml --n-queries 5000
# Step 4 — QA sweep, 1000 NQ-open questions (~25 min on L4)
python scripts/run_qa.py --config configs/experiment.yaml --n-questions 1000
Results are written to experiments/results/. Then generate all plots:
jupyter nbconvert --to notebook --execute notebooks/analysis.ipynb \
--output notebooks/analysis_executed.ipynb
(N,d) @ (d,B) → (N,B).Stage 1 — QJL sketch: 1-bit projection sign(Wx) where W ∈ ℝ^{m×d} is a random Gaussian matrix. Hamming distance used as cosine estimator via the matmul-Hamming identity:
agreements = qjl_signed @ q_signed.T # (N, B), avoids (N, m, B) OOM tensor
cos_est = agreements / m
Stage 2 — residual MSE quantization of x − cos_est · x̂.
All quantized retrieval uses vectorised GPU matmul rather than per-query loops:
(N, d) on GPU(N, d) @ (d, B) → (N, B)torch.topk along dim=0 → top-k indices per queryThis reduced 10-combination retrieval from ~80 minutes to ~1 minute on the L4.
Low absolute Recall@10 (~5%). The 500k indexed passages come from the MS-MARCO training split. MS-MARCO validation QA pairs reference passages largely outside this subset. All relative comparisons between methods remain valid; the absolute numbers reflect corpus coverage, not algorithm quality.
TurboQuant-IP bits=1 and bits=2 produce identical results. The QJL first stage dominates the score; the 1-bit vs 2-bit residual codebook makes no measurable difference. This is likely caused by codebook collapse at very low bit widths. Treat IP as a single configuration for b≤2.
Memory bytes constant across TurboQuant bit widths. The memory_bytes() method returns a fixed value regardless of b. True memory scales linearly with bits; the efficiency plot for TurboQuant memory is inaccurate.
Latency comparison is not apples-to-apples. TurboQuant search runs on GPU; FAISS-PQ runs on CPU (faiss-gpu has no Python 3.12 wheel). The 8.7× latency advantage of TurboQuant includes GPU acceleration.
| Package | Purpose |
|---|---|
torch >= 2.1 | GPU tensor ops, matmul-based retrieval |
sentence-transformers >= 2.3 | all-MiniLM-L6-v2 embedder |
transformers == 4.46.3 | Flan-T5-base reader |
faiss-cpu | FlatFP32Index oracle + FAISS-PQ baseline |
datasets >= 2.18 | MS-MARCO v2.1, NQ-open from HuggingFace |
numpy, scipy | Lloyd-Max codebook training |
matplotlib, seaborn | plots |
transformers 5.xhas a known incompatibility (NameError: name 'nn'in accelerate integration). Pin to4.46.3.
If you use this code or results, please cite the original TurboQuant paper:
@article{turboquant2024,
title = {TurboQuant: Online Vector Quantization for High-Quality Embedding Compression},
year = {2024},
url = {https://arxiv.org/abs/2409.09913}
}
And the datasets:
@inproceedings{msmarco,
title = {MS MARCO: A Human Generated MAchine Reading COmprehension Dataset},
author = {Bajaj, Payal and others},
booktitle = {NeurIPS},
year = {2016}
}
@article{nqopen,
title = {Natural Questions: a Benchmark for Question Answering Research},
author = {Kwiatkowski, Tom and others},
journal = {TACL},
year = {2019}
}
Sanskruti Deshmukh — AMS 691, Spring 2026
2 commits
Python
79.6%
Jupyter Notebook
19.1%
Shell
1.3%
Measuring how vector quantization error propagates through a Retrieval-Augmented Generation pipeline.
This project integrates TurboQuant — a theoretically grounded online vector quantization algorithm — into a full RAG pipeline and measures the compression-quality tradeoff at every stage: from raw MSE distortion, through retrieval recall, to downstream QA exact match. It provides the first empirical Pareto frontier of compression ratio vs QA accuracy for TurboQuant on MS-MARCO + NQ-open.
Course project — AMS 691, Spring 2026.
The TurboQuant paper benchmarks quantization quality using ANN recall on embedding datasets. What it does not measure is whether those quantization-level improvements actually matter for the task that embeddings are built for: answering questions. This project fills that gap by running a controlled experiment where quantization error is the only variable and the downstream metric is QA exact match.
| Method | Description | Compression |
|---|---|---|
| FP32 Oracle | Exact FAISS IndexFlatIP, no quantization | 1× |
| TurboQuant-MSE | Random orthogonal rotation + Lloyd-Max scalar quantization (MSE-optimal, Algorithm 1) | 8–32× |
| TurboQuant-IP | Two-stage QJL (1-bit random projection) + MSE residual quantizer (IP-optimal, Algorithm 2) | 8–16× |
| FAISS-PQ | Product Quantizer baseline (M=48 subspaces, 8 bits/centroid) | 32× |
Bit widths swept: b ∈ {1, 2, 3, 4} for TurboQuant variants.
MS-MARCO 500k passages
│
▼
all-MiniLM-L6-v2 embeddings (dim=384, L2-normalised)
│
├──► TurboQuant-MSE encode ┐
├──► TurboQuant-IP encode ├── RetrievalIndex (unified interface)
├──► FAISS-PQ encode │
└──► FP32 store ┘
│
▼
NQ-open validation queries (1 000 questions)
│
▼
Retrieve top-5 passages (Recall@{1,5,10}, MRR@10)
│
▼
Flan-T5-base reader → answer generation
│
▼
Exact Match, Token F1
All experiments run on an NVIDIA L4 GPU (24 GB VRAM, GCP us-central1-a).
| Method | Bits | Compression | Recall@10 | EM | EM 95% CI |
|---|---|---|---|---|---|
| FP32 Oracle | 32 | 1× | 0.0490 | 0.082 | [0.065, 0.099] |
| TurboQuant-MSE | 4 | 8× | 0.0490 | 0.085 | [0.068, 0.102] |
| TurboQuant-MSE | 3 | 10.7× | 0.0482 | 0.081 | [0.064, 0.098] |
| TurboQuant-MSE | 2 | 16× | 0.0486 | 0.077 | [0.060, 0.094] |
| TurboQuant-MSE | 1 | 32× | 0.0482 | 0.076 | [0.060, 0.092] |
| TurboQuant-IP | 4 | 8× | 0.0478 | 0.079 | [0.062, 0.096] |
| FAISS-PQ | 8 | 32× | 0.0468 | 0.076 | [0.060, 0.092] |
TurboQuant-MSE 4-bit achieves statistically equivalent QA accuracy to the FP32 oracle (overlapping 95% CIs) at 8× compression and 8.7× lower query latency.
| Bits | MSE_norm | Drop vs previous |
|---|---|---|
| 1 | 0.363 | — |
| 2 | 0.117 | 3.1× |
| 3 | 0.034 | 3.4× |
| 4 | 0.009 | 3.8× |
Distortion decreases ~3.5× per bit — consistent with the 6 dB/bit bound from quantization theory, validating the Lloyd-Max codebook.
| Method | Query latency | vs Oracle |
|---|---|---|
| FP32 Oracle (FAISS, CPU) | 3.4 ms | 1× |
| TurboQuant-MSE (GPU matmul) | 0.39 ms | 8.7× faster |
| FAISS-PQ (CPU) | 6.4 ms | 1.9× slower |
Note: TurboQuant ran on GPU; FAISS-PQ on CPU (faiss-gpu unavailable for Python 3.12). Latency comparison is indicative, not directly apples-to-apples.
All plots are in experiments/plots/:
| File | Description |
|---|---|
pareto_frontier_ci.pdf | Compression ratio vs EM with 95% CI error bars |
error_propagation.pdf | Distortion → Recall@10 → EM propagation chain |
distortion_cliff.pdf | QA accuracy vs bit-width with cliff marker |
distortion_validation.pdf | MSE & IP distortion vs bits (compare to TurboQuant paper Fig. 3) |
recall_sweep.pdf | Recall@1/5/10 across all methods × bit-widths |
efficiency.pdf | Memory footprint and query latency |
summary_table_ci.csv | Full metrics table with 95% CIs, ready to paste into paper |
TurboQuantRAG/
├── configs/
│ └── experiment.yaml # all hyperparameters
├── src/
│ ├── quantization/
│ │ ├── codebooks.py # Lloyd-Max codebook (Beta distribution, 200 iters, 2M samples)
│ │ ├── turboquant.py # TurboQuantMSE and TurboQuantIP implementations
│ │ └── faiss_pq.py # FAISS-PQ wrapper and FlatFP32Index oracle
│ ├── retrieval/
│ │ ├── index.py # RetrievalIndex unified abstraction (add/search/profile)
│ │ ├── embedder.py # all-MiniLM-L6-v2 batch embedder
│ │ └── metrics.py # Recall@k, MRR@k
│ ├── data/
│ │ ├── msmarco.py # MS-MARCO v2.1 loader + QA pair loader
│ │ └── nq_open.py # NQ-open validation loader
│ ├── qa/
│ │ ├── reader.py # Flan-T5-base seq2seq reader
│ │ └── metrics.py # Exact Match, Token F1 (SQuAD-style normalisation)
│ └── pipeline.py # RAGPipeline end-to-end class
├── scripts/
│ ├── build_index.py # Step 1: embed 500k passages, save .npy
│ ├── validate_distortion.py # Step 2: compute MSE/IP distortion for all methods × bits
│ ├── run_retrieval.py # Step 3: retrieval sweep, saves retrieval_results.json
│ └── run_qa.py # Step 4: QA sweep, saves qa_results.json
├── notebooks/
│ └── analysis.ipynb # all plots + summary table (run after experiments)
├── experiments/
│ ├── results/ # JSON result files + logs
│ └── plots/ # generated PDFs and CSVs
├── setup_l4.sh # one-shot GPU instance setup script
└── requirements.txt
git clone https://github.com/<your-username>/TurboQuantRAG.git
cd TurboQuantRAG
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
The experiments require a GPU with ≥16 GB VRAM. Tested on NVIDIA L4 (24 GB).
# On the GPU instance
bash setup_l4.sh
setup_l4.sh installs Python 3.12, creates a venv, and installs all dependencies. If faiss-gpu is unavailable for your Python version, install faiss-cpu instead and set device: cpu in configs/experiment.yaml.
Run the four steps in order. Each step caches its output so reruns are fast.
source .venv/bin/activate
# Step 1 — embed 500k passages (~25 min on L4)
python scripts/build_index.py --config configs/experiment.yaml
# Step 2 — distortion validation (~2 min)
python scripts/validate_distortion.py --config configs/experiment.yaml
# Step 3 — retrieval sweep, 5000 NQ-open queries (~5 min on L4)
python scripts/run_retrieval.py --config configs/experiment.yaml --n-queries 5000
# Step 4 — QA sweep, 1000 NQ-open questions (~25 min on L4)
python scripts/run_qa.py --config configs/experiment.yaml --n-questions 1000
Results are written to experiments/results/. Then generate all plots:
jupyter nbconvert --to notebook --execute notebooks/analysis.ipynb \
--output notebooks/analysis_executed.ipynb
(N,d) @ (d,B) → (N,B).Stage 1 — QJL sketch: 1-bit projection sign(Wx) where W ∈ ℝ^{m×d} is a random Gaussian matrix. Hamming distance used as cosine estimator via the matmul-Hamming identity:
agreements = qjl_signed @ q_signed.T # (N, B), avoids (N, m, B) OOM tensor
cos_est = agreements / m
Stage 2 — residual MSE quantization of x − cos_est · x̂.
All quantized retrieval uses vectorised GPU matmul rather than per-query loops:
(N, d) on GPU(N, d) @ (d, B) → (N, B)torch.topk along dim=0 → top-k indices per queryThis reduced 10-combination retrieval from ~80 minutes to ~1 minute on the L4.
Low absolute Recall@10 (~5%). The 500k indexed passages come from the MS-MARCO training split. MS-MARCO validation QA pairs reference passages largely outside this subset. All relative comparisons between methods remain valid; the absolute numbers reflect corpus coverage, not algorithm quality.
TurboQuant-IP bits=1 and bits=2 produce identical results. The QJL first stage dominates the score; the 1-bit vs 2-bit residual codebook makes no measurable difference. This is likely caused by codebook collapse at very low bit widths. Treat IP as a single configuration for b≤2.
Memory bytes constant across TurboQuant bit widths. The memory_bytes() method returns a fixed value regardless of b. True memory scales linearly with bits; the efficiency plot for TurboQuant memory is inaccurate.
Latency comparison is not apples-to-apples. TurboQuant search runs on GPU; FAISS-PQ runs on CPU (faiss-gpu has no Python 3.12 wheel). The 8.7× latency advantage of TurboQuant includes GPU acceleration.
| Package | Purpose |
|---|---|
torch >= 2.1 | GPU tensor ops, matmul-based retrieval |
sentence-transformers >= 2.3 | all-MiniLM-L6-v2 embedder |
transformers == 4.46.3 | Flan-T5-base reader |
faiss-cpu | FlatFP32Index oracle + FAISS-PQ baseline |
datasets >= 2.18 | MS-MARCO v2.1, NQ-open from HuggingFace |
numpy, scipy | Lloyd-Max codebook training |
matplotlib, seaborn | plots |
transformers 5.xhas a known incompatibility (NameError: name 'nn'in accelerate integration). Pin to4.46.3.
If you use this code or results, please cite the original TurboQuant paper:
@article{turboquant2024,
title = {TurboQuant: Online Vector Quantization for High-Quality Embedding Compression},
year = {2024},
url = {https://arxiv.org/abs/2409.09913}
}
And the datasets:
@inproceedings{msmarco,
title = {MS MARCO: A Human Generated MAchine Reading COmprehension Dataset},
author = {Bajaj, Payal and others},
booktitle = {NeurIPS},
year = {2016}
}
@article{nqopen,
title = {Natural Questions: a Benchmark for Question Answering Research},
author = {Kwiatkowski, Tom and others},
journal = {TACL},
year = {2019}
}
Sanskruti Deshmukh — AMS 691, Spring 2026
2 commits
Python
79.6%
Jupyter Notebook
19.1%
Shell
1.3%