This is an automated email from the ASF dual-hosted git repository.
airborne12 pushed a commit to branch clucene
in repository https://gitbox.apache.org/repos/asf/doris-thirdparty.git
The following commit(s) were added to refs/heads/clucene by this push:
new 384edadeefa [fix](index) Recompute destination token counts during
compaction (#420)
384edadeefa is described below
commit 384edadeefa42c77280c197357f1fe2d03089150
Author: arthur.xqw <[email protected]>
AuthorDate: Fri Oct 23 16:18:31 2026 +0800
[fix](index) Recompute destination token counts during compaction (#420)
* [fix](index) Recompute destination token counts during compaction
### What problem does this PR solve?
Issue Number: None
Related PR: Apache Doris integration PR (to be linked)
Problem Summary:
`IndexWriter::indexCompaction` filters deleted documents through the row-ID
translation map but copies the sum of every source field's token count into the
first destination index and writes zero to the remaining destinations.
Consequently BM25 uses stale average document lengths after deletes and
incorrect per-segment statistics after a split.
For two visible documents of lengths 1 and 16 and one deleted document of
length 256, the clean index contains 17 tokens. Native index compaction retains
a token count of 273: on Doris 4.1.5 the visible documents score 0.3069872 /
0.33349273, while rebuilding the same documents scores 0.28530657 / 0.20084909
and reverses their ranking.
Accumulate exact term frequencies from retained postings into each
destination field while merging terms, then write those destination totals
alongside the remapped norms. This also repairs stale source totals when their
postings still retain frequencies. It neither changes BM25's formula nor
changes visibility/statistics before compaction.
An index with norms but without term frequencies cannot supply an exact
count: byte norms are quantized. Reject native compaction for that case before
opening output writers; the companion Doris change selects its existing
raw-column index rebuild path. Indexes without norms continue to compact
without requiring frequencies.
### Release note
Correct BM25 token statistics after inverted-index compaction removes or
redistributes documents.
### Check List (For Author)
- Test
- [x] Unit Test
- [x] Manual test (pre-fix SQL reproduction on Doris 4.1.5)
- [ ] Regression test
- Native CLucene ASAN build, using the repository's CMake/`cl_test`
entrypoints.
- `cl_test -v -p IndexCompaction IndexCompress IndexCompressV3
testBlockMaxScoreV3`: 16 tests passed.
- Rebuilt the original `08d45e541fef1e48dc4ab00d2d5fde9569b49ee3`
implementation with the new tests: 2 new tests fail (expected destination token
count 42, actual 315; missing-frequency guard absent); the 4 existing
compaction tests pass.
- Coverage includes deleted long documents, a non-exact byte-norm
length (41 tokens), two source/destination indexes, multiple fields, empty
field values, and indexes with/without norms or frequencies.
- A full Doris master binary has not been built or exercised locally;
the companion PR records its integration validation separately.
- Behavior changed:
- [x] Yes. Compacted norms carry exact destination token totals. Native
compaction rejects norms-bearing indexes without frequencies; callers must
rebuild them.
- Does this need documentation?
- [x] No. Correctness fix; no new setting or on-disk format.
### Check List (For Reviewer who merge this PR)
- [ ] Confirm the release note
- [ ] Confirm test cases
- [ ] Confirm document
- [ ] Add branch pick label
* [test](index) Cover single-destination compaction after deletes
### What problem does this PR solve?
Issue Number: None
Related PR: https://github.com/apache/doris-thirdparty/pull/420
Problem Summary:
Mirror the ordinary SQL DELETE reproduction at the CLucene boundary. Each
source contains a surviving document and a deleted 256-token document.
Verify that merging into one destination retains two documents and exactly
17 body tokens instead of the 529 source tokens, with independent title
counts.
The old implementation fails this assertion; the existing fix passes it.
### Release note
None (test coverage for the existing compaction fix).
### Check List (For Author)
- Test: 17 CLucene ASAN tests pass. Original implementation fails the three
new compaction regressions; the other 14 selected tests pass.
- Behavior changed: No (test only).
- Does this need documentation: No.
---------
Co-authored-by: xuquanwei <[email protected]>
---
src/core/CLucene/index/IndexWriter.cpp | 41 ++++++---
src/core/CLucene/index/IndexWriter.h | 5 +-
src/test/index/TestIndexCompaction.cpp | 151 ++++++++++++++++++++++++++++++++-
3 files changed, 182 insertions(+), 15 deletions(-)
diff --git a/src/core/CLucene/index/IndexWriter.cpp
b/src/core/CLucene/index/IndexWriter.cpp
index 7ad04859e61..8f9e950331a 100644
--- a/src/core/CLucene/index/IndexWriter.cpp
+++ b/src/core/CLucene/index/IndexWriter.cpp
@@ -1284,7 +1284,7 @@ void
IndexWriter::indexCompaction(std::vector<lucene::store::Directory *> &src_d
// first level vector index is src_index_id
// <std::wstring, ValueArray<uint8_t>> key is field name, value is the
norm of src_doc_id
std::vector<std::map<std::wstring, std::vector<uint8_t>>>
srcFieldNormsMapValues(numIndices);
- std::map<std::wstring, uint64_t> srcFieldTotalTermCountMap;
+ std::vector<std::map<std::wstring, uint64_t>>
destFieldTokenCounts(numDestIndexes);
try {
// check hasProx, indexVersion
@@ -1322,7 +1322,18 @@ void
IndexWriter::indexCompaction(std::vector<lucene::store::Directory *> &src_d
FieldInfo* fi = fieldInfos->fieldInfo(i);
// Is this Field indexed and field need norms ?
if (fi->isIndexed && !fi->omitNorms) {
+ // Without frequencies, byte norms cannot recover exact
lengths
+ // (e.g. 41 tokens encode as 40). The caller must rebuild
such an
+ // index from its source documents instead of publishing
stale totals.
+ if (!fi->hasProx) {
+ _CLTHROWA(CL_ERR_UnsupportedOperation,
+ "Index compaction with norms requires term
frequencies; "
+ "rebuild the index from source documents");
+ }
hasNorms = true;
+ for (auto& counts : destFieldTokenCounts) {
+ counts.emplace(fi->name, 0);
+ }
}
}
}
@@ -1349,8 +1360,6 @@ void
IndexWriter::indexCompaction(std::vector<lucene::store::Directory *> &src_d
srcFieldNormsMapValues[srcIndex][fi->name].emplace_back(
normBuffer.values[j]);
}
- srcFieldTotalTermCountMap[fi->name] +=
- reader->sumTotalTermFreq(fi->name).value_or(0);
}
}
}
@@ -1427,11 +1436,11 @@ void
IndexWriter::indexCompaction(std::vector<lucene::store::Directory *> &src_d
}
/// merge terms
- mergeTerms(hasProx, hasNorms, indexVersion, destFieldNormsMapValues);
+ mergeTerms(hasProx, hasNorms, indexVersion, destFieldNormsMapValues,
destFieldTokenCounts);
/// merge norms if have
if (hasNorms){
- mergeNorms(dest_index_docs, srcFieldTotalTermCountMap,
srcFieldNormsMapValues, normsOutputList);
+ mergeNorms(dest_index_docs, destFieldTokenCounts,
srcFieldNormsMapValues, normsOutputList);
}
/// merge null_bitmap
@@ -1665,7 +1674,8 @@ protected:
};
void IndexWriter::mergeTerms(bool hasProx, bool hasNorms, IndexVersion
indexVersion,
- const std::vector<std::map<std::wstring, std::vector<uint8_t>>>&
destFieldNormsMapValues) {
+ const std::vector<std::map<std::wstring, std::vector<uint8_t>>>&
destFieldNormsMapValues,
+ std::vector<std::map<std::wstring, uint64_t>>& destFieldTokenCounts) {
auto queue = _CLNEW SegmentMergeQueue(readers.size());
auto numSrcIndexes = readers.size();
//std::vector<TermPositions *> postingsList(numSrcIndexes);
@@ -1766,6 +1776,8 @@ void IndexWriter::mergeTerms(bool hasProx, bool hasNorms,
IndexVersion indexVers
std::vector<int32_t> dfs(numDestIndexes, 0);
std::vector<int32_t> lastDocs(numDestIndexes, 0);
+ const bool fieldHasNorms =
!fieldInfos->fieldInfo(smallestTerm->field())->omitNorms;
+ std::vector<uint64_t> termTokenCounts(fieldHasNorms ? numDestIndexes :
0, 0);
while (destPostingQueues->size()) {
if (destPostingQueues->top() != nullptr) {
auto destDoc = destPostingQueues->pop();
@@ -1773,6 +1785,12 @@ void IndexWriter::mergeTerms(bool hasProx, bool
hasNorms, IndexVersion indexVers
auto destDocId = destDoc->destDocId;
auto destFreq = destDoc->destFreq;
auto& descPositions = destDoc->destPositions;
+ // Only postings with a valid destination reach this queue.
Summing
+ // their frequencies excludes deleted documents and attributes
each
+ // token to its destination segment, including after a segment
split.
+ if (fieldHasNorms) {
+ termTokenCounts[destIdx] += destFreq;
+ }
if (infoStream != nullptr) {
for (int i = 0; i < _trans_vec.size(); ++i) {
// find pair < destIdx, destDocId > in _trans_vec[i]
to get the index of the pair
@@ -1882,6 +1900,9 @@ void IndexWriter::mergeTerms(bool hasProx, bool hasNorms,
IndexVersion indexVers
}
for (int i = 0; i < numDestIndexes; ++i) {
+ if (fieldHasNorms) {
+ destFieldTokenCounts[i].at(smallestTerm->field()) +=
termTokenCounts[i];
+ }
if (dfs[i] == 0) {
if (infoStream != nullptr) {
std::string name =
lucene_wcstoutf8string(smallestTerm->text(), smallestTerm->textLength());
@@ -1969,7 +1990,7 @@ void IndexWriter::mergeTerms(bool hasProx, bool hasNorms,
IndexVersion indexVers
}
void IndexWriter::mergeNorms(std::vector<uint32_t> dest_index_docs,
- std::map<std::wstring, uint64_t>
srcFieldTotalTermCountMap,
+ const std::vector<std::map<std::wstring,
uint64_t>>& destFieldTokenCounts,
std::vector<std::map<std::wstring,
std::vector<uint8_t>>> srcFieldNormsMapValues,
std::vector<lucene::store::IndexOutput *>
normsOutputList) {
//Func - Merges the norms for all fields
@@ -2028,11 +2049,7 @@ void IndexWriter::mergeNorms(std::vector<uint32_t>
dest_index_docs,
std::fill(destFieldNormsMap[fieldName].begin(),
destFieldNormsMap[fieldName].end(), 0);
}
- if (i == 0) {
-
normsOutputList[i]->writeLong(srcFieldTotalTermCountMap[fieldName]);
- } else {
- normsOutputList[i]->writeLong(0);
- }
+
normsOutputList[i]->writeLong(destFieldTokenCounts[i].at(fieldName));
auto& destFieldNorms = destFieldNormsMap[fieldName];
normsOutputList[i]->writeBytes(destFieldNorms.data(),
destDocCount);
}
diff --git a/src/core/CLucene/index/IndexWriter.h
b/src/core/CLucene/index/IndexWriter.h
index de1bab67bde..56a9d7157d9 100644
--- a/src/core/CLucene/index/IndexWriter.h
+++ b/src/core/CLucene/index/IndexWriter.h
@@ -329,11 +329,12 @@ public:
void writeFields(lucene::store::Directory* d, std::string segment);
// merge terms and write files
void mergeTerms(bool hasProx, bool hasNorms, IndexVersion indexVersion,
- const std::vector<std::map<std::wstring, std::vector<uint8_t>>>&
destFieldNormsMapValues);
+ const std::vector<std::map<std::wstring, std::vector<uint8_t>>>&
destFieldNormsMapValues,
+ std::vector<std::map<std::wstring, uint64_t>>&
destFieldTokenCounts);
// merge norms and write files
void mergeNorms(
std::vector<uint32_t> dest_index_docs,
- std::map<std::wstring, uint64_t> srcFieldTotalTermCountMap,
+ const std::vector<std::map<std::wstring, uint64_t>>&
destFieldTokenCounts,
std::vector<std::map<std::wstring, std::vector<uint8_t>>>
srcFieldNormsMapValues,
std::vector<lucene::store::IndexOutput*> normsOutputList);
// merge null_bitmap
diff --git a/src/test/index/TestIndexCompaction.cpp
b/src/test/index/TestIndexCompaction.cpp
index 1d49c597886..18e7ff2f4ee 100644
--- a/src/test/index/TestIndexCompaction.cpp
+++ b/src/test/index/TestIndexCompaction.cpp
@@ -21,6 +21,7 @@
#include <vector>
#include "CLucene/debug/mem.h"
#include "test.h"
+#include "store/MockRAMDirectory.h"
#include "CLucene/debug/error.h"
#include "CLucene/index/IndexWriter.h"
#include "CLucene/store/IndexInput.h"
@@ -332,6 +333,151 @@ void TestMergeNullBitmapIgnoreDoc(CuTest *tc) {
+static void writeNormsIndex(RAMDirectory& dir,
+ const std::vector<std::pair<std::string,
std::string>>& documents,
+ bool norms = true, bool frequencies = true) {
+ lucene::analysis::SimpleAnalyzer<char> analyzer;
+ analyzer.set_stopwords(nullptr);
+ lucene::util::SStringReader<char> bodyReader;
+ lucene::util::SStringReader<char> titleReader;
+ lucene::document::Document doc;
+ auto* body = _CLNEW lucene::document::Field(
+ _T("body"), lucene::document::Field::STORE_NO |
lucene::document::Field::INDEX_TOKENIZED);
+ auto* title = _CLNEW lucene::document::Field(
+ _T("title"), lucene::document::Field::STORE_NO |
lucene::document::Field::INDEX_TOKENIZED);
+ for (auto* field : {body, title}) {
+ field->setOmitNorms(!norms);
+ field->setOmitTermFreqAndPositions(!frequencies);
+ doc.add(*field);
+ }
+ IndexWriter writer(&dir, &analyzer, true);
+ writer.setMaxBufferedDocs(-1);
+ writer.setRAMBufferSizeMB(512);
+ writer.setUseCompoundFile(false);
+ for (const auto& values : documents) {
+ bodyReader.init(values.first.data(), values.first.size(), false);
+ titleReader.init(values.second.data(), values.second.size(), false);
+ body->setValue(analyzer.tokenStream(body->name(), &bodyReader), true);
+ title->setValue(analyzer.tokenStream(title->name(), &titleReader),
true);
+ writer.addDocument(&doc);
+ }
+ writer.close();
+}
+
+static std::string repeatedToken(const char* token, size_t count) {
+ std::string text;
+ for (size_t i = 0; i < count; ++i) {
+ text += token;
+ text += ' ';
+ }
+ return text;
+}
+
+void TestCompactionTokenCounts(CuTest* tc) {
+ RAMDirectory source0;
+ RAMDirectory source1;
+ writeNormsIndex(source0, {{"alpha", "one two"},
+ {repeatedToken("deleted", 257),
repeatedToken("gone", 73)},
+ {"alpha alpha " + repeatedToken("omega", 14),
""}});
+ // Length 41 is not exactly representable in a byte norm. The token total
+ // must come from the retained postings, not from decoding the norms.
+ writeNormsIndex(source1, {{"alpha " + repeatedToken("omega", 40), "one"},
+ {"", "one two three"}});
+ // Compaction closes its output directories. The test directory keeps the
+ // files available for reopening, as filesystem directories do in Doris.
+ MockRAMDirectory destination0;
+ MockRAMDirectory destination1;
+ destination0.setRandomIOExceptionRate(0.0, 0);
+ destination1.setRandomIOExceptionRate(0.0, 0);
+ RAMDirectory scratch;
+ lucene::analysis::SimpleAnalyzer<char> analyzer;
+ IndexWriter writer(&scratch, &analyzer, true);
+ std::vector<lucene::store::Directory*> sources {&source0, &source1};
+ std::vector<lucene::store::Directory*> destinations {&destination0,
&destination1};
+ std::vector<std::vector<std::pair<uint32_t, uint32_t>>> mapping {
+ {{0, 0}, {UINT32_MAX, UINT32_MAX}, {1, 0}}, {{0, 1}, {1, 1}}};
+ writer.indexCompaction(sources, destinations, mapping, {2, 2});
+ writer.close();
+ std::vector<uint64_t> bodyCounts;
+ std::vector<uint64_t> titleCounts;
+ for (auto* directory : destinations) {
+ auto* reader = IndexReader::open(directory);
+ bodyCounts.push_back(reader->sumTotalTermFreq(_T("body")).value());
+ titleCounts.push_back(reader->sumTotalTermFreq(_T("title")).value());
+ reader->close();
+ _CLDELETE(reader);
+ }
+ CuAssertIntEquals(tc, _T("first destination body tokens"), 42,
bodyCounts[0]);
+ CuAssertIntEquals(tc, _T("second destination body tokens"), 16,
bodyCounts[1]);
+ CuAssertIntEquals(tc, _T("first destination title tokens"), 3,
titleCounts[0]);
+ CuAssertIntEquals(tc, _T("second destination title tokens"), 3,
titleCounts[1]);
+}
+
+void TestCompactionDeletedDocumentsSingleDestination(CuTest* tc) {
+ // Match the ordinary DELETE regression: each source has a surviving
document
+ // and a deleted 256-token document. The destination has 17 tokens, not
529.
+ const std::string deleted = repeatedToken("alpha", 240) +
repeatedToken("omega", 16);
+ RAMDirectory source0;
+ RAMDirectory source1;
+ writeNormsIndex(source0, {{"alpha", "one"}, {deleted, "gone"}});
+ writeNormsIndex(source1,
+ {{"alpha alpha " + repeatedToken("omega", 14), "two"},
{deleted, "gone"}});
+ MockRAMDirectory destination;
+ destination.setRandomIOExceptionRate(0.0, 0);
+ RAMDirectory scratch;
+ lucene::analysis::SimpleAnalyzer<char> analyzer;
+ IndexWriter writer(&scratch, &analyzer, true);
+ std::vector<lucene::store::Directory*> sources {&source0, &source1};
+ std::vector<std::vector<std::pair<uint32_t, uint32_t>>> mapping {
+ {{0, 0}, {UINT32_MAX, UINT32_MAX}}, {{0, 1}, {UINT32_MAX,
UINT32_MAX}}};
+ writer.indexCompaction(sources, {&destination}, mapping, {2});
+ writer.close();
+ auto* reader = IndexReader::open(&destination);
+ const auto bodyTokens = reader->sumTotalTermFreq(_T("body")).value();
+ const auto titleTokens = reader->sumTotalTermFreq(_T("title")).value();
+ const auto documents = reader->maxDoc();
+ reader->close();
+ _CLDELETE(reader);
+ CuAssertIntEquals(tc, _T("surviving documents"), 2, documents);
+ CuAssertIntEquals(tc, _T("surviving body tokens"), 17, bodyTokens);
+ CuAssertIntEquals(tc, _T("surviving title tokens"), 2, titleTokens);
+}
+
+void TestCompactionWithoutFrequencies(CuTest* tc) {
+ for (bool norms : {false, true}) {
+ RAMDirectory source;
+ writeNormsIndex(source, {{"alpha alpha", "one one"}, {"deleted",
"gone"}},
+ norms, false);
+ MockRAMDirectory destination;
+ destination.setRandomIOExceptionRate(0.0, 0);
+ RAMDirectory scratch;
+ lucene::analysis::SimpleAnalyzer<char> analyzer;
+ IndexWriter writer(&scratch, &analyzer, true);
+ std::vector<lucene::store::Directory*> sources {&source};
+ std::vector<std::vector<std::pair<uint32_t, uint32_t>>> mapping {
+ {{0, 0}, {UINT32_MAX, UINT32_MAX}}};
+ bool rejected = false;
+ try {
+ writer.indexCompaction(sources, {&destination}, mapping, {1});
+ } catch (CLuceneError& error) {
+ CuAssertIntEquals(tc, _T("missing frequencies require a rebuild"),
+ CL_ERR_UnsupportedOperation, error.number());
+ rejected = true;
+ }
+ writer.close();
+ CuAssertIntEquals(tc, _T("only norms need exact frequencies"), norms,
rejected);
+ if (!norms) {
+ auto* reader = IndexReader::open(&destination);
+ const auto tokens = reader->sumTotalTermFreq(_T("body"));
+ const auto documents = reader->maxDoc();
+ reader->close();
+ _CLDELETE(reader);
+ CuAssertIntEquals(tc, _T("norms remain omitted"), false,
tokens.has_value());
+ CuAssertIntEquals(tc, _T("retained document"), 1, documents);
+ }
+ }
+}
+
CuSuite* testIndexCompaction() {
CuSuite* suite = CuSuiteNew(_T("CLucene Index Compaction Test"));
@@ -339,6 +485,9 @@ CuSuite* testIndexCompaction() {
SUITE_ADD_TEST(suite, TestMergeNullBitmapEmptySrc);
SUITE_ADD_TEST(suite, TestMergeNullBitmapEmptyIndexSrcBitmapValues);
SUITE_ADD_TEST(suite, TestMergeNullBitmapIgnoreDoc);
+ SUITE_ADD_TEST(suite, TestCompactionTokenCounts);
+ SUITE_ADD_TEST(suite, TestCompactionDeletedDocumentsSingleDestination);
+ SUITE_ADD_TEST(suite, TestCompactionWithoutFrequencies);
return suite;
-}
\ No newline at end of file
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]