Skip to content

perf(lexical/index): fold .lens into a 1-byte quantised norms column and drop the derivable .fstats - #1123

Merged
mosuka merged 5 commits into
mainfrom
perf/lexical-field-norms
Sep 15, 2026
Merged

mosuka merged 5 commits into
mainfrom
perf/lexical-field-norms

Conversation

@mosuka

@mosuka mosuka commented Sep 15, 2026

Copy link
Copy Markdown
Owner

Summary

Folds .lens (per-doc, per-field string + u32) and the derivable .fstats into a single
columnar, 1-byte-quantised .norms segment part, matching the Lucene NormsFormat / Tantivy
fieldnorm precedent. Closes #555.

What changed

  1. Quantisation table (laurus/src/lexical/index/structures/norms.rs): a Tantivy-style
    table (exact identity below 40, log-spaced in octaves of 8 steps above that), generated from
    its recurrence by a const fn rather than a hand-copied literal array.
  2. New .norms segment part: magic + version + codec-id header, a doc-id slot map
    (contiguous fast path, sparse delta-list fallback for gaps), a field directory carrying the
    exact pre-quantisation sum_length/present_count/min_length/max_length (so
    avg_length stays bit-identical to before), and a presence bitmap distinguishing "field
    absent" from "analyzed to zero tokens" — BM25Scorer::score's unwrap_or(avg) fallback
    depends on that distinction.
  3. BM25 score-bound correctness: write_segment_files now builds a single NormsBuilder
    shared by both write_inverted_index (the #403 Block-Max-WAND score-bound precomputation)
    and write_norms, instead of two independent traversals of buffered_docs that could
    disagree on a field's length once quantisation entered the picture.
  4. Reader switch: SegmentReader's field_lengths/field_stats caches are unified behind
    SegmentNorms (V1 for .norms, Legacy for pre-existing .lens/.fstats segments, read
    exactly/unquantised since their .dict was computed against exact lengths). Every caller
    (searcher.rs, bmw.rs, per_segment_view.rs, merge_engine.rs, query::term,
    query::phrase) is unchanged — field_length/field_stats keep their exact signatures.
  5. Drop the legacy files: write_field_lengths/write_field_stats/calculate_field_stats
    are removed. Existing pre-migration segments keep reading correctly through
    SegmentNorms::Legacy until their next merge naturally rewrites them in the new format.

Landed as 5 commits, each independently test/fmt/clippy-clean:
a8d75a5d (quantisation table) → 4ff1ae4c (.norms part, additive) →
31f27027 (score-bound anchoring) → d1f9c78e (reader switch) →
b09da6ff (drop .lens/.fstats, docs).

Design decisions / deviations from the original design sketch

  • No per-field lazy materialization (unlike .dv): a .norms file is already roughly an
    order of magnitude smaller than the .lens/.fstats pair it replaces, so the whole segment
    is read eagerly at NormsReader::load — the added complexity of a seek-based per-field loader
    wasn't worth it here.
  • SegmentNorms has two variants, not three: a Missing variant was dropped since it
    behaves identically to Legacy with empty maps (both return None from every query).

Tests

  • 24 .norms format round-trip tests (dense/sparse doc ids, absent fields, zero-length fields,
    exact header stats even when the per-doc column is quantised, corrupt-header rejection).
  • 10 quantisation-table property tests (monotonicity, exact window, round-trip idempotence,
    bounded relative error, u32::MAX saturation).
  • RED-GREEN proof of the correctness-critical invariant: temporarily reverted
    NormsBuilder::decoded_length to return the exact (unquantised) length, and confirmed two
    tests fail as a result before restoring it:
    • score_bound_uses_decoded_length_and_never_tightens_relative_to_exact (writer-side)
    • block_max_bound_is_never_violated_after_norms_quantisation (reader-side, end-to-end: a
      real flush + a real TermInfo/BM25Scorer built from it, checked against the actual score
      for a document whose field is well above the exact-quantisation window). Each fixture
      document carries its own unique term so its bound is derived solely from its own length —
      a shared term would let a short, unquantised document's looser bound mask a long document's
      quantisation error.
  • Legacy-segment and format-precedence white-box tests (.lens/.fstats-only segments still
    read exactly; a .norms file wins over stale legacy files on the same segment).
  • Public-API integration tests (laurus/tests/lexical_norms_test.rs): a merge across segments
    with both long (quantised) and short (exact) fields preserves hit counts; a .norms-based
    merge of exact fields reproduces exactly what a fresh single-segment build of the same
    documents would score.

Scope

Three items were deliberately left out and filed as follow-ups rather than folded into this
format-migration PR:

Compatibility

New binary opening an old (.lens/.fstats) index: reads correctly via the legacy path, no
reindex needed. Old binary opening a new (.norms-only) index: finds no .lens/.fstats,
falls back to a flat avg_field_length: 10.0 and no per-doc length — scores degrade to a flat
ranking rather than erroring. Same one-way format contract as #1024.

Test plan

  • cargo fmt --all -- --check
  • cargo clippy --workspace --all-targets --features embeddings-all -- -D warnings
  • cargo test -p laurus --lib (1463 passed)
  • cargo test -p laurus (integration tests + doctests, full pass)
  • cargo test --workspace --features embeddings-all (full workspace pass)

Introduces a byte-quantised length table (exact below 40, log-spaced
above, generated from its recurrence rather than hand-copied) as the
foundation for the .norms segment part (#555). Standalone: not yet
wired into any reader or writer.
Adds NormsBuilder/NormsReader for a columnar, 1-byte-quantised
field-length format that will replace .lens/.fstats (#555). The
writer now emits .norms alongside the existing files (additive
only, ~1500 files unaffected) so this lands with zero read-path
risk; the reader does not consult .norms until a later phase.

Deviates from the original design sketch in one respect: fields
are loaded eagerly rather than lazily per-field (unlike .dv). A
.norms file is already an order of magnitude smaller than the
.lens/.fstats pair it replaces, so the lazy-materialization
complexity wasn't worth it here.
write_segment_files now builds one NormsBuilder and feeds both
write_inverted_index's score-bound precomputation and write_norms
(#555 Phase 3), instead of two independent traversals of
buffered_docs that could disagree on a field's length. The
Block-Max-WAND bound is now computed from the decoded (quantised,
then decoded back) length, matching what the reader will
substitute in once it starts consulting .norms -- a bound anchored
to the exact length would understate the tf component the reader
later scores against, letting real matches get pruned.

The bound only loosens in the interim (search still reads exact
lengths from .lens until Phase 4), never tightens, since the
decoded length never exceeds the exact one.

Also folds compute_field_avg_lengths into NormsBuilder's
avg_length_f32, closing a latent f32/f64 rounding mismatch between
it and the old .fstats path.
SegmentReader now unifies its field-length/statistics cache behind
a single SegmentNorms enum: the .norms columnar format when present,
falling back to the pre-#555 .lens/.fstats pair (read exactly,
never quantised -- their .dict was computed against exact lengths)
for older segments (#555 Phase 4). Legacy segments are naturally
rewritten in the new format on their next merge.

field_length/field_stats keep their exact signatures, so every
caller (searcher, bmw, per_segment_view, merge_engine,
query::term/phrase) is unchanged.

Verified end-to-end with a RED-GREEN proof: temporarily
un-quantising NormsBuilder::decoded_length reproduces a real
Block-Max-WAND soundness violation (a document's actual BM25 score
exceeding its precomputed upper bound) once the reader substitutes
the quantised length back in, confirming the invariant the previous
two commits set up. Also adds legacy-segment and format-precedence
coverage, and cross-checks that a .norms-based merge reproduces
what a fresh single-segment build would score for unquantised
fields.
.norms has fully replaced them: write_field_lengths,
write_field_stats, and calculate_field_stats are removed from
InvertedIndexWriter (#555 Phase 5). Existing pre-#555 segments keep
reading correctly through SegmentNorms::Legacy until their next
merge rewrites them.

Updates the compound-segment loose-file check and a dictionary
lookup-miss test to use .norms instead of the now-unwritten
.lens/.fstats, refreshes the segment-file docs (English and
Japanese) to describe .norms, and clarifies the merge engine's
comment on length preservation to account for the (idempotent)
norms quantisation.
@mosuka
mosuka merged commit 44cbd9a into main Sep 15, 2026
24 checks passed
@mosuka
mosuka deleted the perf/lexical-field-norms branch September 15, 2026 04:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf(lexical/index): fold .lens into a 1-byte quantised norms column and drop the derivable .fstats

1 participant