Conversation
…eld document counts for BM25
### What problem does this PR solve?
Issue Number: None
Related PR: None
Problem Summary: Three independent problems of SNII indexes on mostly NULL columns and VARIANT
paths, fixed without any on-disk format change.
1. NullBitmapWriter serialized its CRoaring bitmap right after addMany, without runOptimize. NULL
rows come in long runs (whole load batches, VARIANT paths absent from most rows), yet every
container denser than 4096 values stayed an 8 KiB bitset, so each logical index on a mostly
NULL path paid about N/8 bytes per segment no matter how few rows were non-NULL.
NullBitmapWriter now run-optimizes and shrinks the bitmap once before its serialized size is
computed and written. The writer is shared by the inverted logical index (also used by direct
SNII index compaction) and the BKD blob index, so both benefit; build_memory_upper_bound also
covers the one replacement container runOptimize/shrinkToFit hold next to the one they
replace. Run containers are part of the portable CRoaring format, so every reader, including
builds that predate this change, decodes the new sections.
2. A VARIANT column copies each index definition to every materialized subcolumn, so one SNII
container holds one logical index per (definition, subcolumn), and every logical index wrote
its own null bitmap section although the definitions on one subcolumn share its suffix and its
NULL rows. The compound writer now remembers the last null bitmap it appended (the index
suffix, an XXH3-128 hash and the length of the framed bytes, and the region). When the next
logical index has the same suffix and an identical bitmap, its core metadata references that
region and nothing is appended; the hash stands in for a byte comparison because each bitmap
is still released as soon as it is written, so peak build memory does not change. Section
references are absolute container offsets and no reader treats a region as owned by one
logical index (null bitmaps are read as stateless byte ranges, the rewrite snapshot's physical
prefix ends at the largest referenced region end, compaction decodes source bitmaps into
docids), so builds without this change read the new containers. Loads, vertical compaction and
SNII index compaction all write the definitions of one subcolumn back to back, so N
definitions store one bitmap instead of N, saving (N - 1)/N of the null-bitmap bytes.
3. CollectionStatistics used the segment row count as the BM25 document count N (idf) and as the
avgdl denominator, and required all SNII fields scored in one segment to share that count.
For a field that is NULL in most rows this is wrong: avgdl comes out far too small and idf
differences are flattened. Lucene's docCount is the number of documents that have the field.
CollectionStatistics now keeps a document count per field. SNII segments add the field's
indexed_doc_count (doc_count - null_count), which every SNII writer has stored since the format
was introduced and the metadata decoder requires; the check that all scored SNII fields of a
segment have one document count is gone. CLucene segments add their segment document count to
every field, so their scores are unchanged. A field without documents yields finite statistics:
avgdl divides by at least one document, and idf uses at least the term's document frequency,
which a NULL ARRAY row that kept tokens under its NULL flag can raise above the indexed count.
Results:
- A 1.02M-row VARIANT table with 1000 paths at 0.8% density and 11 index definitions, one
compacted segment, end-to-end A/B on the same data: null bitmaps 180.4 MB -> 45.2 MB.
- Shared null bitmap, 1M-row container with 625,002 pseudo-random NULL rows: 262,978 -> 131,754
bytes with two definitions on one subcolumn, 920,313 -> 132,969 bytes with seven.
- BM25, VARIANT path present in 2 of 3 rows, query "alpha": scores 0.311 and 0.5235 before,
0.1514 and 0.2292 after, i.e. idf = ln(1 + 0.5 / 2.5) with N = 2 and avgdl = 4 / 2. Columns
without NULL rows score as before.
### Release note
BM25 scores (score()) of SNII inverted indexes on columns or VARIANT paths with NULL rows change:
avgdl and idf now use the field's non-NULL document count instead of the segment row count, as
Lucene does. SNII null bitmaps take less space; the index format does not change.
### Check List (For Author)
- Test: Unit Test, Regression test
- SniiNullBitmap: a mostly NULL 1M-row bitmap is >100x smaller than the unoptimized
serialization and round-trips; sections serialized without run optimization (as earlier
builds wrote them) are still read; sizes follow later adds.
- SniiSharedNullBitmap: definitions on one suffix reference one region and each reads its own
NULL rows back; different NULL rows or document counts on one suffix, and identical NULL
rows on different suffixes, keep separate regions; streamed (compaction) sessions share the
region and still release their bitmap bytes; a rewrite that drops the index which wrote the
region keeps the index that references it readable; saving measured for N = 2 and 7.
- CollectionStatisticsTest: per-field counts from an SNII segment with NULL rows, fields of
one segment with different counts, a field without documents, document frequency above the
indexed count; query_v2 scoring tests set per-field counts.
- Regression: test_storage_format_snii_norms expectations regenerated (the VARIANT path scores
change as computed above); with this commit alone that suite, test_storage_format_snii,
_utf8_wildcard, _custom_analyzer, test_variant_search_subcolumn_snii,
regression_test_variant_var_index_snii, regression_test_variant_snii_compaction,
test_variant_v2_snii_index and test_timestamp_ns_index pass.
- Behavior changed: Yes. BM25 scores of SNII indexes on columns with NULL rows change as described
in the release note; CLucene scores and SNII scores on columns without NULL rows do not change.
Null bitmap sections are smaller and may be shared between index definitions of one VARIANT
subcolumn; older BEs read them.
- Does this need documentation: No
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ly NULL indexes
### What problem does this PR solve?
Issue Number: None
Related PR: None
Problem Summary: An analyzed SNII index with positions stores a one-byte BM25 norm for every row
of the segment, NULL rows included, so each logical index costs one byte per segment row no matter
how few rows have a value. VARIANT copies the index to every materialized path and most paths are
sparse, so the norms dominate the index size. Lucene has stored norms sparsely since 7.0.
A document now carries a norm when it is non-NULL, or when it is NULL but still produced tokens (a
nullable ARRAY row can keep its nested payload under the NULL flag; its tokens are in the postings
and scoring reads its norm). The writer picks the layout when a logical index is finished:
- no document without a norm: the existing dense kNormsPod (14) section, byte-identical to what
earlier writers produced;
- otherwise the smaller of dense and a new sparse section kNormsSparse (15):
[varint doc_count][varint present_count][u8 bytes_per_norm 0|1][varint block_count]
[12-byte block headers][varint data_len][block payloads][constant norm | present_count bytes].
Present docids are split into 65536-docid blocks (ALL / ARRAY / BITSET with a rank table / RUNS
with rank prefixes), so a lookup is a binary search over the block headers plus O(1) or a binary
search inside the block, and the reader needs no memory beyond the section bytes. The layout is
documented in norms_pod.h.
Readers that predate this change reject a sparse section when they open the logical index (its
length differs from the dense length they require and its type is not 14), so they never misread
norms, but queries on such a segment fall back to evaluation without the index and score() fails.
A new mutable BE config, enable_snii_sparse_norms (default true), therefore controls the writer:
when it is off, every index written from then on uses the legacy dense layout. The decision is made
in one place, LogicalIndexWriter::finalize_build, which every path goes through (load, compaction
output including direct SNII index compaction, schema change and BUILD INDEX). Readers accept both
layouts whatever the config says.
The column writer no longer keeps a byte per NULL row: it keeps raw token counts of the rows that
carry a norm (add_array_nulls drops NULL rows without tokens once their NULL flag is known) and
encodes them at finish. Direct SNII index compaction still rebuilds norms from merged postings; it
now keeps the documents that carry a norm and hands the NULL documents that still have postings to
the session, so the destination gets the same layout as a fresh build under the same config,
whatever the source layouts are. On the read side a norms region may not be longer than a dense
section (the cache charge stays bounded by doc_count), sparse payloads are fully validated when
loaded, a checked lookup of a docid without a norm returns a corruption error, and
SniiStatsProvider rejects a norms section that covers fewer documents than the indexed count.
Results, end-to-end A/B on the same data, one compacted segment each, before = master and after =
this commit together with the previous one:
- 1.02M-row VARIANT table, 1000 paths at 0.8% density, 11 index definitions: index 601.2 MB ->
89.7 MB; norms 382.5 MB -> 6.2 MB (this commit), null bitmaps 180.4 MB -> 45.2 MB (previous
commit).
- 677k-row GitHub-events table: index 913.1 MB -> 603.9 MB; norms 324.3 MB -> 39.8 MB.
BM25 scores are bit-identical between the two layouts.
### Release note
New mutable BE config enable_snii_sparse_norms (default true). SNII inverted indexes store BM25
norms only for the rows that carry one when that is smaller, which shrinks indexes on mostly NULL
columns and VARIANT paths (for example norms 382.5 MB -> 6.2 MB on a 1M-row VARIANT table with
1000 sparse paths). BEs without this change cannot use segments written with sparse norms (their
index is skipped and score() fails); set enable_snii_sparse_norms = false while such BEs may read
newly written segments, for example during a rolling upgrade or before a downgrade.
### Check List (For Author)
- Test: Unit Test, Regression test
- SniiNormsSection: legacy dense bytes unchanged (compared with a hand-assembled section),
forced dense with NULL rows equals the earlier bytes, sparse round trips (norm bytes /
constant norm), every block kind, layout choice, invalid input, randomized lookups against a
dense oracle, corrupt sparse payloads, sparse sections fail the checks older readers apply.
- SniiSparseNormsTest: the production column writer writes the same NULL-heavy scalar and
nullable ARRAY input (NULL rows with tokens) with enable_snii_sparse_norms on and off: sparse
section vs a dense section byte-identical to the legacy one, identical norms and bit-identical
BM25 scores; a segment without NULL rows is byte-identical in both modes; the config is on
by default.
- SniiIndexCompactionTest.NormsMergeOfMixedLayoutsMatchesRebuild: direct compaction over
sparse, dense-with-NULLs and NULL-free sources, with the config on and off, equals a fresh
build under the same config and scores like a dense build; existing compaction, streamed
session, compound writer, writer and collection statistics tests updated to the new norms
input (SniiWriterNorms.NullRowsKeepNormsOnlyWithTokens added).
- Regression: new test_storage_format_snii_sparse_norms loads the same batches with the config
on, off, and toggled between batches, then runs a full compaction (config off for the dense
table, on for the others) and checks that MATCH / MATCH_PHRASE / IS NULL / score() results of
all three tables are identical before and after, and that the sparse layout is smaller;
test_storage_format_snii, _norms, _utf8_wildcard, _custom_analyzer,
test_variant_search_subcolumn_snii, regression_test_variant_var_index_snii,
regression_test_variant_snii_compaction, test_variant_v2_snii_index and
test_timestamp_ns_index pass unchanged.
- Behavior changed: Yes. New BE config enable_snii_sparse_norms. With it on (the default), new
SNII segments of indexes with NULL rows may carry the new kNormsSparse section, which BEs
without this change cannot read; with it off the written bytes are the same as before. Query
results and BM25 scores do not depend on the layout.
- Does this need documentation: Yes (an entry for enable_snii_sparse_norms in the BE
configuration reference; doc PR not opened yet)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
|
Thank you for your contribution to Apache Doris. Please clearly describe your PR:
|
Member
Author
|
run buildall |
Contributor
TPC-H: Total hot run time: 27678 ms |
Contributor
TPC-DS: Total hot run time: 153258 ms |
Contributor
ClickBench: Total hot run time: 24.16 s |
Contributor
BE Regression && UT Coverage ReportIncrement line coverage Increment coverage report
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What problem does this PR solve?
Issue Number: None
Related PR: #68039, #53980
Problem Summary:
A VARIANT column copies each inverted index definition to every materialized path, so one SNII segment holds one logical index per (path, definition). Most paths are sparse, and two SNII structures cost one entry per segment row regardless of how many rows hold a value:
NullBitmapWriterserialized its CRoaring bitmap withoutrunOptimize, so every 65536-row container of a mostly NULL path stayed an 8 KiB bitset (about N/8 bytes per index per segment). The definitions on one path also stored identical copies.On top of that, BM25 used the segment row count as the document count, so on sparse paths avgdl came out far too small and idf differences were flattened.
This PR has two commits.
Commit 1: no on-disk format change
NullBitmapWriterrun-optimizes and shrinks the bitmap before sizing and writing it. The inverted index, direct SNII index compaction and the BKD blob'sbkd_nullsall go through this writer. Run containers are part of the portable CRoaring format, so older builds read them.CollectionStatisticskeeps a document count per field. SNII uses the field'sindexed_doc_count(non-NULL rows) for both avgdl and idf N, which is what Lucene'sdocCountmeans. CLucene numbers are unchanged.Commit 2: sparse BM25 norms behind a switch
kNormsSparse(15). It stores norms only for the documents that carry one: present docids in 65536-docid blocks (ALL / ARRAY / BITSET with a rank table / RUNS), plus one byte per present document, or a single byte when all norms are equal.kNormsPod(14) section, byte-identical to before, when no document lacks a norm or when dense is not larger.enable_snii_sparse_norms, defaulttrue. The only decision point isLogicalIndexWriter::finalize_build, which load, compaction, schema change and BUILD INDEX all go through. Readers accept both layouts regardless of the config.End-to-end results. Each row compares master with this PR on the same data, measured as the bytes of one compacted segment file on an ASAN build.
parser=none+parser=english(phrase)enable_snii_sparse_norms = false, 10 load rowsetsBM25 scores are bit-identical between the dense and sparse layouts: after an in-place upgrade, on tables mixing master-written and new segments, and after full compaction into the new layout.
score()latency did not regress.Compatibility (tested by swapping BE binaries over the same storage)
score()works; commit 1 needs no switchscore()fails with E-6012. A full compaction by the older binary rewrites the index densely and restoresscore(). Turn the config off while older BEs may read newly written segments.Release note
score()) of SNII inverted indexes on columns or VARIANT paths with NULL rows change: avgdl and idf now use the field's non-NULL document count instead of the segment row count, as Lucene does. Filtering results do not change.enable_snii_sparse_norms(defaulttrue): SNII stores BM25 norms only for rows that carry one when that is smaller. BEs without this change cannot use indexes written this way (filters fall back to row-by-row evaluation andscore()fails); set it tofalsewhile such BEs may read newly written segments, e.g. during a rolling upgrade or before a downgrade.Check List (For Author)
Test
Unit tests (both commits built and tested on their own):
SniiBatchRangeFetcher.*/SniiLocalFile.*tests (their fixed/tmpfiles belong to another OS user on the test host), andSniiGoldenCorpus.WriteOrVerifyis skipped withoutSNII_GOLDEN_DIR.SniiNormsSection,SniiSparseNormsTest,SniiSharedNullBitmap, plus extendedSniiNullBitmap,SniiIndexCompactionTest(mixed dense/sparse sources, config on and off) andCollectionStatisticsTest.Regression tests:
test_storage_format_snii_sparse_norms: config on / off / toggled per batch, full compaction, identical results and scores across layouts.test_storage_format_snii_norms: expectations regenerated for the new BM25 statistics and hand-checked.test_storage_format_snii,_utf8_wildcard,_custom_analyzer,test_variant_search_subcolumn_snii,regression_test_variant_var_index_snii,regression_test_variant_snii_compaction,test_variant_v2_snii_index,test_timestamp_ns_index.Manual test: the end-to-end A/B, upgrade and rollback runs summarized above, on two clusters built from master and from this PR.
Behavior changed:
score()values change for SNII indexes on fields with NULL rows (see release note).enable_snii_sparse_normson, new SNII segments may carry the newkNormsSparsesection, which BEs without this change cannot use.Does this need documentation?
enable_snii_sparse_normsin the BE configuration reference, and a note on the BM25 statistics in the scoring docs.Check List (For Reviewer who merge this PR)
🤖 Generated with Claude Code