LeetLLM
My PlanLearnGlossaryTracksPracticeBlog
LeetLLM

Your go-to resource for mastering AI & LLM systems.

Product

  • Learn
  • Glossary
  • Tracks
  • Practice
  • Blog
  • RSS

Legal

  • Terms of Service
  • Privacy Policy

© 2026 LeetLLM. All rights reserved.

All Topics
Your Progress
0%

0 of 196 articles completed

🛠️Computing Foundations0/9
Git, Shell, Linux for AIDocker for Reproducible AIPython for AI EngineeringNumPy and Tensor ShapesCUDA for ML TrainingMPS & Metal for ML on MacData Structures for AISQL and Data ModelingAlgorithms for ML Engineers
📊Math & Statistics0/9
Gradients and BackpropVectors, Matrices & TensorsLinear Algebra for MLAdam, Momentum, SchedulersProbability for Machine LearningBayesian Inference from EvidenceStatistics and UncertaintyDistributions and SamplingHypothesis Tests, Intervals, and pass@k
📚Preparation & Prerequisites0/13
Neural Networks from ScratchCNNs from ScratchTraining & BackpropagationSoftmax, Cross-Entropy & OptimizationRNNs, LSTMs, GRUs, and Sequence ModelingAutoencoders and VAEsThe Transformer Architecture End-to-EndLanguage Modeling & Next TokensFrom GPT to Modern LLMsPrompt Engineering FundamentalsCalling LLM APIs in ProductionFirst AI App End-to-EndThe LLM Lifecycle
🧮ML Algorithms & Evaluation0/15
Linear Regression from ScratchLogistic Regression and MetricsDecision Trees, Forests, and BoostingReinforcement Learning BasicsValidation and LeakageStatistical Learning and GeneralizationConformal Prediction and UncertaintyClustering and PCAProbabilistic Generative ModelsCore Retrieval AlgorithmsDecoding AlgorithmsExperiment Design and A/B TestingCausal Inference and CounterfactualsPyTorch Training LoopsDataset Pipelines and Data Quality
📦Production ML Systems0/6
Feature Engineering for Production MLBatch and Streaming Feature PipelinesGradient Boosted Trees in ProductionRanking and Recommendation SystemsForecasting and Anomaly DetectionMonitoring Predictive Models
🧪Core LLM Foundations0/8
The Bitter Lesson & ComputeBPE, WordPiece, and SentencePieceStatic to Contextual EmbeddingsPerplexity & Model EvaluationFile Ingestion for AIChunking StrategiesLLM Benchmarks & LimitationsInstruction Tuning & Chat Templates
🧰Applied LLM Engineering0/25
Dimensionality Reduction for EmbeddingsCoT, ToT & Self-Consistency PromptingFunction Calling & Tool UseMCP & Tool Protocol StandardsContext EngineeringPrompt Injection DefenseResponsible AI GovernancePrivacy-Preserving Machine LearningData Labeling and Human FeedbackEvaluating AI AgentsProduction RAG PipelinesHybrid Search: Dense + SparseReranking and Cross-Encoders for RAGRAG Evaluation for Reliable AnswersLLM-as-a-Judge EvaluationBias & Fairness in LLMsHallucination Detection & MitigationLLM Observability & MonitoringExperiment Tracking with MLflow and W&BPrompt Optimization with DSPyModel Versioning & DeploymentSemantic Caching & Cost OptimizationLLM Cost Engineering & Token EconomicsModel Gateways, Routing, and FallbacksDesign an Automated Support Agent
🎓Portfolio Capstones0/9
Capstone: Delivery ETA PredictionCapstone: Product RankingCapstone: Demand ForecastingCapstone: Image Damage ClassifierCapstone: Production ML PipelineCapstone: Document QACapstone: Eval DashboardCapstone: Fine-Tuned ClassifierCapstone: Reproducible ML Study
🧠Transformer Deep Dives0/8
Sentence Embeddings & Contrastive LossEmbedding Similarity & QuantizationScaled Dot-Product AttentionVision Transformers and Image EncodersPositional Encoding: RoPE & ALiBiLayer Normalization: Pre-LN vs Post-LNMechanistic InterpretabilityDecoding Strategies: Greedy to Nucleus
🧬Advanced Training & Adaptation0/17
Scaling Laws & Compute-Optimal TrainingPre-training Data at ScaleBuild GPT from Scratch LabJAX for PyTorch ResearchersContinued Pretraining for Domain ShiftSynthetic Data PipelinesSupervised Fine-Tuning PipelineMixed Precision TrainingDistributed Training: FSDP & ZeROLoRA & Parameter-Efficient TuningTraining Run OperationsReward Modeling from Preference DataRLHF & DPO AlignmentConstitutional AI & Red TeamingRLVR & Verifiable RewardsKnowledge Distillation for LLMsModel Merging and Weight Interpolation
🤖Advanced Agents & Retrieval0/16
Vector DB Internals: HNSW & IVFAdvanced RAG: HyDE & Self-RAGGraphRAG & Knowledge GraphsRAG Security & Access ControlStructured Output GenerationReAct & Plan-and-ExecuteGuardrails & Safety FiltersCode Generation & SandboxingComputer-Use / GUI / Browser AgentsHuman-in-the-Loop Agent ArchitectureAI Coding Workflow with AgentsAgent Memory & PersistenceAgent Failure & RecoveryRecursive Language Models (RLM)Multi-Agent OrchestrationCapstone: Production Agent
⚡Inference & Production Scale0/31
Inference: TTFT, TPS & KV CacheMulti-Query & Grouped-Query AttentionKV Cache & PagedAttentionPrefix Caching and Prompt CachingFlashAttention & Memory EfficiencyContinuous Batching & SchedulingScaling LLM InferenceModel Parallelism for LLM InferenceAccelerator Architecture Field GuideCorrect GPU Profiling and BenchmarkingGPU Kernel Performance Engineering LabGPU Parallel Primitives LabGPU Compilers and Kernel LanguagesGEMM From Naive CUDA to Tensor CoresLow-Precision GPU ProgrammingGPU Runtime Execution LabAI-Generated Kernel EngineeringModel Quantization: GPTQ, AWQ & GGUFLocal LLM DeploymentSLM Specialization & Edge DeploymentSpeculative DecodingLong Context Window ManagementMixture of Experts ArchitectureMamba & State Space ModelsReasoning & Test-Time ComputeAdvanced MLOps & DevOps for AIGPU Serving & AutoscalingGPU Failure OperationsDistributed Inference Data PlaneServing Benchmark EngineeringA/B Testing for LLMs
🏗️System Design Capstones0/9
Content Moderation SystemCode Completion SystemMulti-Tenant LLM PlatformLLM-Powered Search EngineVision-Language Models & CLIPMultimodal LLM ArchitectureDiffusion Models: Images & TextReal-Time Voice AI AgentReasoning Agent System Design
🎤AI Lab Interviewing0/4
AI Lab Coding Interview: Python SystemsAI Lab System Design InterviewAI Lab Behavioral InterviewAI Lab Technical Presentation
🔬Project Deep Dives0/17
Deep Dive - vLLMDeep Dive - SkyRLDeep Dive - FlashAttentionDeep Dive - FlashInferDeep Dive - DeepGEMMDeep Dive - NCCLDeep Dive - MegatronDeep Dive - DeepSpeedDeep Dive - RayDeep Dive - MLflowDeep Dive - PyTorchDeep Dive - TransformersDeep Dive - SGLangDeep Dive - slimeDeep Dive - DeepEPDeep Dive - TinkerDeep Dive - Light-PEFT
Back to Topics
LearnTransformer Deep DivesEmbedding Similarity & Quantization
📐HardEmbeddings & Vector Search

Embedding Similarity & Quantization

Learn vector scoring contracts, evaluate Matryoshka widths, and measure scalar, product, and binary quantization before deploying compressed retrieval.

35 min read
Learning path
Step 96 of 196 in the full curriculum
Sentence Embeddings & Contrastive LossScaled Dot-Product Attention

Personalize this lesson

Adapt explanations and teaching visuals to your background and preferred voice.

An operator asks, How do I rotate an API key? The embedding model should put that query near approved api-key-rotation-v3 and away from a quota page. The system still has to put the guide first, fit millions of vectors in memory, and never turn private-incident-note-44 into answer evidence.

That gives us one evidence boundary to protect. The private note can mention API keys and still be unauthorized. A raw dot-product search can reward its larger norm even when its direction is worse.

A separate capacity shortcut can drop the approved guide from a compressed shortlist. Neither mistake is obvious if you inspect only the final answer, so we'll keep both failures visible as we move through the index.

Sentence embeddings showed how contrastive training shapes this space. Here the encoder stays fixed. We'll keep both chunks in view while we choose a scoring contract, a width, and a representation that preserve the right evidence.

Follow one question: can api-key-rotation-v3 survive every cheaper shortcut? We'll compute three notions of closeness, test normalization and shorter prefixes, size float and compressed vectors with scalar, product, and binary quantization. Then we'll add approximate search and a final approval gate.

Three ways to measure closeness

Start with one small question: what should "close" mean for these two chunks? An embedding is a point in high-dimensional space, represented by a list of numbers from the encoder. Three common rulers answer different versions of that question.

RulerQuestionRangeRanking changes if you scale a vector?
Cosine similarityDo they point the same way?−1-1−1 to 111No
Dot productSame direction, and how long are they?−∞-\infty−∞ to +∞+\infty+∞Yes
Euclidean (L2) distanceWhat's the straight-line gap?000 to +∞+\infty+∞Yes

The table becomes easier to use once you make the contract explicit. If every stored vector and query is normalized to length 1, all three rulers rank candidates in the same order. You can then choose the scoring kernel your index runs quickly, but you must apply the same normalization at ingest and query time.

A zero vector is the exception you have to reject explicitly: cosine divides by both vector norms, so a zero-norm query has no defined direction. Replacing that division with an arbitrary zero score can quietly reorder an entire shortlist. Record the encoder failure and refuse retrieval instead of pretending the query matched nothing.

A tiny 2D example makes the split visible. Two paraphrases of the rotation guide map to:

  • Passage A, api-key-rotation-v3: [3, 4]
  • Passage B, a second rotation phrasing: [4, 3]

Both point northeast, so predict that every ruler will call them close. Work the arithmetic by hand before touching code.

Keep this equal-length pair in mind. Later fixtures will make the less obvious case visible: same direction, different length.

Cosine similarity

For A and B, the shared direction should produce a high score even though the score must ignore their lengths. Cosine keeps only that direction:

cos⁡(θ)=A⋅B∥A∥∥B∥\cos(\theta) = \frac{\mathbf{A} \cdot \mathbf{B}}{\|\mathbf{A}\| \|\mathbf{B}\|}cos(θ)=∥A∥∥B∥A⋅B​

Start with the numerator. Multiply matching dimensions and add:

A⋅B=(3×4)+(4×3)=12+12=24\mathbf{A} \cdot \mathbf{B} = (3 \times 4) + (4 \times 3) = 12 + 12 = 24A⋅B=(3×4)+(4×3)=12+12=24

The length of a vector is the square root of the sum of squared components:

∥A∥=32+42=25=5\|\mathbf{A}\| = \sqrt{3^2 + 4^2} = \sqrt{25} = 5∥A∥=32+42​=25​=5 ∥B∥=42+32=25=5\|\mathbf{B}\| = \sqrt{4^2 + 3^2} = \sqrt{25} = 5∥B∥=42+32​=25​=5

Their lengths are both 5, so now divide by 5×55 \times 55×5:

cos⁡(θ)=245×5=2425=0.96\cos(\theta) = \frac{24}{5 \times 5} = \frac{24}{25} = 0.96cos(θ)=5×524​=2524​=0.96

1 means the same direction, 0 means orthogonal, and -1 means opposite. That's a geometry result, not a relevance guarantee. Whether the direction tracks useful matches still depends on the encoder and retrieval evaluation.

Dot product

Now remove the length correction. Dot product stops at the sum we already computed:

A⋅B=∑i=1naibi=24\mathbf{A} \cdot \mathbf{B} = \sum_{i=1}^{n} a_i b_i = 24A⋅B=i=1∑n​ai​bi​=24

These passages happen to have the same length, so dot and cosine agree. If A gets longer without changing direction, dot product rises while the angle stays put. That's useful when training made magnitude meaningful. It's a bug when your contract was angular.

Euclidean distance

The third ruler asks for the straight-line gap. Subtract, square, sum, and take the square root:

∥A−B∥2=(3−4)2+(4−3)2=2≈1.41\|\mathbf{A} - \mathbf{B}\|_2 = \sqrt{(3-4)^2 + (4-3)^2} = \sqrt{2} \approx 1.41∥A−B∥2​=(3−4)2+(4−3)2​=2​≈1.41

A small L2 distance also says these paraphrases are close. For this pair, all three rulers agree. Add candidates with different norms and that agreement can disappear, which is why the serving contract matters more than the label on an index.

You've worked the numbers. Predict the next output: after A is scaled by 10, should cosine change, should dot product change, or should both stay fixed? This snippet checks the hand math and makes that prediction concrete:

two-dimensional-rulers.py
1import math 2 3passage_a = [3.0, 4.0] 4passage_b = [4.0, 3.0] 5 6def dot(left: list[float], right: list[float]) -> float: 7 return sum(x * y for x, y in zip(left, right, strict=True)) 8 9def l2_norm(vector: list[float]) -> float: 10 return math.sqrt(sum(value * value for value in vector)) 11 12score = dot(passage_a, passage_b) 13cosine = score / (l2_norm(passage_a) * l2_norm(passage_b)) 14euclidean = l2_norm([x - y for x, y in zip(passage_a, passage_b, strict=True)]) 15print(f"dot={score:.0f}, cosine={cosine:.2f}, euclidean={euclidean:.2f}") 16 17longer_a = [10.0 * value for value in passage_a] 18same_direction_cosine = dot(longer_a, passage_b) / ( 19 l2_norm(longer_a) * l2_norm(passage_b) 20) 21print(f"10x vector: cosine={same_direction_cosine:.2f}, dot={dot(longer_a, passage_b):.0f}")
Output
1dot=24, cosine=0.96, euclidean=1.41 210x vector: cosine=0.96, dot=240
Left: vectors A=(3,4) and B=(4,3) point the same way. Right: as A scales from 1x to 10x, the cosine ratio stays at 1 while the dot-product ratio climbs to 10.
A and B stay aligned as A grows. Cosine is unchanged at 0.96. The dot product scales from 24 to 240, so magnitude is now part of the score.

If vector A is [3, 4] and vector C is [30, 40], what happens to cosine similarity and dot product when you compare both vectors to [4, 3]?

Answer

Cosine similarity stays the same because A and C point in the same direction. Dot product grows by 10x because C is 10x longer. Cosine ignores magnitude. Dot product keeps it.

The experiment gives you a serving decision. Use cosine when the model card, training setup, or labeled queries say length shouldn't change rank. Use raw dot product when magnitude is part of the learned score, as in maximum inner product search (MIPS).

If every vector is already unit length, the rankings match and inner product avoids norm divisions while scoring candidates.

When the three rulers agree

The first fixture used equal-length vectors. Real encoders don't promise that unless their documentation or your pipeline says so. Treat normalization as an explicit contract shared by model output, index construction, and query serving. Don't infer it from a model name.

With that contract in place, the three rulers collapse to one ranking. When both vectors have length 1, the cosine denominator is 1×1=11 \times 1 = 11×1=1, so cosine equals raw dot product. Squared Euclidean distance becomes a strictly decreasing function of that same inner product:

cos⁡(A,B)=A⋅B=1−∥A−B∥22\cos(\mathbf{A}, \mathbf{B}) = \mathbf{A} \cdot \mathbf{B} = 1 - \frac{\|\mathbf{A} - \mathbf{B}\|^2}{2}cos(A,B)=A⋅B=1−2∥A−B∥2​

Higher inner product, higher cosine, and smaller squared L2 distance now induce the same ranking. Normalize stored rows once and each incoming query once. Candidate scoring can use a matrix-multiply kernel without repeated norm divisions.

Here's the failure in the opening story. The next fixture gives private-incident-note-44 a huge raw norm, so it wins unnormalized dot product even though its direction is less aligned with the query. Before running it, predict which label each score will choose. Then compare raw dot, normalized dot, and cosine.

normalization-is-a-ranking-contract.py
1import math 2 3labels = ["api-key-rotation-v3", "private-incident-note-44", "service-token-lifecycle-v2"] 4documents = [ 5 [0.95, 0.05], # Direction closely matches the rotation query. 6 [8.00, 6.00], # Large norm lets raw dot product dominate. 7 [0.20, 0.90], 8] 9query = [1.0, 0.0] 10 11def dot(left: list[float], right: list[float]) -> float: 12 return sum(x * y for x, y in zip(left, right, strict=True)) 13 14def l2_normalize(vector: list[float]) -> list[float]: 15 norm = math.sqrt(sum(value * value for value in vector)) 16 return [value / norm for value in vector] 17 18def l2_norm(vector: list[float]) -> float: 19 return math.sqrt(sum(value * value for value in vector)) 20 21raw_dot = [dot(row, query) for row in documents] 22unit_documents = [l2_normalize(row) for row in documents] 23unit_query = l2_normalize(query) 24unit_dot = [dot(row, unit_query) for row in unit_documents] 25raw_cosine = [ 26 dot(row, query) / (l2_norm(row) * l2_norm(query)) 27 for row in documents 28] 29 30print(f"raw dot winner: {labels[raw_dot.index(max(raw_dot))]}") 31print(f"normalized winner: {labels[unit_dot.index(max(unit_dot))]}") 32print( 33 "cosine equals unit dot: " 34 f"{all(abs(left - right) < 1e-6 for left, right in zip(raw_cosine, unit_dot, strict=True))}" 35)
Output
1raw dot winner: private-incident-note-44 2normalized winner: api-key-rotation-v3 3cosine equals unit dot: True

A large-norm vector isn't automatically junk. The narrower conclusion is about serving: if the contract is cosine-like ranking, skipping normalization changes what the index optimizes.

L2 distance isn't automatically wrong in high dimension either. On unit vectors, squared L2 produces the same ranking through the identity above. Without normalization, L2 also includes norm differences. Keep it when the model, index, and evaluation were built around that geometry.

The original product-quantization formulation approximates this squared L2 distance between a full-precision query and compressed database vectors.[1]Reference 1Product Quantization for Nearest Neighbor Search.https://dblp.org/rec/journals/pami/JegouDS11

We've fixed what "close" means. The next pressure is capacity: how many coordinates can you afford to keep before the scoring contract becomes too expensive?

Choosing how many dimensions you need

We've chosen a scoring contract. Now ask what that contract costs. Width controls how much of the trained representation you store, along with memory traffic and scoring work.

A dimension count has no universal quality meaning: a 512-dimensional model trained for your API-doc queries can beat a different 1,536-dimensional model. Compare widths only after holding model family, scoring convention, corpus, and evaluation set fixed.

Choice under evaluationWhat you can concludeWhat you still must measure
Full trained widthBaseline payload and retrieval score for this modelLatency, RAM, and evidence recall
Model-supported shorter prefixLower raw payload for the same familyRecall loss and failure slices
Different smaller modelPossibly cheaper candidate systemEverything again; training changed too

The table separates two decisions that are easy to conflate. Dimension count changes capacity. Normalization changes score meaning. A supported shorter vector still needs the serving normalization contract documented by its model or applied by your pipeline.

For one representation family, extra dimensions buy capacity but charge the same predictable systems costs:

  1. Storage. Each float32 is 4 bytes. 1M vectors at 1536d is about 5.7 GiB of raw payload.
  2. Exact scoring work. A dot product or L2 comparison does linear work in the number of dimensions, written O(d)O(d)O(d), per candidate.
  3. Memory bandwidth. Loading vectors from memory can bottleneck approximate nearest neighbor (ANN) search, which finds close vectors without scanning every row.
  4. End-to-end latency. Halving dimensions doesn't promise halved latency. Graph traversal, filters, networking, and reranking still take time, so measure before you pay or save the memory bill.

Matryoshka prefixes still need a recall test

Suppose memory pressure makes you want 256 dimensions instead of 1,536. Should the first 256 coordinates be useful by accident, or should the model have been trained to put signal there?

Matryoshka Representation Learning (MRL) answers the second question: training minimizes a weighted sum of losses at selected truncation widths d∈Dd \in \mathcal{D}d∈D, so those prefixes are meant to work on their own.[2]Reference 2Matryoshka Representation Learning.https://arxiv.org/abs/2205.13147

That's different from post-hoc PCA, which projects already-trained vectors into a smaller linear subspace, usually by preserving variance on a fixed dataset. PCA can still help frozen embeddings. MRL fits a serving path with several dimension budgets when the model was trained for truncation.

Hosted APIs can expose a supported shortening control without you slicing tensors by hand. OpenAI's embeddings guide documents a dimensions parameter for text-embedding-3 models, with defaults of 1,536 for text-embedding-3-small and 3,072 for text-embedding-3-large.[3]Reference 3Vector embeddingshttps://developers.openai.com/api/docs/guides/embeddings

The same guide reports that a text-embedding-3-large vector shortened to 256 dimensions outperforms unshortened text-embedding-ada-002 at 1,536 dimensions on MTEB. That's a provider benchmark, not a promise that a 256-d prefix will retrieve api-key-rotation-v3 for your queries.

The provider's shortening control and a local slice aren't interchangeable. If you cut a prefix from an already generated vector, normalize that sliced prefix before indexing, then apply the same operation to queries. OpenAI's embeddings guide shows this L2 renormalization after a manual cut.[3]Reference 3Vector embeddingshttps://developers.openai.com/api/docs/guides/embeddings

This local check enforces the contract without an API call:

renormalize-a-sliced-prefix.py
1import math 2 3full_embedding = [0.21, -0.05, 0.44, 0.31, 0.18, -0.27, 0.12, 0.09] 4 5def l2_normalize(vector: list[float]) -> list[float]: 6 norm = math.sqrt(sum(value * value for value in vector)) 7 if norm == 0: 8 return vector 9 return [value / norm for value in vector] 10 11short_embedding = l2_normalize(full_embedding[:4]) 12norm = math.sqrt(sum(value * value for value in short_embedding)) 13print(f"dims={len(short_embedding)}, norm={norm:.3f}")
Output
1dims=4, norm=1.000

Normalization restores a unit-vector scoring contract. It can't restore coordinates you threw away. In the next fixture, the last two dimensions carry the distinction between the two passages. Cut to width 2 and the private note ties the guide, so argmax picks the unauthorized row. Predict that result before reading the output.

Full 4-d fixture: rotation query [1,0,0,1] matches api-key-rotation-v3. After keeping only d0 and d1, that query matches private-incident-note-44 because both rows collapse to [1,0].
At full width, d2 and d3 separate the private note from the rotation guide. After a 2-d cut, both rows become [1, 0], so top-1 recall on this two-query fixture drops from 100% to 50%.
prefix-still-needs-recall-evaluation.py
1import math 2 3labels = ["private-incident-note-44", "api-key-rotation-v3", "service-token-lifecycle-v2"] 4documents = [ 5 [1.0, 0.0, 1.0, 0.0], 6 [1.0, 0.0, 0.0, 1.0], 7 [0.0, 1.0, 0.0, 1.0], 8] 9queries = [ 10 [1.0, 0.0, 0.0, 1.0], 11 [0.0, 1.0, 0.0, 1.0], 12] 13expected = ["api-key-rotation-v3", "service-token-lifecycle-v2"] 14 15def l2_normalize(vector: list[float]) -> list[float]: 16 norm = math.sqrt(sum(value * value for value in vector)) 17 return [value / norm for value in vector] 18 19def dot(left: list[float], right: list[float]) -> float: 20 return sum(x * y for x, y in zip(left, right, strict=True)) 21 22def top1_at_width(width: int) -> list[str]: 23 docs = [l2_normalize(row[:width]) for row in documents] 24 asks = [l2_normalize(row[:width]) for row in queries] 25 winners: list[str] = [] 26 for ask in asks: 27 scores = [dot(ask, row) for row in docs] 28 winners.append(labels[scores.index(max(scores))]) 29 return winners 30 31full = top1_at_width(4) 32short = top1_at_width(2) 33full_recall = sum(got == want for got, want in zip(full, expected, strict=True)) / len(expected) 34short_recall = sum(got == want for got, want in zip(short, expected, strict=True)) / len(expected) 35print(f"full top-1 recall={full_recall:.1%}, results={full}") 36print(f"short top-1 recall={short_recall:.1%}, results={short}")
Output
1full top-1 recall=100.0%, results=['api-key-rotation-v3', 'service-token-lifecycle-v2'] 2short top-1 recall=50.0%, results=['private-incident-note-44', 'service-token-lifecycle-v2']

This isn't a benchmark for a real MRL model. It's the shape of a release test: evaluate each supported width on approved-query fixtures, including near-duplicate unauthorized passages. A prefix that saves bytes but loses the guide fails the contract.

You shorten a unit-normalized 3072-dimensional embedding by taking only the first 256 values. Should you index that prefix immediately?

Answer

No. Slicing usually changes the vector's L2 norm, so normalize the 256-dimensional prefix before indexing. Otherwise cosine, dot product, and Euclidean conventions no longer match the index contract you think you are using.

The memory problem: a sizing example

The prefix test exposed a quality budget. Now put a number on the memory budget. Use the default text-embedding-3-small width of 1,536 dimensions and ask what one million rows cost before graph links, metadata, or replicas.

Scenario: 1 million embeddings, 1,536 dimensions, stored as 32-bit floats (FP32). Predict the raw payload first. Then we'll replace four-byte floats with one-byte values and finally with PQ IDs.

Step 1: the FP32 baseline

Each dimension is a float32, which takes 4 bytes. Multiply rows, dimensions, and bytes per value:

1,000,000×1,536×4 bytes=6.144×109 bytes≈6.14 GB≈5.72 GiB1{,}000{,}000 \times 1{,}536 \times 4 \text{ bytes} = 6.144 \times 10^9 \text{ bytes} \approx 6.14 \text{ GB} \approx 5.72 \text{ GiB}1,000,000×1,536×4 bytes=6.144×109 bytes≈6.14 GB≈5.72 GiB

That's only the raw numbers. Real indexes add graph links, metadata, and replicas, but the vector payload alone is already over 5.7 GiB. If that's too large for hot memory, precision is the next lever.

Step 2: 8-bit scalar quantization

Map each float32 to a single byte (INT8). The row count and width stay fixed, so storage drops by about 4x:

1,000,000×1,536×1 byte≈1.54 GB≈1.43 GiB1{,}000{,}000 \times 1{,}536 \times 1 \text{ byte} \approx 1.54 \text{ GB} \approx 1.43 \text{ GiB}1,000,000×1,536×1 byte≈1.54 GB≈1.43 GiB

Step 3: product quantization

Split each 1,536-dimensional vector into 96 subvectors and store one 8-bit centroid ID per subvector. The compressed row is 96 bytes:

1,000,000×96 bytes≈91.6 MiB1{,}000{,}000 \times 96 \text{ bytes} \approx 91.6 \text{ MiB}1,000,000×96 bytes≈91.6 MiB

That's a 64x cut from the original FP32 payload. The trade is now explicit: you're comparing approximate codes instead of exact floats, so pair PQ with a higher-precision reranking stage.

Bytes per 1536-d vector: 6144 for float32, 1536 for int8, and 96 for a 96-subspace 8-bit PQ code. One million vectors then occupy 5.72 GiB, 1.43 GiB, and 91.6 MiB of raw payload.
Each 1536-d row shrinks from 6144 bytes to 96 bytes under 96-subspace 8-bit PQ. Linear bars make the PQ code look tiny on purpose: 64 times smaller than FP32, before graph overhead.

The byte math is easy to turn into a sizing helper. Predict the units before running it: decimal gigabytes divide by 10910^9109, while operating systems usually report binary gibibytes divided by 102431024^310243:

vector-payload-sizing.py
1def payload_bytes(num_vectors: int, bytes_per_vector: int) -> int: 2 return num_vectors * bytes_per_vector 3 4raw_bytes = payload_bytes(1_000_000, 1_536 * 4) 5int8_bytes = payload_bytes(1_000_000, 1_536) 6pq_bytes = payload_bytes(1_000_000, 96) 7 8print(f"FP32={raw_bytes / 1e9:.2f} GB ({raw_bytes / 1024**3:.2f} GiB)") 9print(f"INT8={int8_bytes / 1e9:.2f} GB ({int8_bytes / 1024**3:.2f} GiB)") 10print(f"PQ={pq_bytes / 1024**2:.1f} MiB")
Output
1FP32=6.14 GB (5.72 GiB) 2INT8=1.54 GB (1.43 GiB) 3PQ=91.6 MiB

Re-embedding cost scales with total input tokens, not document count alone. Compute it as total_tokens / 1_000_000 * embedding_price_per_1M_tokens, using the current price for the model you selected.

Keep model version, dimensions, normalization, and quantization settings alongside every index. Otherwise a migration can mix vectors that no longer share a scoring contract.

Shrinking vectors without losing the hit

The sizing math says where the pressure comes from. An exact scan touches up to N×dN \times dN×d values. An ANN index reduces how many rows you visit, while quantization reduces bytes per row. Those are separate shortcuts, and either can lose the approved guide, so each needs its own evaluation gate.

Diagram showing FP32 labeled baseline, Benchmark SQ, then PQ or BQ, Shortlist recall OK?, and no.
FP32 labeled baseline, Benchmark SQ, then PQ or BQ, Shortlist recall OK?, and no.

Read the flow as a release decision, not a compression recipe. If the FP32 baseline fits RAM and latency, keep it. If it doesn't, benchmark one shortcut at a time, reject any version that drops required evidence from the shortlist, and keep authorization on the precise serving path.

Scalar quantization (SQ)

Start with the least structural change. Scalar quantization replaces each float32 (4 bytes) with a lower-precision number, typically mapping each dimension independently. The simplest signed int8 version is symmetric around zero:

xq=clip⁡(round⁡(xs),−127,127),x≈sxqx_q = \operatorname{clip}\left(\operatorname{round}\left(\frac{x}{s}\right), -127, 127\right), \qquad x \approx s x_qxq​=clip(round(sx​),−127,127),x≈sxq​

Here sss is the scale. Each dimension goes from 4 bytes to 1 byte, so raw storage drops by about 4x while you keep an approximate path back to float space. Some libraries use this symmetric signed form; others use affine quantization with a zero-point, often over uint8. The code below makes the scale and reconstruction error inspectable.

This function implements the symmetric version. It takes float vectors and returns integer codes plus the per-dimension scales needed to reconstruct later:

symmetric-int8-quantization.py
1vectors = [ 2 [0.20, -1.00, 2.50], 3 [0.24, -0.80, 2.10], 4 [0.50, -1.40, 2.30], 5] 6 7def symmetric_quantize_int8( 8 rows: list[list[float]], 9) -> tuple[list[list[int]], list[float]]: 10 qmax = 127 11 width = len(rows[0]) 12 max_abs = [max(abs(row[dim]) for row in rows) for dim in range(width)] 13 scale = [max(bound, 1e-8) / qmax for bound in max_abs] 14 codes = [ 15 [ 16 max(-qmax, min(qmax, round(row[dim] / scale[dim]))) 17 for dim in range(width) 18 ] 19 for row in rows 20 ] 21 return codes, scale 22 23codes, scale = symmetric_quantize_int8(vectors) 24reconstructed = [ 25 [scale[dim] * code for dim, code in enumerate(row)] 26 for row in codes 27] 28max_error = max( 29 abs(original - approx) 30 for original_row, approx_row in zip(vectors, reconstructed, strict=True) 31 for original, approx in zip(original_row, approx_row, strict=True) 32) 33print(f"codes[0]={codes[0]}, max_error={max_error:.4f}")
Output
1codes[0]=[51, -91, 127], max_error=0.0063

INT8 cuts the raw payload from four bytes per dimension to one. It doesn't promise any fixed Recall@K. Model distribution, calibration sample, scoring rule, shortlist depth, and reranking all change the observed loss.

Treat INT8 as the first candidate to benchmark, not an automatic release.

⚠️ Common mistake: Deriving the scale from the absolute min and max of a calibration sample without checking outliers. If one dimension spikes far outside the usual range, the scale stretches and ordinary values collapse into fewer integer buckets. Percentile clipping can preserve ordinary resolution while saturating outliers, but the percentile is another measured choice, not a universal constant.

The next example isolates that failure with ordinary values plus one spike. Predict which calibration range gives ordinary values more resolution, then compare a scale set by the spike with a clipped scale set from the ordinary range:

outlier-calibration-costs-resolution.py
1ordinary = [index / 50 - 1.0 for index in range(101)] 2with_outlier = [*ordinary, 25.0] 3 4def quantize_reconstruct(values: list[float], bound: float) -> list[float]: 5 scale = bound / 127 6 return [ 7 max(-127, min(127, round(value / scale))) * scale 8 for value in values 9 ] 10 11def mean_abs_error(left: list[float], right: list[float]) -> float: 12 return sum(abs(x - y) for x, y in zip(left, right, strict=True)) / len(left) 13 14bound_from_max = max(abs(value) for value in with_outlier) 15max_scaled = quantize_reconstruct(ordinary, bound_from_max) 16clipped_scaled = quantize_reconstruct(ordinary, 1.0) 17print(f"outlier-bound ordinary MAE={mean_abs_error(ordinary, max_scaled):.4f}") 18print(f"clipped-bound ordinary MAE={mean_abs_error(ordinary, clipped_scaled):.4f}")
Output
1outlier-bound ordinary MAE=0.0483 2clipped-bound ordinary MAE=0.0019

Lower reconstruction error on ordinary values is encouraging, but retrieval is the target. A release check must measure whether relevant approved passages stay in the shortlist after calibration and compression.

Product quantization (PQ)

SQ keeps one approximate number for every coordinate. If that still leaves too many bytes, PQ changes the unit of storage: instead of storing coordinates, store the IDs of nearby codewords. Jégou, Douze, and Schmid introduced this pattern by splitting each vector into MMM subvectors and quantizing each independently with a learned codebook.[1]Reference 1Product Quantization for Nearest Neighbor Search.https://dblp.org/rec/journals/pami/JegouDS11

See the storage change in a small example with M=4M=4M=4 on an 8-dimensional vector:

Original:[1.2,3.4∣0.8,2.1∣5.3,1.7∣0.4,3.2](8×4=32 bytes)\text{Original}: [1.2, 3.4 \mid 0.8, 2.1 \mid 5.3, 1.7 \mid 0.4, 3.2] \quad (8 \times 4 = 32 \text{ bytes})Original:[1.2,3.4∣0.8,2.1∣5.3,1.7∣0.4,3.2](8×4=32 bytes)

Each 2-d chunk maps to the nearest centroid ID in that subspace's codebook. Stored as 8-bit IDs, the row becomes four bytes. At query time you don't reconstruct the floats. You score those IDs against the full-precision query with lookup tables.

With 256 centroids per subspace (8 bits), compressing 768-d float32 vectors (3,072 bytes) using 96 subspaces produces a 96-byte code, a 32x payload cut. Shared codebooks and index metadata add separate overhead.

Asymmetric distance computation (ADC)

The asymmetry is deliberate: keep the query in full precision, and compare each query subvector with every centroid in its matching codebook.[1]Reference 1Product Quantization for Nearest Neighbor Search.https://dblp.org/rec/journals/pami/JegouDS11 That builds a small lookup table per subspace:

d(q,x^)≈∑m=1M∥qm−cm,im∥22d(\mathbf{q}, \hat{\mathbf{x}}) \approx \sum_{m=1}^{M} \left\| \mathbf{q}_m - \mathbf{c}_{m, i_m} \right\|_2^2d(q,x^)≈m=1∑M​∥qm​−cm,im​​∥22​

The database row stores only centroid IDs imi_mim​. Scoring one candidate becomes MMM table lookups and additions, so PQ can search compressed vectors without fully decompressing them. Production libraries such as FAISS implement this ADC path for PQ codes.[4]Reference 4Billion-scale similarity search with GPUs.https://arxiv.org/abs/1702.08734

The distance equation above minimizes summed subspace distances, so it matches an L2 serving contract. Many embedding indexes serve maximum inner product search (MIPS) or unit-vector cosine, where higher ⟨q,x⟩\langle q, x \rangle⟨q,x⟩ wins. For those contracts, use a sum of subspace inner products instead:

⟨q,x^⟩≈∑m=1M⟨qm,cm,im⟩\langle \mathbf{q}, \hat{\mathbf{x}} \rangle \approx \sum_{m=1}^{M} \langle \mathbf{q}_m, \mathbf{c}_{m, i_m} \rangle⟨q,x^⟩≈m=1∑M​⟨qm​,cm,im​​⟩

Build a lookup table of ⟨qm,cm,k⟩\langle \mathbf{q}_m, \mathbf{c}_{m,k} \rangle⟨qm​,cm,k​⟩ for each subspace mmm and centroid kkk. For each stored code, sum the selected entries and rank high-to-low. When every vector is unit-normalized, IP ADC approximates cosine ranking under compression.

Don't train or evaluate L2 PQ tables against an unnormalized IP index and expect the same order: minimizing ∥q−x^∥2\|q - \hat{x}\|^2∥q−x^∥2 differs from maximizing ⟨q,x⟩\langle q, x \rangle⟨q,x⟩ unless norms are controlled.

OPQ rotates the space before PQ so subspaces carry more independent signal.[5]Reference 5Optimized Product Quantizationhttps://openaccess.thecvf.com/content_cvpr_2013/html/Ge_Optimized_Product_Quantization_2013_CVPR_paper.html Residual / IVFADC pipelines quantize a residual after a coarse quantizer.[1]Reference 1Product Quantization for Nearest Neighbor Search.https://dblp.org/rec/journals/pami/JegouDS11 Either can raise recall. Neither changes the rule: match the score you serve.

The next snippet makes ADC concrete with two-centroid codebooks. The full-precision query creates four lookup tables; each stored ID selects one entry per table. Predict which code should win before adding those four distances.

Asymmetric distance computation for an 8-d query split into four pairs. The rotation code [1,1,1,1] sums to 0.08. The lifecycle code [0,0,0,0] sums to 56.57, so ADC ranks rotation nearer without reconstructing floats.
Each subspace compares the query chunk to two centroids and stores one number in a lookup table. Summing the selected entries ranks api-key-rotation-v3 at 0.08 against 56.57, with no float reconstruction of the stored rows.
pq-asymmetric-distance-lookups.py
1codebooks = [ 2 [[0.0, 0.0], [1.2, 3.4]], 3 [[0.0, 0.0], [0.8, 2.1]], 4 [[0.0, 0.0], [5.3, 1.7]], 5 [[0.0, 0.0], [0.4, 3.2]], 6] 7query = [1.1, 3.3, 0.7, 2.0, 5.2, 1.8, 0.3, 3.1] 8stored_codes = { 9 "api-key-rotation-v3": [1, 1, 1, 1], 10 "service-token-lifecycle-v2": [0, 0, 0, 0], 11} 12 13query_chunks = [query[index : index + 2] for index in range(0, 8, 2)] 14lookup = [] 15for chunk, codebook in zip(query_chunks, codebooks, strict=True): 16 distances = [ 17 sum((chunk[dim] - centroid[dim]) ** 2 for dim in range(2)) 18 for centroid in codebook 19 ] 20 lookup.append(distances) 21 22scores = { 23 name: sum(lookup[subspace][code] for subspace, code in enumerate(code)) 24 for name, code in stored_codes.items() 25} 26winner = min(scores, key=scores.get) 27print({name: round(score, 2) for name, score in scores.items()}) 28print(f"ADC nearest code: {winner}")
Output
1{'api-key-rotation-v3': 0.08, 'service-token-lifecycle-v2': 56.57} 2ADC nearest code: api-key-rotation-v3

More compression can lower recall. The loss depends on subspace count, centroid count, query distribution, and reranking budget. Benchmark PQ against the real query distribution instead of trusting one headline number.

Binary quantization (BQ)

If one byte per coordinate still misses the budget, binary quantization keeps only one bit per dimension. This version thresholds at zero: positive numbers become 1, and zero or negative numbers become 0. Magnitude disappears. That makes BQ attractive for a very cheap shortlist, not a final ranking guarantee.

Pack the sign bits into an integer. Comparing two packed rows then becomes XOR plus popcount, which gives Hamming distance:

binary-quantization-hamming.py
1rotation_a = [0.3, -0.1, 0.8, -0.4] 2rotation_b = [0.5, -0.2, 0.7, 0.1] 3 4def pack_sign_bits(vector: list[float]) -> int: 5 packed = 0 6 for index, value in enumerate(vector): 7 if value > 0: 8 packed |= 1 << index 9 return packed 10 11def hamming_distance(packed_a: int, packed_b: int) -> int: 12 return (packed_a ^ packed_b).bit_count() 13 14packed_a = pack_sign_bits(rotation_a) 15packed_b = pack_sign_bits(rotation_b) 16print(f"packed_a={packed_a}, packed_b={packed_b}, hamming={hamming_distance(packed_a, packed_b)}")
Output
1packed_a=5, packed_b=13, hamming=1

Predict the count before reading the output: the two vectors differ only at the last sign. [0.3, -0.1, 0.8, -0.4] becomes bits [1, 0, 1, 0]. Little-endian packing puts dimension 0 in bit 0, so the integer is 0b0101 = 5. The second vector becomes [1, 0, 1, 1] = 13. XOR leaves one differing bit, so Hamming distance is 1.

A Hamming distance of 1 out of 4 bits means the vectors are close in this binary approximation. Sign bits discard magnitude, so this zero-threshold version is most informative when coordinates are centered around zero. Other distributions need different calibration or encoding, and the first stage still needs a recall test.

DimensionPractical effect
Compression32x raw payload reduction when moving from float32 to one bit per dimension.
SpeedPacked bits use XOR plus popcount, so both memory traffic and arithmetic drop. Implementations can add architecture-specific SIMD for packed-byte comparisons.
QualityRecall can drop sharply, so BQ usually fits best as a first-stage filter.

Shortlist cheaply, then rescore

The three representations now have distinct jobs. Stage 1 scans a large candidate set with a cheap code. Stage 2 gives a shortlist a precise comparison. You wouldn't inspect 10,000 API-doc chunks in full precision on every query, and you wouldn't cite the top Hamming match as evidence.

One cost-sensitive pattern uses three stages: binary Hamming search for the cheap shortlist, an INT8 dot-product rescore of that shortlist, and an optional cross-encoder reranker on the final handful when answer quality justifies the extra latency.[6]Reference 6Rerank API.https://docs.cohere.com/reference/rerank

Each stage sees fewer candidates and more precision than the one before it.

Compression doesn't move the evidence boundary. Hamming can tie a private note with the rotation guide. Unit cosine can separate them, but the approval filter still has to drop the note.

Sign-bit Hamming ties private-incident-note-44 and api-key-rotation-v3 at distance 0. Unit cosine then scores rotation 1.00 against the private note 0.41, so the approval filter can keep the guide and drop the note.
Hamming distance 0 doesn't mean "same passage." The private note and the rotation guide share sign bits, so only unit cosine plus the approval list can choose evidence.
binary-shortlist-evidence-gate.py
1import math 2 3labels = ["private-incident-note-44", "api-key-rotation-v3", "service-token-lifecycle-v2"] 4approved = [False, True, True] 5documents = [ 6 [0.05, -2.00, 0.05, -2.00], # Same signs, wrong private evidence. 7 [0.78, -0.38, 0.58, -0.22], # Approved rotation guide, close in full precision. 8 [0.10, 0.60, -0.40, -0.20], 9] 10query = [0.80, -0.40, 0.60, -0.20] 11expected = "api-key-rotation-v3" 12 13def sign_bits(vector: list[float]) -> list[bool]: 14 return [value > 0 for value in vector] 15 16def l2_normalize(vector: list[float]) -> list[float]: 17 norm = math.sqrt(sum(value * value for value in vector)) 18 return [value / norm for value in vector] 19 20def dot(left: list[float], right: list[float]) -> float: 21 return sum(x * y for x, y in zip(left, right, strict=True)) 22 23query_signs = sign_bits(query) 24hamming = [ 25 sum(doc_bit != query_bit for doc_bit, query_bit in zip(sign_bits(row), query_signs, strict=True)) 26 for row in documents 27] 28shortlist = sorted(range(len(hamming)), key=lambda index: (hamming[index], index))[:2] 29allowed_shortlist = [index for index in shortlist if approved[index]] 30unit_query = l2_normalize(query) 31precise_scores = [dot(l2_normalize(documents[index]), unit_query) for index in allowed_shortlist] 32served = labels[allowed_shortlist[precise_scores.index(max(precise_scores))]] 33 34recall_at_2 = expected in [labels[index] for index in shortlist] 35print(f"binary shortlist={[labels[index] for index in shortlist]}, recall@2={recall_at_2}") 36print(f"served evidence={served}, authorized={approved[labels.index(served)]}") 37assert recall_at_2 and served == expected
Output
1binary shortlist=['private-incident-note-44', 'api-key-rotation-v3'], recall@2=True 2served evidence=api-key-rotation-v3, authorized=True

On real data, run that same gate over many labeled queries and failure slices. A compressed first pass earns its place only if shortlist recall stays high enough for precise, authorized evidence selection to succeed.

Why is binary quantization usually paired with reranking instead of used as the final answer?

Answer

Binary quantization keeps only one sign bit per dimension, so Hamming distance is fast but coarse. It's useful for finding a shortlist cheaply. The final ranking should use a higher-precision representation, such as INT8 or FP32 dot product, and often a cross-encoder reranker when result quality matters.

Indexing the compressed codes

Compression answers "how many bytes per row?" ANN answers "how many rows do we visit?" Keep those levers separate. Exact composition is library-specific: some implementations combine HNSW with scalar or product quantization, while others keep full-precision vectors beside compressed codes for rescoring.

Many vector databases offer HNSW (Hierarchical Navigable Small World) graphs as an ANN structure.[7]Reference 7Efficient and Robust Approximate Nearest Neighbor Using Hierarchical Navigable Small World Graphs.https://arxiv.org/abs/1603.09320 HNSW builds a multi-layered proximity graph: upper layers contain progressively fewer sampled nodes, while the base layer contains the collection.

Search starts high, follows promising links, and descends toward a candidate neighborhood at the base.

If a compressed index still misses the guide, you need to know which knob can change without rebuilding. HNSW implementations commonly expose three controls; exact names and allowed values depend on the library:

ControlChanged duringUsually buysUsually costs
M (graph connectivity)Index buildMore routes to true neighborsMore graph memory and build work
ef_constructionIndex buildA better-connected graphSlower index construction
ef_searchQuery servingMore candidate explorationHigher query latency

Start with query-time exploration and measure recall and latency together. If the graph can't reach required recall within the latency budget, rebuild with adjusted construction settings. A copied parameter triple says nothing about a new corpus.

Public benchmarks such as MTEB evaluate embedding models across several task families.[8]Reference 8MTEB: Massive Text Embedding Benchmark.https://arxiv.org/abs/2210.07316 They help you assemble candidates. They can't tell you which index retrieves api-key-rotation-v3 under your storage, authorization, and latency limits.

DecisionEstablish a baselineAccept a change only when
Model familyFull-precision Recall@K on approved evidence queriesTarget slices and latency remain acceptable
Supported prefix widthFull trained width with identical scoringMeasured recall loss fits your release threshold
SQ, PQ, or BQUncompressed shortlist and final rankingCompressed shortlist preserves required evidence recall
ANN settingsExact-search results for a labeled sampleRecall and latency both fit the serving budget

Keep a versioned experiment row for model, width, normalization, quantizer, index settings, shortlist size, and reranker. That row lets you explain a compressed-index rollout when a rotation-guide answer later changes.

Bytes, RAM, and the shortlist bill

The one-million-row example hides scale. As the corpus grows from millions to billions of chunks, low-latency ANN search keeps more index data hot in memory, and raw float32 payload can dominate the bill. The ratios stay simple, so use the table to choose where to measure next.

The byte math is deterministic even when cloud pricing isn't. This table uses the same 1,536-d width as the sizing example, rounded in binary units, before HNSW links, metadata, replicas, or region-specific RAM pricing:

ScaleRaw payload (1536d, FP32)With INT8 SQWith PQ (96-byte code)
1M vectors5.72 GiB1.43 GiB92 MiB
10M vectors57.2 GiB14.3 GiB916 MiB
100M vectors572 GiB143 GiB8.94 GiB
1B vectors5.6 TiB1.4 TiB89 GiB

Practice: choose a retrieval stack

You have 50 million approved API-doc embeddings at 768 dimensions. private-incident-note-44 must never be served as answer evidence. P99 latency budget is 60 ms, and raw FP32 vectors would crowd out the rest of the service's RAM. Choose a path that saves memory without weakening the evidence boundary.

  1. Would you keep raw FP32 payload in memory for first-stage search?
  2. If your ingest path already L2-normalizes vectors, would you score with cosine or dot product online?
  3. Would you start with INT8 SQ, PQ, or BQ for the first pass?
  4. Where would you spend full-precision compute?
  5. What test prevents a cheaper index from silently changing evidence quality?

Use this as a design review. Give each answer a reason tied to the scoring contract, memory budget, or evidence gate.

Solution sketch:

  • Don't start with raw FP32 if memory is already tight. Benchmark INT8 first, then PQ if you still need a bigger cut.
  • Use dot product online if vectors are already unit-normalized. It preserves cosine ranking without redundant norm work.
  • Start by benchmarking INT8 SQ because it changes payload less aggressively than PQ or BQ. Move further only when the measured RAM and latency gain justifies shortlist loss.
  • Spend full-precision compute on a shortlist, not on the whole corpus. That's where rescoring and reranking pay off.
  • Build a labeled release set containing approved rotation and token-lifecycle docs plus tempting unauthorized near-matches. Compare Recall@K with the full-precision baseline, and reject any serving result sourced from unapproved text.

Common pitfalls

When a release goes wrong, the symptom usually points to one boundary. Use it to choose the next measurement instead of changing every knob at once.

  • Recall falls after INT8 rollout. Cause: scale got stretched by outliers, or offline and online normalization disagree. Fix: clip the calibration range robustly, recheck the normalization contract, and benchmark recall on real queries.
  • ANN results look fine offline but wrong in production. Cause: you tuned on MTEB or synthetic samples instead of real ticket language and chunking. Fix: build an eval set from your own queries and review misses manually.
  • Search is still too slow after quantization. Cause: you compressed payload but still rescore too many candidates or set ef_search too high. Fix: tune shortlist size, HNSW parameters, and rerank depth together.
  • Migration silently degrades quality. Cause: old and new vectors use different model versions, dimensions, or quantization settings. Fix: version the whole embedding contract and build the new index side by side before traffic cutover.
Complete the lesson

Mastery Check

Answer every question, then check your score. Score 75% or higher to mark this lesson complete.

1.A retrieval service is intended to use cosine-like ranking. The query is [1, 0], api-key-rotation-v3 is [0.95, 0.05], and unauthorized private-incident-note-44 is [8, 6]. Which outcome shows the effect of skipping document normalization?

Correct answer: Raw dot ranks the private note first, while L2-normalizing documents and query ranks the rotation guide first because its direction is closer to the query.

The raw dot scores are 0.95 for the rotation guide and 8 for the private note, so the large-norm private note wins. After L2 normalization, the rotation guide is nearly aligned with [1, 0], while the private note has cosine 0.8. If the serving contract is cosine-like ranking, both indexed documents and queries must follow the same normalization convention.

2.A query and two document vectors are all L2-normalized. The query dot products are 0.73 with document A and 0.60 with document B. What does this imply for cosine similarity and squared Euclidean distance ranking?

Correct answer: Cosines are 0.73 and 0.60; squared L2 distances are 0.54 and 0.80, so A ranks closer under both.

For unit vectors, cosine equals the dot product. Squared Euclidean distance is 2 - 2 times the dot product, so document A has distance 2 - 1.46 = 0.54 and document B has distance 2 - 1.20 = 0.80. Higher dot product, higher cosine, and smaller squared L2 distance induce the same ranking.

3.A provider documents 3072-, 1024-, and 256-dimensional outputs for a Matryoshka-trained model. A team wants to deploy a manually sliced 384-dimensional prefix under a unit-vector dot-product contract. What must it do before deployment?

Correct answer: Normalize each sliced document and query prefix, then measure retrieval recall on labeled evidence queries.

Matryoshka training makes selected prefix widths useful; it doesn't guarantee every arbitrary cut point or task. Manual slicing also changes the vector norm, so both document and query prefixes must be normalized for a unit-vector contract. Evaluation is still required because normalization can't restore information removed by truncation.

4.A vector store holds 1,000,000 embeddings with 1,536 dimensions stored as float32. Using decimal GB and ignoring graph, scale, and metadata overhead, which raw payload comparison is correct after INT8 scalar quantization?

Correct answer: FP32 is about 6.14 GB, and INT8 is about 1.54 GB, because bytes per dimension drop from 4 to 1.

Payload is vector count times dimensions times bytes per dimension. FP32 therefore uses 1,000,000 * 1,536 * 4 = 6.144 billion bytes, or 6.14 decimal GB. INT8 uses one byte per dimension, producing 1.536 billion bytes, or 1.54 decimal GB. Dividing by 1024^3 would instead report GiB.

5.A 768-dimensional float32 vector is compressed with product quantization using 96 subspaces and one 8-bit centroid ID per subspace. Which description of storage and first-stage scoring is correct?

Correct answer: It stores 96 one-byte centroid IDs, about 96 bytes versus 3,072 bytes, and ADC scores them by summing query-to-centroid lookup distances.

A 768-dimensional FP32 vector uses 768 * 4 = 3,072 bytes. With 96 PQ subspaces and 8 bits per subspace, the stored database code has 96 one-byte IDs. In the original ADC setup, the full-precision query is compared to centroids in each subspace, and the selected lookup distances are summed.

6.A symmetric INT8 scalar quantizer sets a dimension's scale from a calibration sample. Most values in that dimension are between -1 and 1, but one calibration vector has value 25. What failure mode should you check before rollout?

Correct answer: The outlier can stretch the scale, so ordinary values use fewer INT8 buckets; test robust clipping or another calibration and remeasure shortlist recall.

In symmetric scalar quantization, the scale is tied to the chosen absolute bound. A large outlier makes each integer step coarser for the ordinary range, so typical values collapse into fewer buckets and reconstruction error can rise. Clipping can help, but the release decision must be based on retrieval shortlist recall, not reconstruction error alone.

7.Under a tight RAM and latency budget, you use packed binary sign bits to shortlist API-doc candidates. Binary Hamming search is a coarse first-stage filter, and private incident notes must never be served as answer evidence. Which serving pattern preserves evidence quality and authorization?

Correct answer: Use binary search only to form a shortlist, check that approved evidence remains in Recall@K, then rescore approved survivors with INT8 or FP32 and optionally rerank.

Binary quantization stores one sign bit per dimension, so Hamming distance is fast but coarse. It's appropriate as a first-stage filter only if the relevant approved passages remain in the shortlist. Final evidence selection should use higher-precision scoring on the shortlist and keep the approval boundary intact.

8.An HNSW index is fast but misses required Recall@K on a labeled query set. You want a query-time adjustment before rebuilding the graph; in HNSW, ef_search controls serving-time candidate exploration, while M and ef_construction are build-time settings. Which change and trade-off are appropriate?

Correct answer: Increase ef_search; it explores more candidates and can improve recall at the cost of higher query latency.

In the lesson's HNSW table, ef_search is the serving-time knob that buys more candidate exploration and usually costs latency. M and ef_construction are build-time controls, so changing them requires rebuilding candidate indexes. If ef_search can't reach the recall target within the latency budget, then construction settings should be revisited and measured on the corpus.

8 questions remaining.

Next Step
Continue to Scaled Dot-Product Attention

You just ranked passages with inner products, and you saw why unnormalized scores and compressed codes can change that ranking. Attention uses the same inner product inside a sequence: each token query is compared to other tokens' keys, then divided by $\sqrt{d_k}$ before softmax so those scores don't saturate.

PreviousSentence Embeddings & Contrastive Loss
Share this article
XFacebookLinkedInBlueskyRedditHacker NewsEmail
References

Product Quantization for Nearest Neighbor Search.

Jégou, H., Douze, M., & Schmid, C. · 2011 · IEEE TPAMI 2011

https://dblp.org/rec/journals/pami/JegouDS11

Matryoshka Representation Learning.

Kusupati, A., et al. · 2022 · NeurIPS 2022

https://arxiv.org/abs/2205.13147

Vector embeddings

OpenAI · 2024

https://developers.openai.com/api/docs/guides/embeddings

Billion-scale similarity search with GPUs.

Johnson, J., Douze, M., & Jégou, H. · 2017 · arXiv preprint

https://arxiv.org/abs/1702.08734

Optimized Product Quantization

Ge, T., He, K., Ke, Q., & Sun, J. · 2013

https://openaccess.thecvf.com/content_cvpr_2013/html/Ge_Optimized_Product_Quantization_2013_CVPR_paper.html

Rerank API.

Cohere. · 2026 · Official documentation

https://docs.cohere.com/reference/rerank

Efficient and Robust Approximate Nearest Neighbor Using Hierarchical Navigable Small World Graphs.

Malkov, Y. A., & Yashunin, D. A. · 2018 · IEEE Transactions on Pattern Analysis and Machine Intelligence

https://arxiv.org/abs/1603.09320

MTEB: Massive Text Embedding Benchmark.

Muennighoff, N., et al. · 2023 · EACL 2023

https://arxiv.org/abs/2210.07316

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.