Lab LD3: HNSW Vector Files on Disk
Background
Vectors are written by a KnnVectorsFormat into three files —
.vec (the raw or quantized vectors), .vex (the HNSW graph adjacency), and
.vem (metadata: dimension, similarity, the graph entry node, offsets, and
quantization params). The HNSW chapter
explained the algorithm and these files at a concept level; the
masterclass index nailed the layout. In this lab you produce those
files with KnnFloatVectorField + Lucene99HnswVectorsFormat, then:
- Locate and size the
.vec/.vex/.vemfiles withfind/du. - Read their headers with
xxdto confirm theCodecUtilmagic and the codec name written into each file. - Reason about the on-disk layout: the
.vecis vectors back-to-back (offset = ord × dims × 4); the.vexis per-level neighbour lists; the.vemnames the entry node. - Compare raw vs scalar-quantized storage by building the same index with
Lucene99HnswScalarQuantizedVectorsFormatand diffing.vecsizes.
Finally you map all of it to OpenSearch's k-NN lucene engine (engines chapter).
Why This Matters for Contributors
Vector indices are where disk and RAM pressure show up first: a float32 768-dim
vector is 3 KB, and the graph plus vectors must sit in page cache for fast search. A
contributor who can find the .vec/.vex/.vem, read their headers, and compare
quantized vs raw sizes can diagnose "k-NN search is slow / OOM on merge / disk blew
up" issues and reason about the recall/memory trade-off of quantization. Because the
lucene engine is Lucene HNSW, this is the exact storage OpenSearch ships.
Prerequisites
- JDK 17+ and a
lucene-core-*.jar(the HNSW format is in core). Setexport LUCENE_CP=...as in Lab LD1. find,du,xxdon PATH.- Read index.md (the HNSW section) and hnsw-vector-search.md.
export LUCENE_CP="/path/to/lucene-core-9.x.x.jar"
export LUCENE_SRC="/path/to/lucene/lucene/core/src/java/org/apache/lucene"
Note: Format class names carry a version (
Lucene99...,Lucene104...). If a class below doesn't resolve, grep for the current one:grep -rln "HnswVectorsFormat" "$LUCENE_SRC/codecs/".
Step-by-Step Tasks
Step 1 — Index float vectors (raw float32)
Vectors.java indexes 5000 random 64-dim vectors with the default HNSW format and a
cosine similarity, then runs a k-NN query. We keep dims small (64) so the files are
readable but the structure is identical to 768-dim.
import org.apache.lucene.codecs.Codec;
import org.apache.lucene.codecs.KnnVectorsFormat;
import org.apache.lucene.codecs.lucene99.Lucene99HnswVectorsFormat;
import org.apache.lucene.codecs.perfield.PerFieldKnnVectorsFormat;
import org.apache.lucene.document.*;
import org.apache.lucene.index.*;
import org.apache.lucene.search.*;
import org.apache.lucene.store.*;
import java.nio.file.*;
import java.util.Random;
public class Vectors {
static final int DIMS = 64, N = 5000;
static float[] randomVec(Random rnd) {
float[] v = new float[DIMS];
for (int i = 0; i < DIMS; i++) v[i] = rnd.nextFloat();
return v; // cosine normalizes internally
}
public static void main(String[] args) throws Exception {
Path dir = Paths.get("ld3-vec-raw");
Random rnd = new Random(42);
try (Directory d = FSDirectory.open(dir)) {
IndexWriterConfig cfg = new IndexWriterConfig();
cfg.setUseCompoundFile(false);
// Default HNSW: float32 vectors + graph. M=16, beamWidth=100 by default.
cfg.setCodec(new org.apache.lucene.codecs.lucene99.Lucene99Codec() {
@Override public KnnVectorsFormat getKnnVectorsFormatForField(String field) {
return new Lucene99HnswVectorsFormat(16, 100); // (maxConn=M, beamWidth=efConstruction)
}
});
try (IndexWriter w = new IndexWriter(d, cfg)) {
for (int i = 0; i < N; i++) {
Document doc = new Document();
doc.add(new KnnFloatVectorField("embedding", randomVec(rnd),
VectorSimilarityFunction.COSINE));
doc.add(new StoredField("id", i));
w.addDocument(doc);
}
w.forceMerge(1); // one segment -> one .vec/.vex/.vem, easiest to inspect
w.commit();
}
try (DirectoryReader r = DirectoryReader.open(d)) {
IndexSearcher s = new IndexSearcher(r);
float[] q = randomVec(new Random(7));
TopDocs hits = s.search(new KnnFloatVectorQuery("embedding", q, 10), 10);
System.out.println("kNN top-10 totalHits=" + hits.totalHits);
for (LeafReaderContext ctx : r.leaves()) {
FloatVectorValues fvv = ctx.reader().getFloatVectorValues("embedding");
if (fvv == null) continue;
System.out.println("FloatVectorValues: dims=" + fvv.dimension()
+ " size=" + fvv.size()); // 64 and 5000
}
}
}
}
}
javac -cp "$LUCENE_CP" Vectors.java
java -cp "$LUCENE_CP:." Vectors
Expected:
kNN top-10 totalHits=10
FloatVectorValues: dims=64 size=5000
Step 2 — Find and size the vector files
find ld3-vec-raw -name "*.vec" -o -name "*.vex" -o -name "*.vem"
# ld3-vec-raw/_X.vec ld3-vec-raw/_X.vex ld3-vec-raw/_X.vem
du -h ld3-vec-raw/_*.vec ld3-vec-raw/_*.vex ld3-vec-raw/_*.vem
Sanity-check the .vec size against the layout size = N × dims × 4 + header/footer:
5000 vectors × 64 dims × 4 bytes = 1,280,000 bytes ≈ 1.3 MB <- ~ your .vec size
The .vec is just the vectors back to back — that is why du matches the
arithmetic so closely (only the small CodecUtil envelope is overhead). The .vex
(graph) is much smaller and its size tracks M (16 here): roughly N × ~M × neighbour-bytes plus per-level lists. The .vem is tiny — fixed metadata.
Step 3 — Read the headers with xxd
# Every Lucene file starts with CODEC_MAGIC 0x3FD76C17, then the codec/format name.
xxd -l 48 ld3-vec-raw/_*.vec
xxd -l 48 ld3-vec-raw/_*.vex
xxd -l 48 ld3-vec-raw/_*.vem
You should see, on each:
00000000: 3fd7 6c17 .... 4c75 6365 6e65 3939 ... ?.l.....Lucene99...
^^^^^^^^^ CODEC_MAGIC ^^^^^^^^^^^^ "Lucene99..." format name
The four bytes 3F D7 6C 17 are CodecUtil.CODEC_MAGIC; the string after the
version/length is the format name written by CodecUtil.writeIndexHeader. Confirm
in source:
grep -rn "CODEC_MAGIC\|writeIndexHeader" "$LUCENE_SRC/codecs/CodecUtil.java"
grep -rn "META_CODEC_NAME\|VECTOR_DATA_CODEC_NAME\|VECTOR_INDEX_CODEC_NAME\|entryNode\|writeGraph\|writeMeta" \
"$LUCENE_SRC/codecs/lucene99/Lucene99HnswVectorsFormat.java" \
"$LUCENE_SRC/codecs/lucene99/Lucene99HnswVectorsWriter.java" 2>/dev/null | head
Step 4 — Reason about the on-disk graph layout
You can't pretty-print the .vex adjacency without the reader, but you can verify
the shape by reading the writer/reader source and the metadata:
# The metadata file records the entry node and per-level offsets:
grep -n "entryNode\|writeMeta\|numLevels\|nodesByLevel\|writeField" \
"$LUCENE_SRC/codecs/lucene99/Lucene99HnswVectorsWriter.java" | head
# The reader's neighbour iteration (this is the graph walk at query time):
grep -rn "class .*HnswGraph\b\|nextNeighbor\|seek\|class OffHeapHnswGraph" \
"$LUCENE_SRC/codecs/lucene99/" | head
The structure, from the masterclass index:
flowchart TD
Vem[".vem (metadata)"] -->|dims, similarity| Cfg["64 dims, COSINE"]
Vem -->|entry node| Entry["entryNode ordinal (top layer)"]
Vem -->|per-level offsets| Vex[".vex graph"]
Vex -->|"layer L: neighbour ord lists"| Adj["adjacency per node per level"]
Vem -->|offset into .vec| Vec[".vec raw float32, ord-contiguous"]
Vec -->|"offset = ord*dims*4"| V0["vector for ordinal 0, 1, 2 ..."]
Key facts to internalize:
.vecrandom access: vector for ordinalordlives at byteord × dims × bytesPerComponent(+ header). Float32 ⇒ 4 bytes; that contiguity is what the SIMD distance loops exploit..vexholds, per node and per level, the list of neighbour ordinals (bounded byM, or2·Mon layer 0 in some impls). Higher levels have fewer nodes (the "express lanes")..vemnames the single entry node the greedy search starts from, plus the per-level node counts and the offsets that tie.vec/.vextogether.
Step 5 — Build the same index scalar-quantized, compare sizes
Now switch the format to Lucene99HnswScalarQuantizedVectorsFormat (int8) and diff
.vec sizes. Copy Vectors.java to VectorsQuant.java, change the directory to
ld3-vec-q and the format line:
import org.apache.lucene.codecs.lucene99.Lucene99HnswScalarQuantizedVectorsFormat;
// ...
cfg.setCodec(new org.apache.lucene.codecs.lucene99.Lucene99Codec() {
@Override public KnnVectorsFormat getKnnVectorsFormatForField(String field) {
// int8 scalar-quantized HNSW: ~1/4 the .vec bytes of float32.
return new Lucene99HnswScalarQuantizedVectorsFormat(16, 100);
}
});
javac -cp "$LUCENE_CP" VectorsQuant.java
java -cp "$LUCENE_CP:." VectorsQuant
# Compare the vector data files:
echo "RAW float32:"; du -b ld3-vec-raw/_*.vec
echo "QUANT int8 :"; du -b ld3-vec-q/_*.vec
Expected: the quantized .vec is roughly ¼ the raw size (1 byte/component vs 4),
plus a small per-vector/per-segment quantile stored in metadata. For 5000 × 64:
RAW float32: ~1,280,000 bytes
QUANT int8 : ~320,000 bytes (+ quantiles) -> ~4x smaller
Note: Scalar quantization is lossy. The standard production pattern is search the quantized graph for candidates, then rescore against full-precision vectors. OpenSearch's disk-based ANN does exactly this — see quantization and disk-ANN. The
Lucene104*formats generalize the bit width (1/2/4/7/8 bits) to dial memory vs recall finer.
Step 6 — Validate with CheckIndex
cp -r ld3-vec-raw ld3-vec-copy
java -cp "$LUCENE_CP" org.apache.lucene.index.CheckIndex ld3-vec-copy -verbose 2>&1 \
| grep -iA3 "test: vectors\|knn\|vector"
The vectors test reads back every vector and walks the graph, validating the
.vec/.vex/.vem checksums — proof the files you sized are internally consistent.
Step 7 — Map to OpenSearch k-NN lucene engine
The OpenSearch field config that produces exactly these files:
PUT /products
{
"settings": { "index.knn": true },
"mappings": { "properties": { "embedding": {
"type": "knn_vector",
"dimension": 64,
"space_type": "cosinesimil",
"method": { "name": "hnsw", "engine": "lucene",
"parameters": { "m": 16, "ef_construction": 100 } }
}}}
}
| OpenSearch setting | Lucene construct / file effect |
|---|---|
engine: lucene | uses Lucene99HnswVectorsFormat → .vec/.vex/.vem (no native JNI) |
space_type: cosinesimil | VectorSimilarityFunction.COSINE (stored in .vem) |
parameters.m: 16 | Lucene M (maxConn) → .vex size |
parameters.ef_construction: 100 | Lucene beamWidth → graph quality, build cost |
| (quantization on the field) | Lucene99HnswScalarQuantizedVectorsFormat → ¼ .vec |
So a find …/<shard>/index -name '*.vec' on a real k-NN-with-engine:lucene index
shows the same three files you just produced. The native faiss engine writes its
own graph file instead — see k-NN engines.
Deliverables
-
Vectors.java— indexes float32 vectors, runs a k-NN query, printsFloatVectorValuesdims/size. -
A
find/ducapture of.vec/.vex/.vemwith the.vecsize checked againstN × dims × 4. -
An
xxdcapture of each file'sCODEC_MAGIC+ format name. -
VectorsQuant.java+ adu -bdiff showing the int8.vec≈ ¼ the float32.vec. -
A
CheckIndexvectors-test capture.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
cannot find symbol Lucene99Codec | Version mismatch — use the codec matching your jar (grep -rln "extends Codec" "$LUCENE_SRC/codecs"). |
No .vec/.vex/.vem | No KnnFloatVectorField added, or compound file on. Add the field; setUseCompoundFile(false). |
Many .vec files | More than one segment. forceMerge(1) then commit() (the example does). |
IllegalArgumentException: vector's dimension differs | All docs + the query must use the same DIMS. |
Quantized .vec not ~¼ size | You used the non-quantized format, or dims too small for the per-vector overhead to amortize — raise N/DIMS. |
xxd shows different magic | You read a .cfs/footer region — read from offset 0; ensure non-compound. |
Expected Output
Three vector files whose sizes match the byte arithmetic, a CODEC_MAGIC-confirmed
header on each, a ~4× shrink from float32→int8 quantization, and a clear mapping
from these files to an OpenSearch knn_vector field with engine: lucene.
Stretch Goals
- Vary
M. Rebuild withM = 8,16,32; chart.vexsize — it grows withM, confirming the graph (not the vectors) is whatMcontrols. - 768 dims. Bump
DIMSto 768 andNto 10k; the.vecis now ~30 MB — observe how quickly raw float32 vectors dominate disk, motivating quantization. - Read a vector by ordinal. Use
FloatVectorValuesrandom access to fetch the vector at ordinal 0 and confirm it equals what you indexed (seed-reproducible). - Real OpenSearch shard. Create the
productsindex above, index a few vectors,findthe shard, and confirm the same three extensions appear. - faiss contrast. Switch
enginetofaisson a second field and observe the different on-disk file (native graph) — see k-NN engines.
Coding Exercises
These exercises turn "size and reason about the files" into graded code. Each is a
standalone Java program or JUnit test against the Lucene jars on $LUCENE_CP — no
Gradle. Build on Vectors.java and VectorsQuant.java.
-
(warm-up) Assert random access by ordinal. Extend
Vectors.javainto a JUnit testVecByOrdTestthat, after indexing with a fixedRandom(42)seed, usesFloatVectorValuesrandom access to fetch the vector at ordinal 0 and asserts it equals the first vector you generated (regenerate from the same seed). This proves the.veclayoutoffset = ord × dims × bytesPerComponentgives O(1) lookup. -
(warm-up) Verify the
.vecbyte arithmetic in code. WriteVecSizeCheckthat reads the on-disk.veclength withFiles.size(...)and asserts it equalsN × dims × 4plus theCodecUtilheader+footer (header =indexHeaderlength + footer = 16 bytes; locate the constants withgrep -n "footerLength\|CODEC_MAGIC\|writeIndexHeader" "$LUCENE_SRC/codecs/CodecUtil.java"). Print the residual and confirm it is exactly the envelope, not data. -
(core) Measure the float32→int8 ratio as a test. Combine
VectorsandVectorsQuantinto one testQuantRatioTestthat builds both indices, reads both.vecsizes, and asserts the int8.vecis between0.2×and0.3×the float32.vec(≈¼ plus quantiles). Then assert k-NN recall@10 of the quantized index against the raw index's results is ≥ some threshold on your random data — quantifying the recall cost of the size win. (This is the rescore motivation; see quantization-and-disk-ann.md.) -
(core) Chart
.vexgrowth withM. WriteMSweepthat builds the index three times withM ∈ {8, 16, 32}(sameN,DIMS, seed), records each.vexsize, and assertsvex(8) < vex(16) < vex(32)while the.vecsize is unchanged across all three. This isolates thatMcontrols the graph file, not the vector file — the structural claim behind the lab. -
(advanced) Advanced challenge — walk the off-heap HNSW graph and verify degree bounds. Locate the graph reader:
grep -rln "OffHeapHnswGraph\|class HnswGraph\b\|nextNeighbor" "$LUCENE_SRC/codecs/lucene99/" "$LUCENE_SRC/util/hnsw/". WriteGraphWalkthat opens the index reader, obtains theHnswGraphfor your field (you may need the codec reader / a small reflection or test-scoped accessor — note the access path you used), seeks the entry node, and for every node on level 0 counts neighbours viaseek(level, node)+nextNeighbor(). Assert (a) the entry node is the one named in.vem, and (b) no level-0 node exceeds2·Mneighbours — the degree invariant HNSW maintains. Print the average degree. Deliverable: a JUnit test that fails loudly if any node violates the2·Mbound.
Issues to Practice On
The HNSW vector format lives in apache/lucene (KnnVectorsFormat,
Lucene99HnswVectorsWriter, HnswGraphBuilder); the OpenSearch lucene engine just
wires to it. Apache workflow: GitHub issues + PRs, a CHANGES.txt entry — no DCO.
For OpenSearch-side k-NN wiring bugs, the repo is opensearch-project/k-NN. Find
work with:
gh label list --repo apache/lucene | grep -iE "good first|core/|new feature" # confirm taxonomy (labels move; check the tracker)
gh issue list --repo apache/lucene --label "good first issue" --state open
gh issue list --repo apache/lucene --search "HNSW OR KnnVectors OR vector in:title,body" --state open
# OpenSearch-side (lucene engine / knn_vector mapping):
gh issue list --repo opensearch-project/k-NN --label "good first issue" --state open
gh issue list --repo opensearch-project/k-NN --search "lucene engine OR quantization in:title,body" --state open
Representative issue patterns:
- Quantization / format size or recall. "Scalar-quantized
.vecbigger than expected," "recall drop after merge." Approach: reproduce with a synthetic index, measure.vec/.vexsizes and recall@k as a test, locate the writer (rg "ScalarQuantizer\|Lucene99HnswScalarQuantizedVectorsWriter"), bisect. - Graph build / merge correctness. "Entry node wrong after merge," "neighbour list
exceeds
M." Approach: write a degree-bound assertion (exercise 5), reproduce on a force-merge, locateHnswGraphBuilder/IncrementalHnswGraphMergerviarg.
Planted-bug drill. In a copy of Vectors.java, change the query vector's
dimension from DIMS to DIMS + 1 and re-run: you should get
IllegalArgumentException: vector's dimension differs. Now harden: add a JUnit test
assertThrows(IllegalArgumentException.class, ...) that would have caught the
mismatch — the same invariant that produces the #1 OpenSearch k-NN error
("dimension mismatch"). Then plant a subtler one in GraphWalk: assert < M instead
of ≤ 2·M and watch a legitimate level-0 node fail the bound; revert and note the
real invariant.
Etiquette: claim the issue first, reproduce before coding; Apache Lucene PRs need a test +
CHANGES.txt(no DCO), OpenSearch k-NN PRs need a test + CHANGELOG entry + DCOSigned-off-by(git commit -s). See community-interaction.md.
Validation: prove you understand this
- Map
.vec/.vex/.vemto their contents and say which one grows withMand which names the entry node. - From your
du, show the.vecsize matchesN × dims × 4and explain why the layout makes random access by ordinalO(1). - Identify the
CODEC_MAGICbytes in yourxxdand the format name that follows. - Explain the float32→int8 size ratio you measured and why quantization needs a rescore pass for top-quality recall.
- Translate the OpenSearch
knn_vectorconfig (engine: lucene,m,ef_construction,space_type) into the Lucene format,M,beamWidth, andVectorSimilarityFunctionit produces. - Explain why a force-merge of a vector index is expensive (the graph is rebuilt —
see HNSW chapter) and what
.vexchurn you would observe.
When you can do all six, continue to Lab LD4: DocValues Encodings.