Topic 23 notes — full-text search & inverted indexes
Baseline (provided code, Apple M3 Pro, measured 2026-07-10)
Corpus: 100K docs, vocab 50K zipf θ=1.0, ~10M tokens, 7.87M postings. Gen 236 ms, index build 335 ms (single thread, HashMap tf-counting). df(t0)=99888 (in 99.9% of docs — “the”), df(t100)=8259, df(t10000)=83.
BM25 top-10, exhaustive TAAT oracle
| query | ms | postings walked | top1 score |
|---|---|---|---|
| common∧common [t0 t1 t5] | 8.75 | 272,310 | 0.612 |
| mid∧mid [t100 t1000] | 0.507 | 9,098 | 9.142 |
| common∧rare [t0 t12000] | 6.34 | 99,964 | 8.975 |
| rare∧rare [t9000 t15000] | 0.008 | 159 | 9.208 |
The common∧rare row is the WAND poster child: 99,964 postings walked but the rare term (df=83, idf≈9.0) contributes ~93% of the top-1 score — nearly all of t0’s 99,888 postings are provably hopeless once the heap holds 10 docs that contain t12000. ~32 ns/posting for the oracle (hash accumulate dominates — topic 22’s Q1 story again).
Posting-list AND/OR, sorted-vec two-pointer
| pair | AND µs | OR µs | result size |
|---|---|---|---|
| t0∧t1 (99888 ∩ 97580) | 97 | 116 | 97,474 |
| t0∧t5000 (99888 ∩ 172) | 52 | 81 | 172 |
Dense∧sparse costs HALF of dense∧dense despite a 567× smaller output — two-pointer is O(|dense|); the walk of t0 is the price. That asymmetry is the roaring (probe 172 u16s into bitmaps) and galloping motivation.
Predictions (fill BEFORE implementing the stubs)
| question | prediction | actual |
|---|---|---|
| wand docs_scored on common∧rare [t0 t12000] (oracle walks 99,964) | ||
| wand on common∧common [t0 t1 t5] — does block-max help when all idfs are low? | ||
| wand speedup over oracle, common∧rare, wall clock ×? | ||
| roaring t0∧t1 AND (both ~bitmap containers) vs 97 µs vec ×? | ||
| roaring t0∧t5000 AND vs 52 µs vec ×? | ||
roaring t0 memory vs 400 KB Vec<u32> |
Implementation log
- wand.rs
wand_topk— 3 oracle-match tests + work bound green - postings.rs
Roaring— sparse/dense/mixed oracle tests green - prediction table reconciled
- stretch: quantize block max_score to u8 (Lucene-style), verify top-k unchanged (bounds may only round UP)
- stretch: galloping vec_and, three-way race vec/gallop/roaring
- stretch: RRF fusion demo — BM25 top-k + a fake vector top-k,
1/(60+rank)sum, the M23 hybrid in 20 lines
Surprises / dead ends:
- df(t0) = 99,888 of 100K docs: zipf θ=1.0 over a 50K vocab puts rank-0 in essentially every 100-token doc. Realistic (“the”) but it means common-term posting lists here are ~dense bitsets — good for the roaring dense lane, remember it when reading AND numbers.
- catch_unwind lanes again saved the bench binary: stub panics print
[stub — implement …]and the provided lanes still report.
Questions from the reading guides
Zobel & Moffat (reading-zobel-moffat.md)
- Expected gap at df=n/2; why 128-block bitpack beats varint there:
- Capped accumulators vs WAND — which is exact, what does the other buy:
- Runs/merges ↔ memtable/flush/compaction; tiered-vs-leveled for text:
- When M23 needs positions vs cheaper substitutes:
- Which cost models survive the BM25+vector hybrid:
BM25 (reading-bm25.md)
- tf for 90% of the K1+1 ceiling; keyword-stuffing implication:
- idf smoothing at df=0 and df=N:
- b=0.75→0 on uniform-length corpus vs tweets+books:
- 1-byte fieldnorm worst-case score error; why ranking survives:
- Where M23 gets relevance feedback for full RSJ weights:
Block-max WAND (reading-blockmax-wand.md)
- θ after heap fills on [t0 t12000]; can t0 alone cross it:
- Why block-max helps most on common terms (block-max variance):
- Metadata overhead/posting at 128; u8 quantization direction rule:
- Hybrid with unbounded vector scores — WAND candidates + RRF vs fused:
- Deleted docs holding block maxima — still exact? merge-time fix:
Roaring (reading-roaring.md)
- Derive 4096 crossover; when run containers win; doc-id locality:
- Predicted bitmap∧bitmap cost for t0∧t1 vs 97 µs measured vec:
- Galloping vs container probe — where roaring wins on memory traffic:
- Roaring containers ↔ GraphBLAS sparse/bitmap format lattice:
- RediSearch→GraphBLAS conversion cost FalkorDB pays; native saving:
tantivy (reading-tantivy.md)
- FST vs hash dict — three query types, the sorted-insert cost:
- df-in-dictionary makes which WAND input free:
- Tail <128 postings — vint fallback vs RediSearch always-varint:
- Tiered segments fine for text, bad for LSM point reads — why:
- Quickwit on S3: which files fetched, what order:
RediSearch (reading-redisearch.md)
- Effective block-size policy; why variable blocks resist block-max:
- gc_marker/unique_id ↔ delta-matrix wait/version:
- Codec ladder ↔ Lucene positions/doc-values taxonomy:
- Varint vs bitpack bytes/posting at delta≈1; where varint wins:
- What to lift verbatim into falkordb-rs-next-gen; what the graph changes:
Cross-topic threads
- Segments + merge policies = topic 4’s LSM; tiered-not-leveled works because text queries fan out to all segments anyway (no key-range pruning). Deletes-as-bitmap = tombstones; RediSearch GC = in-place compaction.
- BM25 scoring loop = topic 22’s YCSB lesson: the accumulator hash map dominates (32 ns/posting TAAT) — WAND is to search what the flat-array group-by was to Q1: remove the hash from the hot loop.
- Block-max metadata = topic 3’s B-tree fence keys but for SCORES; skip-without-decode = topic 12’s zone maps, exactly (min/max per block, prune before touching data).
- Varint-vs-bitpack = topic 17: byte-at-a-time branchy decode caps throughput; 128-wide unpack is the SIMD lane.
- FST term dictionary = topic 2/3’s ordered-dictionary trade: hash gives O(1) lookup, FST gives prefix/range/regex + compression — same reason B-trees beat hashes for range scans.
- Roaring containers = topic 20’s GraphBLAS sparse↔bitmap format switch at 64K-chunk granularity; M23’s hit-set → masked mxv makes the connection literal.
- Hybrid search RRF = topic 14’s HNSW top-k fused with BM25 top-k — rank-based fusion sidesteps incomparable score scales.
M23 log (capstone)
- analyzer + per-property inverted index over node/edge string props (codec: doc_ids_only for filters, freqs for ranked)
- BM25 top-k procedure with block-max WAND; node ids = doc ids
- hit-set as roaring bitmap → mask input to M20 matrix ops
- RRF hybrid with M14’s HNSW lane
- decide mutable-chained-blocks (RediSearch) vs immutable-segment (tantivy) — leaning segments + merge, matching the LSM the capstone already has
Done when
- Both stubs green with lanes filled; prediction table reconciled; guide questions answered; M23 design decision (segments vs mutable blocks) argued in writing.