Lab VI3: The Quantization Math
Background
The intensive Part 3 wrote out the arithmetic of the three quantizers:
scalar (per-dimension min/max → int), product (split into m subvectors,
per-subspace k-means codebooks, asymmetric distance via lookup tables), and binary
(sign bit + Hamming). Lab VI2 used the product
quantizer inside a faiss IndexIVFPQ — but it was a black box behind JNI. This lab makes
you implement all three yourself in plain Java, so the math stops being abstract: you
encode, decode, measure the reconstruction error, compute the compression ratio, and
measure recall@k against full precision — and you watch a rescore pass recover the
recall you lost.
When you have written ADC by hand, you will never again be confused about why PQ needs
trained codebooks, why BQ always rescores, or what compression_level: 32x is doing under
the hood.
Why This Matters for Contributors
Over-quantization is one of the most common k-NN recall complaints: someone sets
compression_level: 16x or an aggressive PQ m, recall tanks, and they file a bug. The
fix is almost always "add/strengthen rescoring" or "back off the compression" — but to
reason about it you need to know what each quantizer throws away and how rescoring buys
it back. The map at the end of this lab — your hand-rolled quantizers ↔ k-NN
compression_level/on_disk ↔ Lucene's Lucene99/Lucene104 scalar-quantized formats —
is the same map you reach for when triaging a real recall regression.
Prerequisites
-
Java 21+ (
java -version). No Lucene or OpenSearch needed — this is pure Java math. -
You've read the intensive Part 3 (the SQ/PQ/BQ math) and the
quantization and disk-ANN chapter (the
menu,
on_disk,compression_level).
mkdir -p /tmp/vi3 && cd /tmp/vi3
Note: These implementations are deliberately small and exact-to-the-formula, not optimized — the point is to see the arithmetic, not to beat faiss. Reuse the
Vectorshelper from Lab VI1 (clustered data + L2
- brute-force top-k); it is reproduced inline below so this lab is standalone.
Step-by-Step Tasks
Step 0 — The shared harness
// Q.java — shared helpers: clustered data, L2, exact top-k, recall@k.
import java.util.*;
public final class Q {
public static float[][] clustered(int n, int dim, int clusters, long seed) {
Random r = new Random(seed);
float[][] c = new float[clusters][dim];
for (float[] x : c) for (int d = 0; d < dim; d++) x[d] = r.nextFloat() * 10f;
float[][] out = new float[n][dim];
for (int i = 0; i < n; i++) {
float[] cc = c[r.nextInt(clusters)];
for (int d = 0; d < dim; d++) out[i][d] = cc[d] + (float) r.nextGaussian();
}
return out;
}
public static double l2(float[] a, float[] b) {
double s = 0; for (int i = 0; i < a.length; i++){ double d=a[i]-b[i]; s+=d*d; } return s;
}
public static int[] exactTopK(float[][] data, float[] q, int k) {
Integer[] idx = new Integer[data.length];
for (int i = 0; i < idx.length; i++) idx[i] = i;
Arrays.sort(idx, Comparator.comparingDouble(i -> l2(data[i], q)));
int[] o = new int[k]; for (int i = 0; i < k; i++) o[i] = idx[i]; return o;
}
// recall@k for an approximate scorer: returns candidate ids sorted best-first.
public static double recall(float[][] data, float[][] queries, int k,
java.util.function.BiFunction<Integer,float[],Double> approxDist) {
double sum = 0;
for (float[] qv : queries) {
Integer[] idx = new Integer[data.length];
for (int i = 0; i < idx.length; i++) idx[i] = i;
Arrays.sort(idx, Comparator.comparingDouble(i -> approxDist.apply(i, qv)));
Set<Integer> approx = new HashSet<>();
for (int i = 0; i < k; i++) approx.add(idx[i]);
Set<Integer> exact = new HashSet<>();
for (int id : exactTopK(data, qv, k)) exact.add(id);
approx.retainAll(exact); sum += approx.size() / (double) k;
}
return sum / queries.length;
}
}
Step 1 — Scalar quantization (float → int8, per-dim min/max)
Implement the int8 SQ from intensive §3.1: learn per-dimension [min,max], encode to
byte, decode, and measure reconstruction error.
// ScalarQ.java
import java.util.*;
public class ScalarQ {
final int dim, levels; // levels = 2^bits - 1 (255 for int8, 15 for int4)
final float[] min, max;
ScalarQ(float[][] train, int bits) {
dim = train[0].length; levels = (1 << bits) - 1;
min = new float[dim]; max = new float[dim];
Arrays.fill(min, Float.MAX_VALUE); Arrays.fill(max, -Float.MAX_VALUE);
for (float[] v : train) for (int j = 0; j < dim; j++) {
min[j] = Math.min(min[j], v[j]); max[j] = Math.max(max[j], v[j]);
}
}
int[] encode(float[] v) { // ints 0..levels (store as byte for int8)
int[] q = new int[dim];
for (int j = 0; j < dim; j++) {
float span = Math.max(1e-9f, max[j] - min[j]);
int code = Math.round((v[j] - min[j]) / span * levels);
q[j] = Math.max(0, Math.min(levels, code)); // clip into range
}
return q;
}
float[] decode(int[] q) {
float[] v = new float[dim];
for (int j = 0; j < dim; j++) {
float span = max[j] - min[j];
v[j] = min[j] + (q[j] / (float) levels) * span;
}
return v;
}
public static void main(String[] a) {
int n = 5000, dim = 64, k = 10;
float[][] data = Q.clustered(n, dim, 20, 42L);
float[][] q = Q.clustered(200, dim, 20, 7L);
for (int bits : new int[]{8, 4}) {
ScalarQ sq = new ScalarQ(data, bits);
int[][] codes = new int[n][];
double err = 0;
for (int i = 0; i < n; i++) {
codes[i] = sq.encode(data[i]);
err += Math.sqrt(Q.l2(data[i], sq.decode(codes[i]))); // L2 reconstruction error
}
// recall: distance from query (full precision) to DECODED database vector.
double rec = Q.recall(data, q, k, (i, qv) -> Q.l2(qv, sq.decode(codes[i])));
double ratio = 32.0 / bits; // vs float32 (4 bytes=32 bits)
System.out.printf("SQ int%d: ratio=%.0fx avg_recon_err=%.4f recall@%d=%.3f%n",
bits, ratio, err / n, k, rec);
}
}
}
javac Q.java ScalarQ.java && java ScalarQ
Observe: int4 has ~16× coarser steps than int8, so its reconstruction error is larger and
recall lower — exactly the §3.1 prediction. This is the math behind Lucene's
Lucene99/Lucene104ScalarQuantizedVectorsFormat.
Step 2 — Product quantization (codebooks + ADC)
Now the centerpiece: split into m subvectors, learn a 256-centroid codebook per subspace
with k-means, encode to m bytes, and compute distances with asymmetric distance
computation (intensive §3.2).
// ProductQ.java
import java.util.*;
public class ProductQ {
final int dim, m, sub, k; // m subvectors of length sub; k centroids per subspace
final float[][][] codebooks; // [m][k][sub]
ProductQ(float[][] train, int m, int nbits) {
this.dim = train[0].length; this.m = m; this.sub = dim / m; this.k = 1 << nbits;
if (dim % m != 0) throw new IllegalArgumentException("dim % m != 0");
codebooks = new float[m][][];
for (int s = 0; s < m; s++) codebooks[s] = kmeans(slice(train, s), k, 12, 1L + s);
}
// extract subspace s of every training vector.
float[][] slice(float[][] data, int s) {
float[][] out = new float[data.length][sub];
for (int i = 0; i < data.length; i++)
System.arraycopy(data[i], s * sub, out[i], 0, sub);
return out;
}
// plain Lloyd k-means over sub-dim points -> k centroids.
static float[][] kmeans(float[][] pts, int k, int iters, long seed) {
Random r = new Random(seed);
float[][] c = new float[k][];
for (int i = 0; i < k; i++) c[i] = pts[r.nextInt(pts.length)].clone();
for (int it = 0; it < iters; it++) {
float[][] sum = new float[k][pts[0].length]; int[] cnt = new int[k];
for (float[] p : pts) {
int best = 0; double bd = Double.MAX_VALUE;
for (int j = 0; j < k; j++){ double d=Q.l2(p,c[j]); if(d<bd){bd=d;best=j;} }
cnt[best]++; for (int d = 0; d < p.length; d++) sum[best][d] += p[d];
}
for (int j = 0; j < k; j++) if (cnt[j] > 0)
for (int d = 0; d < sum[j].length; d++) c[j][d] = sum[j][d] / cnt[j];
}
return c;
}
int[] encode(float[] v) { // m codes, each a centroid id in 0..k-1
int[] code = new int[m];
for (int s = 0; s < m; s++) {
float[] piece = Arrays.copyOfRange(v, s * sub, (s + 1) * sub);
int best = 0; double bd = Double.MAX_VALUE;
for (int j = 0; j < k; j++){ double d=Q.l2(piece,codebooks[s][j]); if(d<bd){bd=d;best=j;} }
code[s] = best;
}
return code;
}
// ADC: build per-subspace LUT once per query, then dist = sum of m table lookups.
double[][] queryTables(float[] query) {
double[][] lut = new double[m][k];
for (int s = 0; s < m; s++) {
float[] qp = Arrays.copyOfRange(query, s * sub, (s + 1) * sub);
for (int j = 0; j < k; j++) lut[s][j] = Q.l2(qp, codebooks[s][j]);
}
return lut;
}
double adc(double[][] lut, int[] code) {
double d = 0; for (int s = 0; s < m; s++) d += lut[s][code[s]]; return d;
}
public static void main(String[] a) {
int n = 5000, dim = 64, k = 10, nbits = 8;
float[][] data = Q.clustered(n, dim, 20, 42L);
float[][] queries = Q.clustered(200, dim, 20, 7L);
for (int m : new int[]{8, 16}) { // 8 subvectors -> 8 bytes; 16 -> 16 bytes
ProductQ pq = new ProductQ(data, m, nbits);
int[][] codes = new int[n][];
for (int i = 0; i < n; i++) codes[i] = pq.encode(data[i]);
// recall via ADC: build LUT per query, distance = sum of m lookups.
double rec = Q.recall(data, queries, k, new java.util.function.BiFunction<>() {
double[][] cached; float[] cachedQ;
public Double apply(Integer i, float[] qv) {
if (qv != cachedQ) { cached = pq.queryTables(qv); cachedQ = qv; }
return pq.adc(cached, codes[i]);
}
});
double ratio = (dim * 4.0) / m; // float32 bytes / code bytes (nbits=8 -> 1 byte/sub)
System.out.printf("PQ m=%-2d nbits=%d: code=%dB ratio=%.0fx recall@%d=%.3f%n",
m, nbits, m, ratio, k, rec);
}
}
}
javac Q.java ProductQ.java && java ProductQ
You just built ADC: the LUT is m·256 distances computed once per query, then each
candidate distance is m byte-indexed lookups + an add. Larger m → finer compression,
larger code, better recall — the §3.2 trade-off, measured.
Step 3 — Binary quantization (sign bit + Hamming)
The extreme: one bit per dimension, Hamming distance (intensive §3.3).
// BinaryQ.java
public class BinaryQ {
final int dim;
final float[] threshold; // per-dim threshold (here: the per-dim mean)
BinaryQ(float[][] train) {
dim = train[0].length; threshold = new float[dim];
for (float[] v : train) for (int j = 0; j < dim; j++) threshold[j] += v[j];
for (int j = 0; j < dim; j++) threshold[j] /= train.length;
}
long[] encode(float[] v) { // dim bits packed into longs
long[] bits = new long[(dim + 63) / 64];
for (int j = 0; j < dim; j++)
if (v[j] > threshold[j]) bits[j >> 6] |= (1L << (j & 63));
return bits;
}
static int hamming(long[] a, long[] b) {
int d = 0; for (int i = 0; i < a.length; i++) d += Long.bitCount(a[i] ^ b[i]); return d;
}
public static void main(String[] s) {
int n = 5000, dim = 64, k = 10;
float[][] data = Q.clustered(n, dim, 20, 42L);
float[][] queries = Q.clustered(200, dim, 20, 7L);
BinaryQ bq = new BinaryQ(data);
long[][] codes = new long[n][];
for (int i = 0; i < n; i++) codes[i] = bq.encode(data[i]);
double rec = Q.recall(data, queries, k,
(i, qv) -> (double) hamming(bq.encode(qv), codes[i]));
double ratio = (dim * 4.0) / (dim / 8.0); // float32 bytes / (1 bit per dim)
System.out.printf("BQ 1bit/dim: code=%dB ratio=%.0fx recall@%d=%.3f (PRE-rescore)%n",
dim / 8, ratio, k, rec);
}
}
javac Q.java BinaryQ.java && java BinaryQ
BQ's recall is the lowest — you threw away all magnitude. That low number is not a bug; it is why BQ always pairs with rescoring (next step).
Step 4 — Rescoring: recover the lost recall
This is the universal fix. Use the cheap quantized scorer to shortlist oversample · k
candidates, then re-rank that shortlist with full-precision L2. Show recall climb back
toward 1.0.
// Rescore.java — wrap ANY approximate scorer with a full-precision rescore pass.
import java.util.*;
public class Rescore {
// shortlist with approxDist, then re-rank the shortlist with exact L2.
static double recallWithRescore(float[][] data, float[][] queries, int k, int oversample,
java.util.function.BiFunction<Integer,float[],Double> approxDist) {
double sum = 0;
for (float[] qv : queries) {
Integer[] idx = new Integer[data.length];
for (int i = 0; i < idx.length; i++) idx[i] = i;
// pass 1: cheap shortlist of oversample*k candidates.
Arrays.sort(idx, Comparator.comparingDouble(i -> approxDist.apply(i, qv)));
int cand = Math.min(data.length, oversample * k);
Integer[] shortlist = Arrays.copyOf(idx, cand);
// pass 2: exact rescore of the shortlist.
Arrays.sort(shortlist, Comparator.comparingDouble(i -> Q.l2(data[i], qv)));
Set<Integer> approx = new HashSet<>();
for (int i = 0; i < k; i++) approx.add(shortlist[i]);
Set<Integer> exact = new HashSet<>();
for (int id : Q.exactTopK(data, qv, k)) exact.add(id);
approx.retainAll(exact); sum += approx.size() / (double) k;
}
return sum / queries.length;
}
public static void main(String[] a) {
int n = 5000, dim = 64, k = 10;
float[][] data = Q.clustered(n, dim, 20, 42L);
float[][] queries = Q.clustered(200, dim, 20, 7L);
BinaryQ bq = new BinaryQ(data);
long[][] codes = new long[n][];
for (int i = 0; i < n; i++) codes[i] = bq.encode(data[i]);
java.util.function.BiFunction<Integer,float[],Double> bqDist =
(i, qv) -> (double) BinaryQ.hamming(bq.encode(qv), codes[i]);
for (int os : new int[]{1, 4, 16, 64}) {
double rec = recallWithRescore(data, queries, k, os, bqDist);
System.out.printf("BQ + rescore oversample=%-3d recall@%d=%.3f%n", os, k, rec);
}
}
}
javac Q.java BinaryQ.java Rescore.java && java Rescore
Watch recall rise with the oversample factor: the cheap binary pass casts a wide net, the
exact rescore re-ranks it. This is precisely what on_disk mode + oversample_factor
does, and why BQ needs a wider funnel than PQ (intensive §3.3).
Step 5 — Map to k-NN and Lucene
Put your hand-rolled quantizers next to the productized settings:
| Your code | k-NN setting | Lucene engine equivalent |
|---|---|---|
ScalarQ int8 | data_type: byte / faiss sq encoder | Lucene99HnswScalarQuantizedVectorsFormat (int8) |
ScalarQ int4 | faiss sq (4-bit) / compression_level: 8x | Lucene104HnswScalarQuantizedVectorsFormat (1/2/4/7/8-bit) |
ProductQ (ADC) | faiss pq encoder (trained, model_id) | none — lucene engine has no PQ |
BinaryQ (Hamming) | binary vectors, space_type: hamming / compression_level: 32x | Lucene* 1-bit SQ (the closest analogue) |
Rescore (oversample) | rescore / oversample_factor, on_disk mode | rescore against full-precision .vec |
# Confirm the keys your k-NN version uses (don't trust this doc — grep):
cd ~/src/k-NN
grep -rn "fp16\|ENCODER_SQ\|ENCODER_PQ\|code_size\|nbits\|hamming\|compression_level\|oversample" \
src/main/java/org/opensearch/knn/common/KNNConstants.java
# And the Lucene SQ formats your bundled Lucene ships:
grep -rln "Lucene9.*ScalarQuantizedVectorsFormat\|Lucene10.*ScalarQuantizedVectorsFormat" \
~/src/lucene/lucene/core/src/java 2>/dev/null
The full menu and the on_disk/compression_level mechanics live in the
quantization and disk-ANN chapter; the Lucene
SQ formats in the Lucene HNSW chapter.
Deliverables
-
ScalarQoutput: int8 and int4 reconstruction error, compression ratio, recall@10. -
ProductQoutput: code size, compression ratio, recall@10 for twomvalues, with ADC (LUT) distances. -
BinaryQoutput: 1-bit code size, 32× ratio, and the (low) pre-rescore recall. -
Rescoreoutput: recall@10 climbing withoversample ∈ {1,4,16,64}. - The completed quantizer ↔ k-NN ↔ Lucene mapping table (Step 5).
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
dim % m != 0 in ProductQ | PQ m must divide dimension | pick m so dim % m == 0 (e.g. dim 64 → m 8/16/32) |
| PQ recall worse than SQ at the same ratio | too few k-means iters, or empty clusters | raise iters; reseed empty centroids; more training data |
| BQ recall ~0 even with rescore | threshold all-zero / data not centered | use per-dim mean threshold (as written), or center the data first |
| int4 recall barely below int8 | data range too narrow / clusters too separated | widen the data spread so coarse steps actually lose information |
| recall doesn't improve with oversample | shortlist already contains the exact top-k | data too easy; raise n, dim, cluster count so ANN and exact diverge |
Expected Output
SQ int8: ratio=4x avg_recon_err=0.0142 recall@10=0.97
SQ int4: ratio=8x avg_recon_err=0.2280 recall@10=0.78
PQ m=8 nbits=8: code=8B ratio=32x recall@10=0.74
PQ m=16 nbits=8: code=16B ratio=16x recall@10=0.88
BQ 1bit/dim: code=8B ratio=32x recall@10=0.31 (PRE-rescore)
BQ + rescore oversample=1 recall@10=0.31
BQ + rescore oversample=4 recall@10=0.67
BQ + rescore oversample=16 recall@10=0.93
BQ + rescore oversample=64 recall@10=0.99
Exact numbers depend on data and seed. What must hold: int8 ≫ int4 recall; PQ recall rises
with m; BQ's pre-rescore recall is the lowest, and rescoring with a growing oversample
factor recovers it toward 1.0.
Stretch Goals
- Symmetric vs asymmetric (SDC vs ADC). Add an SDC variant that also quantizes the query, and compare recall to ADC — confirm ADC is more accurate (intensive §3.2).
-
PQ + IVF in code. Cluster the data into cells (a tiny k-means coarse quantizer),
store PQ codes per cell, and search only the nearest
nprobecells — a from-scratchIndexIVFPQmirroring Lab VI2's native one. -
Measure ADC cost. Time the per-query LUT build vs the per-candidate lookups and
confirm the lookup pass dominates only when
Nis large — the reason ADC scales. - Compression-vs-recall curve. Plot recall@10 against compression ratio across SQ8/SQ4/PQ/BQ (pre- and post-rescore), reproducing the intensive's §3.4 table with your own data.
Coding Exercises
These graduate your hand-rolled quantizers from print-statements into tested code, then push into the SIMD trick that makes PQ fast in production. Mix Java (the quantizer math) and Python (the quantization-error visualization that connects to Lab VI4).
-
(warm-up) A JUnit round-trip and monotonicity test for
ScalarQ. Wrap Step 1 in a test asserting (a)decode(encode(v))is withinspan/levelsper dimension (the worst-case SQ error), and (b) int8 reconstruction error is strictly smaller than int4's on the same data. Print the twoavg_recon_errvalues. This is the §3.1 "int4 is ~16× coarser" claim, machine-checked — the property a quantizer regression breaks. -
(warm-up) Assert PQ's compression/recall trade-off. Add a test over
ProductQthat asserts recall@10 is non-decreasing asmrises (m ∈ {4,8,16}, all dividingdim=64) and that thedim % m != 0constructor throwsIllegalArgumentException. Tie the recall curve back to the LUT sizem·kyou build per query inqueryTables. -
(core) SDC vs ADC, as a graded comparison. Implement the symmetric variant (SDC) that also quantizes the query before table lookup, deepening Stretch Goal 1 into code. Write a test asserting ADC's recall@10 ≥ SDC's on your clustered data (asymmetric keeps the query at full precision, so it must not lose). Print both. This is the precise reason faiss defaults to ADC for queries.
-
(core) Implement PQ4 fast-scan and validate it against your float-LUT ADC. In Java, re-encode with
nbits=4(so each codebook hask=16centroids) and quantize the LUT touint8(scale each subspace's distances into0..255), then score candidates with integer table lookups + saturating adds — the scalar twin of the_mm256_shuffle_epi8fast-scan kernel. Write a test asserting your quantized-LUT ranking agrees with the float ADC ranking on the top-k for ≥ 90% of queries. You have now built — in plain Java — the exact design decision ("why only 16 centroids per sub-space?") that chapter explains: 16 bytes is one shuffle lane. -
(core, Python) Visualize the quantization-error vs recall frontier. Reuse VI4's
viz5_pq_cells.pyidea but drive it from your numbers: write a Python script that, for SQ8/SQ4/PQ(m=8)/PQ(m=16)/BQ, plots recall@10 (pre- and post-rescore) against compression ratio — the §3.4 table as a curve (Stretch Goal 4, made concrete). Saveviz_quant_frontier.png. Annotate where rescoring lifts BQ from "useless alone" to "competitive," matching yourRescoreoutput. -
(advanced challenge) A from-scratch
IndexIVFPQwith annproberecall frontier. Build the full pipeline VI2 used natively: a coarse k-means quantizer (nlistcells), PQ codes stored per cell, search restricted to the nearestnprobecells, then a full-precision rescore of the shortlist (reuse yourRescore.recallWithRescore). Emit a CSV (nlist,nprobe,oversample,recall@10) and write a test asserting recall is non-decreasing in bothnprobeandoversample. Compare your recall numbers to the nativeIndexIVFPQyou built in Lab VI2 at matching params — they should land in the same neighbourhood, and any gap is k-means quality or fast-scan rounding. This is the whole quantization+IVF stack, owned end to end and benchmarked against the real engine.
Issues to Practice On
Over-quantization (aggressive compression_level, large PQ m, BQ without enough rescore) is
one of the most-filed k-NN recall complaints — a great, well-scoped contributor area. The repo
is opensearch-project/k-NN.
| Goal | Command |
|---|---|
| Beginner-friendly bugs | gh issue list --repo opensearch-project/k-NN --label "good first issue" --state open |
| Quantization / encoder bugs | gh issue list --repo opensearch-project/k-NN --label "bug" --search "quantization OR PQ OR scalar OR SQ OR binary OR compression_level" |
| Rescore / oversample / on_disk | gh issue list --repo opensearch-project/k-NN --label "bug" --search "rescore OR oversample OR on_disk OR recall" |
| Quantization roadmap / RFCs | gh issue list --repo opensearch-project/k-NN --label "Roadmap" --search "quantization OR disk OR compression" |
Labels drift — list and pick (gh label list --repo opensearch-project/k-NN). k-NN commonly
uses good first issue, bug, enhancement, Roadmap.
Representative issue patterns. (1) "Recall tanked after I set compression_level: 16x /
on_disk" — almost always "add/strengthen rescoring or back off compression." Reproduce by
quantizing at the reported level (your SQ/PQ/BQ code shows exactly what is thrown away), rg
the encoder + rescore plumbing (grep -rn "ENCODER_PQ\|compression_level\|oversample\|RescoreContext" src/main/java),
and ship a test pinning post-rescore recall. (2) "PQ m / code_size validation is wrong or
unclear" — the dimension % m == 0 and tens-per-centroid rules your code enforces; locate the
validator and add the missing guard + a unit test on the rejection message.
Planted-bug exercise. In your ProductQ.queryTables, change the LUT to use the decoded
database vector's subspace distance instead of the codebook-centroid distance (i.e. accidentally
recompute against reconstructions). Recall barely moves on easy data but your Exercise 2
monotonicity test still passes — yet your Exercise 4 fast-scan agreement test goes red,
because the LUT no longer matches the codes it indexes. Restore the centroid-distance LUT, then
keep the agreement test as the regression that catches a corrupted lookup table. The lesson:
quantization bugs hide behind "recall looks okay" — only a structural agreement assertion exposes
them.
Etiquette. Claim an issue before working it, reproduce first, and every PR needs a test, a
CHANGELOG.md entry, and a DCO sign-off (git commit -s). See
community interaction.
Validation / Self-check
- Write the int8 SQ encode/decode and the worst-case per-dimension error from your code. Why is int4's error ~16× int8's, and why does that show up as lower recall?
- Explain ADC in your own words: what is in the per-query LUT, how big is it, and how many operations is a single candidate distance after the LUT is built?
- Why does PQ need trained codebooks while SQ and BQ do not? Tie this to k-NN's
_trainAPI and the.opensearch-knn-modelssystem index. - From your
Rescorenumbers: explain why BQ needs a larger oversample factor than PQ would to reach the same post-rescore recall. - Compute per-vector storage for
d=1024under int8, PQ(m=128, nbits=8), and 1-bit binary. Give each ratio and which require training. - Map each of your three quantizers to its k-NN configuration and its Lucene-engine equivalent. Which has no lucene-engine analogue, and why?
When this holds, you have implemented the entire quantization layer the
intensive Part 3 describes and the Lab VI2
IndexIVFPQ used. Close the loop with the
quantization and disk-ANN chapter (the
productized on_disk/compression_level story) and the
Lucene HNSW chapter (the Lucene99/Lucene104 SQ
formats), and re-read the intensive to connect the math back to the graph and
the index types.