Skip to content

feat: [#4687] native BM25 full-text scoring (field boosts, caret, EXPLAIN/PROFILE) - #4695

Merged
lvca merged 47 commits into
mainfrom
feat/bm25-fulltext-scoring
Jun 24, 2026
Merged

feat: [#4687] native BM25 full-text scoring (field boosts, caret, EXPLAIN/PROFILE)#4695
lvca merged 47 commits into
mainfrom
feat/bm25-fulltext-scoring

Conversation

@lvca

@lvca lvca commented Jun 22, 2026

Copy link
Copy Markdown
Member

Closes #4687.

Summary

Adds native Okapi BM25 ranking to FULL_TEXT indexes. BM25 (TF/IDF + document-length normalization) is now the default similarity for newly created full-text indexes; existing indexes keep the legacy term-coordination scoring (CLASSIC), which stays available via METADATA {"similarity":"CLASSIC"}. Zero behavior change on upgrade.

What's included

  • BM25 scoring with configurable k1/b (defaults 1.2 / 0.75, matching Elasticsearch).
  • Field boosts two ways, composable (effective = caret × field_boost):
    • configured per-field: METADATA {"title_boost": 3.0}
    • query-time caret (Lucene/ES idiom): title:java^3, usable inside AND/OR/NOT and (group)^n / "phrase"^n.
  • $score exposes the float BM25 relevance on every matching row (no extra call needed):
    SELECT title, $score FROM Article
    WHERE SEARCH_INDEX('Article[content]', 'java database') = true ORDER BY $score DESC;
  • EXPLAIN / PROFILE annotate the FETCH FROM INDEXED FUNCTION step with the BM25 similarity, k1/b, corpus stats (totalDocs, avgDocLength) and each query term's df/idf/boost (query-level "why these scores").
  • Persistence fix: analyzer config + BM25 settings + corpus counters now survive a restart (previously toJSON dropped the metadata and silently reverted custom analyzers to StandardAnalyzer).

Storage design

Per-posting term frequency + document length are carried inline through FullTextPostingRID extends DatabaseRID, so they ride the entire existing RID-typed pipeline (transaction staging, commit replay, compaction, cursors) with no signature changes. The value (de)serialization is gated by a storeTermFrequency flag derived from the persisted similarity, so every non-full-text LSM index keeps the byte-identical RID-only format. Existing full-text indexes open and score as CLASSIC; getting BM25 on old data requires a rebuild.

Pre-existing bug fixed (also affected CLASSIC)

Full-text index compaction dropped postings when a single token's value list spanned multiple compacted pages. The compacted root is a positional sparse index that can't index one leaf page under two keys, so a key's values left on a shared continuation page became unreachable on read. Overflowing keys now start on a fresh page they fully own. This was independent of BM25 (reproduced identically with CLASSIC).

Testing

  • New: BM25ScorerTest (exact math), FullTextBM25Test (IDF ranking, length norm, field + caret boosts in AND/OR/NOT, CLASSIC fallback, restart persistence, EXPLAIN metadata), FullTextBM25CompactionTest (postings + scores survive compaction at tiny page sizes).
  • Regression-safe: full com.arcadedb.index.** + com.arcadedb.graph.** (869) and full-text + all SQL function/method + select/explain suites (1339) pass; existing coordination-scoring tests pinned to CLASSIC.

…LAIN/PROFILE)

Add Okapi BM25 ranking to FULL_TEXT indexes. BM25 (TF/IDF + document-length
normalization) becomes the default similarity for newly created full-text
indexes; existing indexes keep the legacy term-coordination (CLASSIC) scoring,
which stays available via METADATA {"similarity":"CLASSIC"}.

- Per-posting term frequency + document length are stored inline through a RID
  subclass (FullTextPostingRID), gated by a storeTermFrequency flag so every
  non-full-text LSM index keeps the byte-identical RID-only format.
- Configurable k1/b, per-field boosts (metadata) and Lucene-style caret boosts
  (e.g. title:java^3), combinable inside AND/OR/NOT and grouped queries. The
  effective weight is caret * field_boost.
- $score exposes the float BM25 relevance on every matching row. EXPLAIN/PROFILE
  annotate the full-text fetch step with the similarity, k1/b, corpus stats
  (N, avgdl) and each query term's df/idf/boost.
- Corpus counters (N, sum of document lengths) and analyzer configuration now
  persist across restart; previously toJSON dropped the metadata and silently
  reverted custom analyzers to StandardAnalyzer.

Fix (pre-existing, also affected CLASSIC): full-text index compaction dropped
postings when a single token's value list spanned multiple compacted pages. The
compacted root is a positional sparse index that cannot index one leaf page
under two keys, so a key's values left on a shared continuation page became
unreachable. Overflowing keys now start on a fresh page they fully own.
@mergify

mergify Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

@codacy-production

codacy-production Bot commented Jun 22, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 9 complexity

Metric Results
Complexity 9

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements native Okapi BM25 full-text scoring, allowing newly created full-text indexes to rank results using term frequency, inverse document frequency, and document-length normalization. Key changes include the addition of a pure BM25Scorer utility, the persistence of per-posting statistics via FullTextPostingRID, incremental maintenance of corpus statistics, and support for query-time caret boosts and per-field boosts. Feedback on the changes highlights critical resource management and concurrency issues: IndexCursor and db.iterateType() iterators should be closed to prevent resource leaks, ensureCounters() requires synchronization to avoid concurrent table scans on startup, and collectPhraseMatches should correctly apply field-specific boosts instead of hardcoding a default boost.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +269 to +287
final IndexCursor postings = underlyingIndex.get(new String[] { storedKey });

// Collect the postings first so the document frequency (and therefore the IDF) is known before scoring.
final List<FullTextPostingRID> termPostings = new ArrayList<>();
while (postings.hasNext()) {
final Identifiable id = postings.next();
if (id instanceof FullTextPostingRID s)
termPostings.add(s);
}

final long df = termPostings.size();
if (df == 0)
continue;

final double idf = BM25Scorer.idf(totalDocs, df);
for (final FullTextPostingRID s : termPostings) {
final double contribution = BM25Scorer.termScore(idf, s.tf, s.docLength, avgdl, k1, b) * boost;
scoreMap.merge(s.getIdentity(), (float) contribution, Float::sum);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The IndexCursor returned by underlyingIndex.get() should be closed to release page pins and locks in the storage engine. Failing to close it can lead to resource leaks and page cache exhaustion.

        final IndexCursor postings = underlyingIndex.get(new String[] { storedKey });
        try {
          // Collect the postings first so the document frequency (and therefore the IDF) is known before scoring.
          final List<FullTextPostingRID> termPostings = new ArrayList<>();
          while (postings.hasNext()) {
            final Identifiable id = postings.next();
            if (id instanceof FullTextPostingRID s)
              termPostings.add(s);
          }

          final long df = termPostings.size();
          if (df == 0)
            continue;

          final double idf = BM25Scorer.idf(totalDocs, df);
          for (final FullTextPostingRID s : termPostings) {
            final double contribution = BM25Scorer.termScore(idf, s.tf, s.docLength, avgdl, k1, b) * boost;
            scoreMap.merge(s.getIdentity(), (float) contribution, Float::sum);
          }
        } finally {
          postings.close();
        }

Comment on lines +439 to +451
private void ensureCounters() {
if (ftMetadata == null)
return;
if (ftMetadata.getTotalDocs() > 0)
return;
final String typeName = getTypeName();
if (typeName == null)
return;
final DatabaseInternal db = underlyingIndex.getMutableIndex().getDatabase();
if (db.countType(typeName, false) <= 0)
return; // empty type: the (0,0) counters are correct
recomputeBM25Counters();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The ensureCounters() method is called on every query and is not synchronized. If multiple queries run concurrently on a freshly opened database where counters are not yet valid, they will all concurrently trigger recomputeBM25Counters(), causing concurrent full-table scans and concurrent configuration file writes. Double-checked locking should be used to prevent this concurrency hazard without hurting read performance.

Suggested change
private void ensureCounters() {
if (ftMetadata == null)
return;
if (ftMetadata.getTotalDocs() > 0)
return;
final String typeName = getTypeName();
if (typeName == null)
return;
final DatabaseInternal db = underlyingIndex.getMutableIndex().getDatabase();
if (db.countType(typeName, false) <= 0)
return; // empty type: the (0,0) counters are correct
recomputeBM25Counters();
}
private void ensureCounters() {
if (ftMetadata == null)
return;
if (ftMetadata.getTotalDocs() > 0)
return;
final String typeName = getTypeName();
if (typeName == null)
return;
final DatabaseInternal db = underlyingIndex.getMutableIndex().getDatabase();
if (db.countType(typeName, false) <= 0)
return; // empty type: the (0,0) counters are correct
synchronized (this) {
if (ftMetadata.getTotalDocs() > 0)
return;
recomputeBM25Counters();
}
}

Comment on lines +468 to +481
final Iterator<com.arcadedb.database.Record> it = db.iterateType(typeName, true);
while (it.hasNext()) {
final com.arcadedb.database.Record record = it.next();
if (!(record instanceof Document doc))
continue;
int len = 0;
for (final String p : props) {
final Object v = doc.get(p);
if (v != null)
len += analyzeText(indexAnalyzer, new Object[] { v }).size();
}
++docs;
sumLen += len;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The iterator returned by db.iterateType() may be a ResultSet or Cursor that implements AutoCloseable. It should be closed in a finally block to prevent resource leaks.

    final Iterator<com.arcadedb.database.Record> it = db.iterateType(typeName, true);
    try {
      while (it.hasNext()) {
        final com.arcadedb.database.Record record = it.next();
        if (!(record instanceof Document doc))
          continue;
        int len = 0;
        for (final String p : props) {
          final Object v = doc.get(p);
          if (v != null)
            len += analyzeText(indexAnalyzer, new Object[] { v }).size();
        }
        ++docs;
        sumLen += len;
      }
    } finally {
      if (it instanceof AutoCloseable closeable) {
        try {
          closeable.close();
        } catch (Exception e) {
          // ignore
        }
      }
    }

Comment on lines 364 to +365
for (final Term term : terms) {
recordScoringToken(term.text(), 1.0f);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In collectPhraseMatches, the scoring token is recorded using the unqualified term text and a hardcoded 1.0f boost. This ignores the field prefix and the configured field boost for phrase queries on specific fields (e.g., title:"java programming"). It should use buildSearchKey and boostFor to correctly apply field-specific scoring.

Suggested change
for (final Term term : terms) {
recordScoringToken(term.text(), 1.0f);
for (final Term term : terms) {
final String field = term.field();
recordScoringToken(buildSearchKey(field, term.text()), boostFor(field));

@claude

claude Bot commented Jun 22, 2026

Copy link
Copy Markdown

Code Review: feat/bm25-fulltext-scoring

Solid feature addition with well-structured code and good test coverage. A few correctness and thread-safety concerns worth addressing before merge.


Correctness Bugs

1. Per-bucket df vs. type-wide totalDocs mismatch (medium severity)

In both getBM25() and scoreCandidatesBM25(), df is computed by counting postings from underlyingIndex - a single bucket's LSMTreeIndex. But resolveTotalDocs() returns the full type count (countType(typeName, false)), which spans all buckets. When a type has multiple buckets (the common case with any non-trivial dataset), idf(totalType, dfOneBucket) is systematically biased low, making every term appear rarer than it is. This shifts scores but does not break ranking unless bucket sizes differ significantly. It may be acceptable as a known approximation, but it should be documented explicitly.

Similarly, explainScoring() returns only the first bucket's stats - fine for informational use, but worth a comment.

2. explainScoring() unnecessary allocation

final Map<RID, AtomicInteger> scoreMap = new HashMap<>();
collectMatches(query, scoreMap, new HashSet<>());
return index.explainScoring(scoringTokens);

scoreMap is populated as a side-effect of collectMatches (which also populates scoringTokens), then discarded. For large indexes this allocates a HashMap containing every matched document unnecessarily. Consider a dedicated method that skips document collection and only captures tokens.


Thread-Safety

3. Unsynchronized corpus counter mutation (medium severity)

FullTextIndexMetadata.addDocument() and removeDocument() increment bare long fields without synchronization:

public void addDocument(final long docLength) {
    ++totalDocs;       // no synchronization
    sumDocLength += docLength;
}

ArcadeDB runs concurrent transactions. Two threads simultaneously indexing documents into the same type will race on totalDocs and sumDocLength since ftMetadata is shared per-bucket-index. AtomicLong fields or synchronized methods would fix this without significant overhead.


Compaction Fix

4. Partial write to continuation page - orphaned bytes with no comment

When firstIteration && startedOnContinuation && 0 < writtenValues < values.length, the code flushes the continuation page with partial data for the current key already written to it, then rewrites all values cleanly to a fresh page. The abandoned partial write (key header + partial RID list) at the tail of the previous key's last page is unreachable (the root index points to the new page), so correctness is fine. But a clarifying comment would help future readers understand that these bytes are intentionally orphaned until the next compaction cycle.

The fix itself looks correct and the compaction test provides good coverage.


Minor Issues

5. $score type change is a potential breaking change

$score is now Float/float even for CLASSIC indexes (previously int/Integer). Any caller doing (Integer) result.getProperty("$score") will get a ClassCastException. The classicSimilarityKeepsCoordinationScoring test confirms this with isEqualTo(3.0f). This should be noted in the changelog as a behavior change for CLASSIC indexes.

6. getIndexedFunctionScoringExplain delegation chain

The method threads from FetchFromIndexedFunctionStep through BinaryCondition -> FunctionCall -> IndexableSQLFunction.getScoringExplain(). A single comment at the call site noting this chain would help maintainability.


Strengths

  • The two-phase approach in putWithStats (analyze all fields first for docLen, then store postings) correctly avoids storing postings before docLen is known.
  • FullTextPostingRID extending DatabaseRID is a clean design - tf/docLength ride the existing pipeline without signature changes, and equals/hashCode keep bucket+offset identity.
  • storeTermFrequency flag gating the extended serialization ensures zero overhead and byte-identical on-disk format for every non-full-text LSM index.
  • countersValid flag and ensureCounters() lazy recompute is good defensive design for upgrade scenarios.
  • BM25 math in BM25Scorer is correct and unit-tested against hand-computed values.
  • Existing tests pinned to CLASSIC via explicit METADATA {"similarity": "CLASSIC"} is the right migration approach - zero behavior change on upgrade.
  • The compaction bug fix is independent and valuable on its own.

@lvca lvca self-assigned this Jun 22, 2026
@lvca lvca added this to the 26.7.1 milestone Jun 22, 2026
…lain

- BM25 is scored per bucket (per-shard, like Elasticsearch): the document
  frequency is read from a single bucket's postings, so N and avgdl must be
  per-bucket too. The fallback (was countType, type-wide) and recompute (was
  iterateType, whole type) now use the associated bucket's count/records,
  removing the systematic IDF bias when a type has multiple buckets. Documented
  the per-bucket scoring model.
- EXPLAIN/PROFILE scoring metadata: collect scoring tokens in a tokens-only mode
  that skips per-document score accumulation, so no document set is materialized
  just to read the term/idf/df breakdown.
- test: BM25 ranking across multiple buckets.
@lvca

lvca commented Jun 22, 2026

Copy link
Copy Markdown
Member Author

Thanks for the review - both addressed in 21ab9bf.

1. df vs N mismatch. Fixed (not just documented). BM25 is scored per bucket (per-shard, like Elasticsearch), so df comes from a single bucket's postings - I made N and avgDocLength consistently per-bucket as well:

  • The incremental corpus counters were already per-bucket (maintained on that bucket's put/remove), so the common path was already consistent.
  • The two inconsistent paths are fixed: the fallback now counts the associated bucket (was countType, type-wide) and recomputeBM25Counters() now iterates the associated bucket (was iterateType, whole type).
  • Added a doc comment on the per-bucket scoring model, and a bm25RankingHoldsAcrossMultipleBuckets test (type with 4 buckets; the rare-term doc still ranks first).

explainScoring() reporting the first bucket's stats is now also explicitly per-bucket and commented.

2. explainScoring() allocation. Fixed. Added a tokensOnly matching mode used only by EXPLAIN/PROFILE: it captures the scoring tokens (still scanning the index for wildcard/prefix/fuzzy token expansion) but skips per-document score accumulation, so no document set is materialized.

Full-text suite (137) + SQL full-text function suite green.

@claude

claude Bot commented Jun 22, 2026

Copy link
Copy Markdown

Code Review - PR #4695: Native BM25 Full-Text Scoring

Overview

This is a substantial, well-structured addition. The design correctly separates concerns (pure math in BM25Scorer, storage in FullTextPostingRID, execution in FullTextQueryExecutor), the backward-compat story (CLASSIC default for existing indexes, BM25 for new ones) is well thought-out, and the three new test classes provide solid coverage. The compaction bug fix is a welcome bonus.

The review below focuses on things that could bite in production.


Potential Bugs

1. ensureCounters does not check countersValid -- stale non-zero counters are never recomputed

ensureCounters() only recomputes when totalDocs == 0:

if (ftMetadata.getTotalDocs() > 0)
    return;  // skips recompute even when countersValid == false

If a BM25 index is re-opened with persisted counters that are stale (e.g. written mid-batch, off by some documents), ensureCounters will silently use them. The countersValid flag is persisted but never actually checked on the read-hot path. Fix: also guard on !ftMetadata.isCountersValid():

if (ftMetadata.getTotalDocs() > 0 && ftMetadata.isCountersValid())
    return;

2. Documentation contradicts the code on the compaction bug

docs/4687-bm25-fulltext-scoring.md (section "Known limitation") says the compaction posting-drop bug is "not addressed here" and is "tracked separately". The actual Java change in LSMTreeIndexCompacted.appendDuringCompaction (the startedOnContinuation + firstIteration fix) and FullTextBM25CompactionTest both show it IS fixed. The doc should be updated.

3. boostFor hardcodes the magic string "content"

private float boostFor(final String field) {
    if (field != null && !field.isEmpty() && !"content".equals(field) && metadata != null)
        return metadata.getFieldBoost(field);
    return 1.0f;
}

The "content" check silently suppresses field-boost lookups for any field literally named content. This is an undocumented convention. A user who names their property content and configures content_boost will get boost 1.0 regardless. Suggest removing the "content" special-case or renaming the internal sentinel.


Thread Safety

4. FullTextIndexMetadata corpus counters are not thread-safe

addDocument, removeDocument, and setCounters mutate totalDocs (a plain long) without synchronization. Under concurrent transactions that index documents to the same bucket simultaneously, these counters can silently lose increments. At minimum, consider volatile long + CAS, or route all mutations through the database's existing transaction-locking primitives.


Memory / Performance

5. All postings for every query term loaded into memory

Both getBM25 and scoreCandidatesBM25 collect an unbounded List<FullTextPostingRID> per query term to compute df:

final List<FullTextPostingRID> termPostings = new ArrayList<>();
while (postings.hasNext()) {
    final Identifiable id = postings.next();
    if (id instanceof FullTextPostingRID s)
        termPostings.add(s);
}
final long df = termPostings.size();

For a common term in a large collection (say, "the" in millions of documents) this can exhaust the heap. A two-pass or streaming approach (first scan to count df, second to score only candidates) would avoid the allocation. The postings cursor supports re-opening; alternatively, df could be maintained as a separate counter per term key.

6. recomputeBM25Counters does a full bucket scan and saveConfiguration on every invocation

This is called from ensureCounters (i.e. on every BM25 query when counters are missing). The full-bucket document scan plus schema serialization on a potentially cold path is expensive. A simple in-memory guard (recomputeInProgress flag) won't help if the server is restarted; but at least calling saveConfiguration() once per server lifetime (not per query) would reduce the overhead.


Design / Clarity

7. IndexCursorEntry now carries both score (int) and floatScore (float)

The int score field is still exposed and used by existing callers; the new float score field shadows it in all BM25 contexts. This dual-field design is a maintenance hazard - future callers can easily grab the int score for a BM25 cursor and get silently truncated values. Consider marking score @Deprecated with a note pointing to floatScore.

8. Two independent BM25 scoring paths that must stay in sync

getBM25 (direct-path via LSMTreeFullTextIndex.get) and scoreCandidatesBM25 (Lucene-syntax path via FullTextQueryExecutor) both implement the BM25 formula with the same parameters but independently. If k1/b handling, boost logic, or the ensureCounters call ever diverges between the two, the same index will produce different scores depending on which code path is taken. A shared private computeBM25Scores(Iterable<String> storedKeys, Map<String,Float> boosts) helper would eliminate the duplication.

9. getScoringExplain only reports stats from the first bucket

for (final Index bucketIndex : typeIndex.getIndexesOnBuckets())
    if (bucketIndex instanceof final LSMTreeFullTextIndex ftIndex)
        return new FullTextQueryExecutor(ftIndex).explainScoring(queryString);

For a multi-bucket type the EXPLAIN output only reflects one shard's statistics. This should at least be noted as a comment so users don't mistake per-bucket IDF for global IDF.


Minor

10. No validation of BM25 parameters

k1 must be >= 0 and b must be in [0, 1]. Invalid values passed via METADATA {"bm25_k1": -1} or withBM25(-1, 2) will silently produce wrong scores (possibly negative). A check in FullTextIndexMetadata.setBm25K1/setBm25B or at index creation time would surface misconfiguration early.

11. Inline comment uses NOTE (concurrency) tagging convention inconsistently

The CLAUDE.md guidelines ask concurrent-pool callers to be tagged with // NOTE (concurrency). The new FullTextIndexMetadata counter mutations (concurrent indexing concern) have no such marker.


Positives

  • Clean separation of the pure math (BM25Scorer) from the index machinery - makes it trivially testable.
  • FullTextPostingRID extends DatabaseRID is an elegant way to piggyback stats through the existing pipeline without touching every caller.
  • The storeTermFrequency gating ensures zero byte-format change for non-full-text indexes.
  • The metadata persistence fix (the toJSON/fromJSON round-trip) is a real correctness improvement that was needed independently of BM25.
  • Test coverage is thorough: unit math tests, end-to-end ranking tests (IDF, length norm, field boost, caret, restart), and compaction survival.

- Make the BM25 corpus counters thread-safe: totalDocs/sumDocLength are now
  AtomicLong (countersValid volatile). Concurrent transactions indexing into the
  same bucket share the per-bucket metadata and were racing on bare long fields.
- Clarify in the compaction fix that no bytes are orphaned on the continuation
  page: the key's entry lives only in the scratch buffer until putByteArray, so
  the continuation page is flushed with just the previous keys' entries.
- Comment the EXPLAIN/PROFILE delegation chain at the FetchFromIndexedFunctionStep
  call site.
- Docs: note the $score type change (now Float, incl. CLASSIC where it was an
  Integer match count) as the one user-visible change for existing indexes; fold
  the compaction issue into a "fixed" section.
@lvca

lvca commented Jun 22, 2026

Copy link
Copy Markdown
Member Author

Addressed in f5df585.

3. Unsynchronized corpus counters. Fixed. totalDocs and sumDocLength are now AtomicLong (incrementAndGet/addAndGet, and updateAndGet for the clamped decrement); countersValid is volatile. Lock-free, no measurable overhead on the indexing path. (The two counters are read independently in avgDocLength(), so a concurrent update can make avgdl momentarily approximate - fine for a ranking heuristic.)

4. Continuation-page "orphaned bytes". Good catch to flag, but there are actually no orphaned bytes - the page switch happens before the commit. The key's serialized entry lives only in the scratch buffer (keyValueContent) until putByteArray() further down; when we switch to a fresh page, the continuation page is flushed with just the previous keys' entries and this key's bytes are written entirely to the fresh page. I expanded the comment to state this explicitly.

5. $score type change. Documented as the one user-visible behavior change for existing CLASSIC indexes (was Integer, now Float/Number).

6. Delegation-chain comment. Added at the FetchFromIndexedFunctionStep call site (chain: BinaryCondition → Expression → … → FunctionCall → IndexableSQLFunction.getScoringExplain, mirroring executeIndexedFunction).

Full-text + SQL full-text suites green (182).

@claude

claude Bot commented Jun 22, 2026

Copy link
Copy Markdown

Code Review: feat/bm25-fulltext-scoring

This is a substantial, well-conceived feature addition. The BM25 implementation is clearly structured, the storage design (carrying tf/docLength inline via FullTextPostingRID) is clean and backward-compatible, and the compaction bug fix is a valuable addition. The test suite covers the happy path thoroughly. Below are findings ranging from correctness concerns to minor nits.


Correctness

1. $score type changes from Integer to Float - silent breaking API change

In SQLFunctionSearchIndex.java, the $score variable is now stored as float for both BM25 and CLASSIC indexes. Any caller that currently does:

Integer score = (Integer) result.getProperty("$score");

will get a ClassCastException after upgrade - even on CLASSIC indexes, because the code now converts the old integer match-count to (float) before storing it. This should be flagged prominently in release notes, and/or a compatibility shim using Number as the type could smooth the transition.

2. docLength recomputed during remove() may drift from original put() length

In LSMTreeFullTextIndex.remove(), the document length is re-derived by re-analyzing the field values at removal time. If the analyzer configuration changed between put() and remove() (or the text content was modified before the remove was called with the old keys), ftMetadata.removeDocument(docLen) will subtract a different value than was added, permanently skewing sumDocLength and therefore avgDocLength for all future BM25 scores. This is a known limitation in some engines, but it is worth a comment in the code and documentation noting it. One option: store the original docLength in a lightweight secondary map keyed by RID.

3. BM25 parameter bounds not validated

FullTextIndexMetadata.setBm25K1() and setBm25B() accept arbitrary floats. In BM25Scorer.termScore(), the formula is:

final double norm = 1.0 - b + b * (docLen / safeAvgdl);
return idf * (tf * (k1 + 1.0)) / (tf + k1 * norm);

With k1 = 0, the denominator reduces to tf * norm - which can be zero when tf = 0. With b < 0 or b > 1, length normalization produces nonsense values. Simple guards:

if (k1 <= 0) throw new IllegalArgumentException("bm25_k1 must be > 0, got: " + k1);
if (b < 0 || b > 1) throw new IllegalArgumentException("bm25_b must be in [0,1], got: " + b);

These should also be validated in the SQL CREATE INDEX ... METADATA {...} parser path.

4. Corpus counters can drift on transaction rollback

addDocument() and removeDocument() on the AtomicLong pair in FullTextIndexMetadata are called during index put()/remove() operations, before the transaction commits. On rollback, the increment is not reversed. For BM25, small long-term drift in totalDocs/avgDocLength is generally tolerable (the formula is robust to approximate statistics), but a significant skew - such as bulk-indexing a batch that then rolls back - could meaningfully affect scoring quality. At minimum, document this limitation explicitly in FullTextIndexMetadata.

5. Silent precision loss in IndexCursorEntry

// IndexCursorEntry.java
this.score = Math.round(floatScore);   // e.g. 2.73 -> 3
this.floatScore = floatScore;           // 2.73 preserved here

The legacy score field rounds BM25 scores, so any code that reads entry.getScore() instead of entry.getFloatScore() silently loses precision. Consider deprecating score (as an int) and making getScore() delegate to Math.round(floatScore) with a @Deprecated annotation, or remove it from the public API entirely since floatScore is always populated.


Performance

6. Double iteration over posting list in scoreCandidatesBM25()

In LSMTreeFullTextIndex, postings for each token are first drained into a List<FullTextPostingRID> to compute df, then iterated again to score documents. For high-frequency terms this could be significant. Since df is just termPostings.size(), a single-pass approach accumulates scores while counting:

long df = 0;
while (postings.hasNext()) {
  if (postings.next() instanceof FullTextPostingRID s) {
    df++;
    // update docAccumulator inline
  }
}
// apply IDF using df after the pass

This halves allocations for high-cardinality postings.


Thread Safety

7. fieldAnalyzers and fieldBoosts are HashMap, not ConcurrentHashMap

In FullTextIndexMetadata, these maps are HashMap. While they are effectively write-once during index creation, writeToJSON() iterates them and could theoretically race with a concurrent setFieldAnalyzer()/setFieldBoost() call, throwing ConcurrentModificationException. Since the project emphasizes thread safety throughout, switching to ConcurrentHashMap or Map.copyOf() in writeToJSON() would be safer.


Testing

8. Missing tests for error/edge cases

The test suite is strong for the happy path but is missing:

  • Invalid parameter inputs (k1 = 0, b = -1, b = 2) - especially important now that no validation exists (see Bump slf4j-simple.version from 1.7.31 to 1.7.32 #3)
  • Empty or single-character document indexed and then removed (counter invariant)
  • recomputeBM25Counters() called on an empty type (all documents deleted)
  • CLASSIC index queried via $score to verify it still returns a value (regression for the float type change in build support #1)

Minor / Nits

9. Inconsistent counter validity predicates

ensureCounters() checks ftMetadata.getTotalDocs() > 0, but the constructor uses !metadata.isCountersValid(). A freshly created empty index would have totalDocs = 0 and isCountersValid() = true, causing ensureCounters() to call recomputeBM25Counters() on every query until at least one document is indexed. Aligning to isCountersValid() would be cleaner.

10. !"content".equals(field) hardcode in boostFor()

// FullTextQueryExecutor.java
if (field != null && !field.isEmpty() && !"content".equals(field) && metadata != null)
    return metadata.getFieldBoost(field);

The string "content" appears to be a sentinel for single-field indexes, but it is not documented and would silently suppress any configured content_boost. If single-field indexes use the property name as the field, this check should use the actual index property name, or the magic string should be a named constant with a comment.

11. Docs file in wrong location

docs/4687-bm25-fulltext-scoring.md is a design/change doc rather than user-facing documentation. If it is intended as developer notes, it belongs under docs/internal/ or similar; if it is user docs, it should be integrated with the existing documentation structure rather than named after the issue number.


Summary

Area Severity Item
API compat Major $score type change from Integer to Float
Correctness Major docLength drift on remove() re-analysis
Correctness Major No validation of k1/b BM25 parameters
Correctness Moderate Corpus counters not rolled back on tx rollback
Correctness Minor Math.round() truncates legacy score field
Performance Minor Double iteration over posting list for BM25 scoring
Thread safety Minor HashMap iterated without synchronization
Testing Minor No tests for invalid params or empty-type edge cases
Code clarity Nit Inconsistent counter validity check predicate
Code clarity Nit Hardcoded "content" sentinel in boostFor()

Overall this is high-quality work - the BM25 math is correct, the backward-compatibility gating via storeTermFrequency is well-designed, and the persistence fix (previously toJSON dropped metadata) is a genuinely important bug fix. Addressing the type-change compatibility issue and adding parameter validation before merge would be the highest-priority items.


Reviewed by Claude (Sonnet 4.6)

@codecov

codecov Bot commented Jun 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.93220% with 135 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.29%. Comparing base (e8fee25) to head (95586a1).

Files with missing lines Patch % Lines
.../arcadedb/index/fulltext/LSMTreeFullTextIndex.java 81.46% 22 Missing and 31 partials ⚠️
...arcadedb/index/fulltext/FullTextQueryExecutor.java 69.91% 20 Missing and 14 partials ⚠️
...dedb/function/sql/text/SQLFunctionSearchIndex.java 65.71% 7 Missing and 5 partials ⚠️
.../java/com/arcadedb/graph/GraphDatabaseChecker.java 71.05% 10 Missing and 1 partial ⚠️
...ava/com/arcadedb/schema/FullTextIndexMetadata.java 89.69% 5 Missing and 5 partials ⚠️
.../com/arcadedb/schema/TypeFullTextIndexBuilder.java 75.00% 5 Missing and 1 partial ⚠️
...main/java/com/arcadedb/engine/DatabaseChecker.java 88.88% 1 Missing and 2 partials ⚠️
...ery/sql/executor/FetchFromIndexedFunctionStep.java 50.00% 2 Missing and 1 partial ⚠️
...main/java/com/arcadedb/index/IndexCursorEntry.java 88.88% 0 Missing and 1 partial ⚠️
.../main/java/com/arcadedb/index/TempIndexCursor.java 0.00% 0 Missing and 1 partial ⚠️
... and 1 more
Additional details and impacted files
@@             Coverage Diff              @@
##               main    #4695      +/-   ##
============================================
+ Coverage     65.23%   65.29%   +0.06%     
+ Complexity      547      537      -10     
============================================
  Files          1671     1673       +2     
  Lines        130891   131503     +612     
  Branches      28033    28163     +130     
============================================
+ Hits          85385    85870     +485     
- Misses        33712    33778      +66     
- Partials      11794    11855      +61     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…er, validation

- IDF consistency (multi-bucket): the FullTextIndexMetadata (and its corpus
  counters) is shared across a type's bucket indexes, so the counters are
  type-wide. Since document frequency is read per bucket, N for IDF now comes
  from the bucket's live record count (matching df scope); the shared counters
  feed only the average document length. Recompute scans the whole type (not a
  single bucket, which would corrupt the shared counters).
- Unify the two BM25 scoring paths (direct get() and Lucene-syntax executor)
  into one streaming computeBM25Scores helper: df is stream-counted without
  materializing the posting list, and only candidate/result postings are held -
  bounding memory for high-frequency terms and removing duplicated formula code.
- ensureCounters() now keys off countersValid (not totalDocs > 0), so a fresh
  empty index is not rescanned on every query; lazy recompute is in-memory only
  (no saveConfiguration on the read path).
- Validate BM25 params (k1 >= 0, b in [0,1]) in the setters and the METADATA
  path; reject misconfiguration at index creation.
- Centralize the QueryParser default-field sentinel as DEFAULT_FIELD with the
  collision limitation documented.
- Per-field maps -> ConcurrentHashMap (read on the query path, iterated by
  writeToJSON). Document corpus-counter drift on rollback / analyzer change.
- IndexCursorEntry.score: document the BM25 rounding/precision loss (use
  floatScore). Comment that EXPLAIN reports one representative bucket's stats.
- tests: BM25 param validation, document removal + recompute (incl. empty type).
@lvca

lvca commented Jun 22, 2026

Copy link
Copy Markdown
Member Author

Addressed in 6e43e4a (a few were already fixed in f5df585/21ab9bf; noted below).

1. $score Integer→Float. Documented as the one user-visible change for existing CLASSIC indexes (was Integer, now Float/Number) in the design note. $score is a Number, so ((Number) prop).floatValue() is the safe read.

2. docLength drift on remove(). Documented (code + addDocument javadoc): the counters feed only avgDocLength (a robust normalizer), are not reversed on rollback, and a recomputed remove length can drift after an analyzer change. recomputeBM25Counters() rebuilds exactly.

3. BM25 param bounds. Validated in setBm25K1/setBm25B and on the METADATA {...} path (rejected at index creation; test added). Kept k1 >= 0 (not > 0): termScore returns 0 for tf <= 0 and scoring only sees tf >= 1, so the denominator is never zero; k1 = 0 is a legitimate "no TF saturation" config (as in Elasticsearch).

4. Counter drift on rollback. Documented on the counter mutators (see #2).

5. IndexCursorEntry.score precision. Documented the rounding hazard and pointed to floatScore/getFloatScore(). Chose a Javadoc warning over @Deprecated: the int field is exact and legitimate for all non-BM25 cursors (regular LSM, CLASSIC coordination), so deprecating it would warn at every valid call site.

6. Double iteration / allocation. Both BM25 paths now share one streaming computeBM25Scores: df is stream-counted without materializing the posting list, and only candidate/result postings are retained - bounds memory for high-frequency terms (the earlier OOM concern) and removes the duplicated formula.

7. fieldAnalyzers/fieldBoosts thread-safety. Switched to ConcurrentHashMap (read on the query path, iterated by writeToJSON).

8. Edge-case tests. Added: invalid params; document removal + recomputeBM25Counters including on an emptied type. CLASSIC $score is already asserted (as Float) by classicSimilarityKeepsCoordinationScoring.

9. ensureCounters predicate. Now keys off countersValid (not totalDocs > 0), so a fresh empty index isn't rescanned per query.

10. "content" hardcode. Now a documented DEFAULT_FIELD constant; the multi-field collision limitation is noted (renaming would break single-property indexes whose sole property is content).

11. Docs location. docs/ already holds issue-numbered dev notes (e.g. 4317-*.md, 4333-*.md), so docs/4687-*.md follows the existing convention; happy to relocate if you prefer a different home.

One design note re #1 (multi-bucket): the IDF fix found that the metadata - and thus the corpus counters - is shared type-wide across a type's bucket indexes, so N now comes from the per-bucket record count to stay consistent with the per-bucket df; the shared counters serve only avgDocLength. Full suite green: index + functions + SQL methods (1669) and full-text + function (184).

@claude

claude Bot commented Jun 22, 2026

Copy link
Copy Markdown

Code Review: feat/bm25-fulltext-scoring

Overview

This is a substantial and well-designed PR that adds native Okapi BM25 ranking to FULL_TEXT indexes. The implementation is layered cleanly: math isolated in BM25Scorer, posting statistics carried inline via FullTextPostingRID, serialization gated by a storeTermFrequency flag, and the corpus counters maintained in FullTextIndexMetadata. The compaction fix (dropped postings when a token's list spans multiple pages) is an important correctness fix that happens to land alongside BM25 but is independent.


Positives

  • Clean math isolation - BM25Scorer is side-effect-free, trivially testable, and the IDF formula matches the sparse-vector index for consistency.
  • Backward-compatible upgrade path - old indexes default to CLASSIC, no silent ranking changes on upgrade.
  • Elegant RID extension - FullTextPostingRID extends DatabaseRID so equals/hashCode keep working and the whole existing RID pipeline is unaffected.
  • Compaction fix - well-reasoned; the startedOnContinuation + firstIteration flags elegantly handle the overflow case without disrupting the general multi-page split path.
  • Metadata persistence fix - the toJSON/fromJSON round-trip bug was a real correctness issue (custom analyzers silently reverting on restart).
  • Test coverage - BM25ScorerTest (pure math), FullTextBM25Test (end-to-end IDF/length-norm/boost/CLASSIC/restart), and FullTextBM25CompactionTest cover the key scenarios well.

Issues and Suggestions

1. Two-pass posting scan doubles I/O (Performance)

computeBM25Scores in LSMTreeFullTextIndex opens each token's posting cursor twice: once to count df and compute idf, then again to accumulate per-document scores. For a common term (e.g. "data" in the compaction test with 600 postings), this is 1200 cursor reads per term per query. The trade-off - avoiding materializing the whole posting list - is documented. A low-effort improvement: if a df count is accessible through the LSM layer without a full scan (the compacted index already has page-level counts in its root), the first pass could be avoided. Even a // TODO: optimize with index-level count comment would be useful to track the gap.

2. Corpus counter drift on rollback (Correctness concern, documented)

addDocument/removeDocument are called at put/remove time, BEFORE the transaction commits, and are not reversed on rollback. The PR documents this clearly. The drift affects only avgdl (a BM25 length normalizer), not IDF, so ranking degrades gradually rather than catastrophically. However, there is currently no automated periodic recompute - recomputeBM25Counters() is public and called in tests but not wired into any background job. Consider scheduling a periodic recompute (similar to MaterializedViewScheduler) or flagging countersValid = false on rollback so the lazy ensureCounters() path kicks in on the next query.

3. $score type change is a breaking API change (User-visible)

Changing $score from Integer to Float (even for CLASSIC indexes) is a breaking change for application code that reads $score via result.getProperty("$score", Integer.class) or casts it directly. The PR docs mention it, but it should be explicitly called out in the release notes. The test classicSimilarityKeepsCoordinationScoring validates the float values but does not pin the returned type - worth adding a check that the returned type is Float, not Integer, so the change is explicitly tested.

4. setSimilarity does not validate unknown similarity names

public void setSimilarity(final String similarity) {
    this.similarity = similarity != null ? similarity.toUpperCase() : SIMILARITY_BM25;
}

An index created with METADATA {"similarity": "LUCENE"} would silently behave as CLASSIC (isBM25() returns false). Consider adding validation:

if (!SIMILARITY_BM25.equals(upper) && !SIMILARITY_CLASSIC.equals(upper))
    throw new IllegalArgumentException("Unknown similarity: " + similarity + ". Valid values: BM25, CLASSIC");

5. No test for CLASSIC compaction fix

The compaction bug fix in LSMTreeIndexCompacted is described as independent of BM25 - it affected CLASSIC too. FullTextBM25CompactionTest covers it for BM25 but there is no test that verifies CLASSIC postings survive compaction after the fix. A regression test for CLASSIC would close the coverage gap and document the historical break.

6. EXPLAIN reports only the first bucket (Documentation gap)

getScoringExplain in SQLFunctionSearchIndex iterates getIndexesOnBuckets() and returns the first full-text bucket's statistics. For a type with many buckets, IDF can differ significantly between them (per the resolveTotalDocs design). Nothing in the output itself makes this sampling clear. Suggest adding a "note" field in the explain JSON (e.g. "stats from one bucket; BM25 is scored per bucket") so users are not surprised by mismatches between the explained IDF and actual scores.

7. countDocuments multiplies by rids.length (Potential over-count)

countDocuments(rids.length, docLen);

put accepts a RID[] array. For a full-text index the common case is one RID per indexed document, but if multiple RIDs are ever passed the counter over-counts by rids.length. It is worth asserting (or at least documenting) that rids.length == 1 is expected for the full-text BM25 path, or handling the multi-RID case explicitly.

8. Missing @tag on some FullTextBM25Test methods

bm25RankingHoldsAcrossMultipleBuckets inserts 41 documents across 4 buckets, and bm25ConfigAndCountersSurviveRestart inserts 13 documents and reopens the database. Per the project's convention, tests with non-trivial setup/data volumes should be tagged @tag("slow") at the method level.

9. Minor: fully-qualified return type in explainScoring

public com.arcadedb.serializer.json.JSONObject explainScoring(final String queryString) {

The project convention is to import classes and use short names. JSONObject is already imported in other files in this package.


Summary

The implementation is solid and the design choices (two-pass scan, per-bucket IDF, lazy counter recompute) are well-reasoned and documented. The highest-priority items before merge are:

  1. Validation in setSimilarity to reject unknown similarity names (item 4 - easy fix, prevents silent misconfiguration)
  2. CLASSIC compaction regression test (item 5 - closes the coverage gap for an independently-important bug fix)
  3. Release-note callout for the $score type change from Integer to Float (item 3)

Items 2 and 6 (counter drift on rollback, EXPLAIN multi-bucket note) are acceptable with the current documentation but worth addressing in a follow-up.

…ty validation, tests

- computeBM25Scores: when a candidate set is given (the SEARCH_INDEX path) score
  in a SINGLE pass, counting df while collecting only candidate postings - halves
  cursor I/O for high-frequency terms vs the previous two-pass. The no-candidate
  path stays two-pass to keep memory bounded. Added a TODO to source df from a
  per-key index count if the LSM layer ever exposes one.
- setSimilarity rejects unknown names (e.g. "LUCENE") instead of silently falling
  back to CLASSIC; the METADATA path routes through it.
- EXPLAIN scoring JSON carries a "note" stating the statistics are per-bucket, so
  the explained IDF is not mistaken for a global value.
- countDocuments: document that rids.length is 1 per document on the standard path.
- Docs: corpus-counter drift / no-background-recompute behavior; $score is Float.
- Use the imported JSONObject short name in explainScoring.
- tests: CLASSIC postings survive compaction (independent regression); $score type
  pinned to Float for CLASSIC; unknown-similarity rejection; @tag("slow") on the
  multi-bucket and restart tests.
@lvca

lvca commented Jun 22, 2026

Copy link
Copy Markdown
Member Author

Addressed in 74af1b2.

1. Two-pass scan. The common SEARCH_INDEX path (candidate set known) is now single-pass: it counts df while collecting just the candidate postings (bounded by the candidate set), then applies the IDF - halving cursor reads for high-frequency terms. The no-candidate path stays two-pass to keep memory bounded. Added a TODO to source df from a per-key index count if the LSM layer ever exposes one.

2. Counter drift / no periodic recompute. Documented explicitly (counters feed only avgdl; not reversed on rollback; recomputeBM25Counters() is the on-demand repair; lazy rebuild when countersValid is false). I deliberately did not wire a background scheduler in this PR - happy to add a MaterializedViewScheduler-style job as a follow-up if you'd like it.

3. $score type. Pinned in a test (assertThat(...).isInstanceOf(Float.class)); the breaking change is documented for release notes.

4. Unknown similarity. setSimilarity now throws on anything other than BM25/CLASSIC, and the METADATA path routes through it (test added).

5. CLASSIC compaction. Added classicPostingsSurviveCompaction - the same tiny-page, multi-page-token scenario on a CLASSIC index, asserting no postings drop.

6. EXPLAIN per-bucket. The scoring JSON now includes a note field stating the statistics are per-bucket.

7. countDocuments / rids.length. Documented that the standard indexing path passes exactly one RID per document, so each RID is a distinct document (no over-count).

8. @Tag("slow"). Added to the multi-bucket and restart tests.

9. Fully-qualified type. explainScoring now uses the imported JSONObject.

Full-text + function suites green (185).

@claude

claude Bot commented Jun 22, 2026

Copy link
Copy Markdown

Code review posted - see below for full analysis

@claude

claude Bot commented Jun 22, 2026

Copy link
Copy Markdown

Code Review: Native BM25 full-text scoring

This is a substantial, well-engineered feature. The BM25 math is isolated and unit-tested, and backward compatibility is carefully preserved.

Correctness concerns

Corpus counters not reversed on rollback: addDocument/removeDocument fire before the transaction commits. A failed transaction permanently inflates totalDocs/sumDocLength with no automatic repair - only manual recomputeBM25Counters() fixes it. Consider: (1) annotating countDocuments() with the rollback-drift risk, and (2) adding a test that rolls back and verifies the counter repair.

@claude

claude Bot commented Jun 22, 2026

Copy link
Copy Markdown

Code Review: Native BM25 full-text scoring

This is a substantial, well-engineered feature. The overall design is sound, the BM25 math is isolated and unit-tested, and backward compatibility is carefully preserved.


CORRECTNESS CONCERNS

Corpus counters not reversed on rollback: addDocument/removeDocument fire before the transaction commits. A failed insert that rolls back permanently inflates totalDocs/sumDocLength with no automatic repair - only manual recomputeBM25Counters() fixes it. Consider: (1) annotating countDocuments() with the rollback-drift risk; (2) adding a test that rolls back and verifies counter repair.

Double scan of posting list for unbounded BM25 queries: In computeBM25Scores with candidates == null, each token is iterated TWICE - once for df, once for scoring. For common terms with large posting lists this doubles read I/O. The TODO is correct; if deferred, document the expected impact.

associatedBucket() swallows all exceptions: Catching Exception and returning null hides legitimate failures. resolveTotalDocs() falls back to 1, silently biasing IDF. Tighten to SchemaException or at minimum log at WARNING.


DESIGN / API CONCERNS

Breaking change - score type widened from Integer to Float for CLASSIC indexes: Runtime break for code casting to Integer. The classicSimilarityKeepsCoordinationScoring test pins the new type correctly. Consider noting this prominently in release notes.

DEFAULT_FIELD collision on multi-property indexes: A property literally named "content" silently loses field boosts. Consider renaming the sentinel (e.g., default) or logging a WARNING at creation time.

Mutable instance fields in FullTextQueryExecutor: scoringTokens, collectingExclusion, tokensOnly, currentBoost are not reset between calls. The one-executor-per-search invariant is not enforced. Resetting at the top of search() and explainScoring() would prevent silent corruption if an executor were reused.


PERFORMANCE NOTES

computeCorpusCounters scans entire type on first query: Cold-start lazy recompute can be slow on large collections. Consider persisting countersValid = true eagerly at schema save, or logging the recompute at INFO.

explainScoring iterates postings for every query term: Surprising cost for large indexes. A warning comment would help.


MINOR NITS

Deletion markers wrapped in FullTextPostingRID: In readEntryValue, markers with negative bucket IDs are wrapped with tf=0/docLength=0. Future instanceof checks could misinterpret them. A rid.getBucketId() >= 0 guard before constructing FullTextPostingRID would make intent explicit.

Fragile hasSize(601) in compaction test: Fragile to tokenization/bucket changes. Prefer isGreaterThanOrEqualTo(600) plus containsKey("rare").


WHAT IS DONE WELL

  • BM25Scorer is pure and trivially testable - isolating the math from DB is the right call.
  • FullTextPostingRID threading through the existing RID pipeline is elegant; avoids signature changes across indexing, compaction, and transaction layers.
  • The compaction bug fix (overflowing key forced to a fresh page) correctly addresses the root cause of the silent posting-drop. The firstIteration + startedOnContinuation guard is subtle but correct.
  • Metadata persistence fix is important - the old toJSON() silently dropped custom analyzer config on restart.
  • Backward compatibility is handled cleanly: existing indexes open as CLASSIC, BM25 requires an explicit rebuild, zero behavior change on upgrade.
  • currentBoost multiplication in recordScoringToken correctly handles caret boosts for ALL query types without duplicating the logic at each call site.
  • Test coverage is comprehensive: BM25ScorerTest (pure math), FullTextBM25Test (ranking, length norm, field/caret boosts, CLASSIC fallback, restart), FullTextBM25CompactionTest (postings survive compaction). Pinning CLASSIC in existing tests is the right approach.
  • EXPLAIN/PROFILE support adds real diagnostic value.

Overall this is ready to merge after the correctness concerns are addressed (or explicitly accepted as known trade-offs with follow-up issues filed).

Review by Claude Sonnet 4.6

…ft test

- readEntryValue: keep deletion markers (negative bucket id) as a plain RID
  instead of wrapping them in FullTextPostingRID, so nothing mistakes a marker
  for a scorable posting (the tf/docLength varints are still read to stay aligned).
- FullTextQueryExecutor: reset per-query matching state (scoringTokens,
  collectingExclusion, tokensOnly, currentBoost) at the top of search() and
  explainScoring() - defensive against executor reuse.
- associatedBucket(): log a WARNING instead of silently returning null (IDF then
  falls back to N=1); log the cold-start corpus recompute at INFO (it full-scans
  the type).
- countDocuments: document the rollback-drift risk (counters feed only avgdl;
  recomputeBM25Counters repairs).
- EXPLAIN explainScoring: note the per-term posting scan cost.
- docs: fix stale RIDWithStats -> FullTextPostingRID; document the single-pass
  (candidate) vs two-pass (unbounded) scoring and the bounded-memory trade-off.
- tests: rolled-back insert is not indexed and recompute repairs counters;
  relaxed the fragile hasSize(601/600) checks to >= + containsKey.
@lvca

lvca commented Jun 22, 2026

Copy link
Copy Markdown
Member Author

Addressed in ada3e4b.

Correctness

  • Counter rollback drift — documented on countDocuments (fires pre-commit, not reversed on rollback, feeds only avgdl, repaired by recomputeBM25Counters), and added a test: a rolled-back insert is never indexed and recomputeBM25Counters keeps scoring correct afterwards.
  • Double scan (unbounded path) — the common SEARCH_INDEX path is single-pass now; only the direct index-lookup path (no candidate set) scans twice to bound memory. Documented the trade-off + impact in the design note; TODO remains for an index-level df count.
  • associatedBucket() swallowing exceptions — now logs a WARNING (IDF falls back to N=1) instead of silently returning null.

Design / API

  • $score Integer→Float — documented for release notes; type pinned in a test.
  • DEFAULT_FIELD collision — kept the constant + documented limitation (renaming breaks single-property content indexes). Open to also logging a WARNING at creation when a multi-property index contains a content field if you prefer - say the word.
  • Mutable executor fields — now reset at the top of search() and explainScoring(); the one-executor-per-search invariant no longer relied upon.

Performance

  • Cold-start recomputeCorpusCounters full scan is logged at INFO.
  • explainScoring per-term posting scan documented as a diagnostic-only cost.

Nits

  • Deletion markers (negative bucket id) are no longer wrapped in FullTextPostingRID (kept as plain RID; varints still read for alignment).
  • Compaction tests: replaced hasSize(601/600) with >= + containsKey("rare").

Full-text + function suites green (186); non-full-text LSM smoke (72) confirms the gated readEntryValue change doesn't affect other indexes.

@codacy-production

codacy-production Bot commented Jun 22, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 9 complexity

Metric Results
Complexity 9

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@claude

claude Bot commented Jun 22, 2026

Copy link
Copy Markdown

Code Review: Native BM25 Full-Text Scoring (#4695)

This is a substantial and well-engineered PR. The design is solid, the documentation is thorough, and the compaction bug fix is a genuine correctness fix independent of BM25. Here is a breakdown by area.


Strengths

  • Clean separation of concerns. BM25Scorer is a pure-math, database-free class that is trivially unit-testable. Its tests verify the Robertson-Sparck-Jones formula, saturation, and length normalization exactly.
  • Smart storage design. FullTextPostingRID extends DatabaseRID is an elegant carrier: equals/hashCode inherit from RID (bucket+offset only), so the whole existing RID-typed pipeline (transaction staging, compaction, cursors) works unchanged. The storeTermFrequency flag is the only per-index switch.
  • Strong backward compatibility. Old indexes open as CLASSIC with no data migration. The countersValid gate correctly forces a lazy full scan before the first BM25 query on a pre-feature index.
  • Compaction bug fix is correct. The root cause (a positional sparse root index cannot key one leaf page under two different tokens) is well-diagnosed, the fix (force an overflowing key onto a fresh page it fully owns) is minimal, and the regression test drives it at a tiny page size.
  • Good test coverage. BM25ScorerTest, FullTextBM25Test, and FullTextBM25CompactionTest together cover math correctness, IDF ranking, length normalization, field boosts, caret boosts, CLASSIC fallback, restart persistence, and compaction survival.

Issues

1. Corpus counter drift on rollback (functional correctness)

addDocument / removeDocument update the AtomicLong counters before the transaction commits, and the counters are not reversed on rollback. The code comments acknowledge this, but:

  • A high-rollback workload (retried optimistic transactions, batch imports with failures) can inflate totalDocs / sumDocLength unboundedly.
  • There is no automatic trigger to call recomputeBM25Counters() - the user must know to call it.

Suggestion: either (a) document explicitly when the user should rebuild, or (b) wire a post-rollback callback that clears countersValid, so the lazy rebuild fires on the next query rather than silently using stale numbers.

2. Stale counters survive restart with countersValid = true (data correctness)

The counters are persisted only when the schema is saved. If documents are indexed after the last schema save and the server restarts (even cleanly), those documents' token counts are in the index on disk but not in the loaded counters - yet countersValid is still true. The average document length will be understated until a rebuild.

Since the schema save cadence is not controlled by the full-text index, the simplest fix is to set countersValid = false on schema load unless a WAL-consistency guarantee can be made (i.e. only set countersValid = true when the counters were saved atomically with the last committed transaction).

3. Double full scan for unconstrained BM25 queries (performance)

In computeBM25Scores when candidates == null, every query token is scanned twice: once for df and once for score accumulation. For a common token (e.g. "data" in 50k documents) across 10 query terms this is 20 full posting-list scans per query. The TODO comment acknowledges this, but it is a meaningful regression vs. the CLASSIC path which scans once. Worth a follow-up ticket.

4. Float precision loss in score accumulation

In computeBM25Scores:

scoreMap.merge(s.getIdentity(),
    (float) (BM25Scorer.termScore(idf, s.tf, s.docLength, avgdl, k1, b) * boost),
    Float::sum);

termScore returns a double. Casting to float before Float::sum discards precision on every term. For a document matching many query terms the accumulated error is visible. Accumulating in double throughout and converting to float only when writing to IndexCursorEntry would improve result quality at no extra cost.

5. Breaking change for $score type in CLASSIC indexes

The cache map changes from Map<RID, Integer> to Map<RID, Float>, so $score is now a Float for CLASSIC indexes (previously Integer). Any application code doing (Integer) result.getProperty("$score") will throw a ClassCastException at runtime. The PR documentation covers this, but a prominent release-notes callout would help users upgrading existing integrations.

6. EXPLAIN reports only the first bucket's statistics

for (final Index bucketIndex : typeIndex.getIndexesOnBuckets())
    if (bucketIndex instanceof final LSMTreeFullTextIndex ftIndex)
        return new FullTextQueryExecutor(ftIndex).explainScoring(queryString);

For a type with multiple buckets, the IDF figures in EXPLAIN come from bucket 0 only. If data distribution is skewed, these numbers may differ significantly from the bucket that scored the top result. The "note" field in the JSON is a reasonable mitigation; adding the bucket id/name to the EXPLAIN output would help users correlate the stats to a specific shard.

7. DEFAULT_FIELD = "content" collision

The collision where a property literally named content on a multi-property index is treated as "no field boost" is documented inline. Using a name that is guaranteed invalid as a user property name (e.g. "__default__") would eliminate the collision without breaking single-property indexes, since the sentinel is only used as the Lucene QueryParser default field name. Worth a follow-up issue.

8. Minor style

  • FullTextQueryExecutor carries mutable per-query state (scoringTokens, collectingExclusion, tokensOnly, currentBoost) as instance fields. Since "a new executor is created per search" is the invariant, passing these as parameters through collectMatches would be safer and more explicit.
  • BM25Scorer has a trailing blank line before the closing }.

Test coverage gaps

  • No test verifies corpus counter behavior after a rollback: insert docs, roll back, check that counters remain consistent or that they drift and recomputeBM25Counters() repairs them.
  • No test covers a restart when documents were indexed after the last schema-save: reopen should trigger a lazy counter rebuild on the first query.
  • The bm25RankingHoldsAcrossMultipleBuckets test has @Tag("slow") at the method level inside a class without it at the class level - correct, but worth verifying that CI filters slow at both class and method levels.

Summary

The implementation is production-quality in most respects. Items 1 and 2 (counter drift on rollback and across restarts) are the ones I would address before merging - they are silent correctness issues that users will not discover until their ranking degrades unexpectedly. Item 3 (double-scan) is a known trade-off acceptable if tracked with a follow-up ticket. Items 4-8 are lower-priority polish.

…ness self-heal

- computeBM25Scores accumulates in double and narrows to float only when building
  the cursor entry, removing per-term float rounding error.
- Restart staleness: persisted corpus counters can lag the on-disk data (docs
  indexed after the last schema save). On the first BM25 query of a session the
  counters are validated once with a cheap live document count (countType) and
  rebuilt only if they disagree - a clean restart pays nothing, a stale one
  self-heals. (staleChecked is transient.)
- EXPLAIN scoring JSON now includes the bucket name alongside the per-bucket note.
- BM25Scorer: removed the trailing blank line.
@lvca

lvca commented Jun 22, 2026

Copy link
Copy Markdown
Member Author

Addressed in dd1cdf73.

4. Float accumulation precision. computeBM25Scores now accumulates in double (the score map is Map<RID, Double>) and narrows to float only when building the cursor entry - no per-term rounding error.

2. Stale counters surviving restart. Implemented self-healing, but a bit cheaper than always invalidating on load (which would force a full re-scan on every restart even when the counters are fresh - costly for large indexes). On the first BM25 query of a session the persisted counters are validated once against a cheap live document count (countType); they are rebuilt only if they actually disagree. So a clean restart with fresh counters pays nothing, and a restart with documents indexed after the last schema save self-heals on the first query. (countType < the full posting/analysis rescan, and it runs once per session.)

6. EXPLAIN bucket attribution. The scoring JSON now includes the bucket name next to the per-bucket note, so the stats can be correlated to a shard.

8 (minor). Removed the trailing blank line in BM25Scorer.

Deferred with rationale (flagged for your call):

  • 1 (rollback drift): documented + repair via recomputeBM25Counters; a post-rollback auto-invalidate would cause full-rescan storms on high-rollback workloads, and the restart self-heal (above) bounds cross-restart drift. Affects only avgdl.
  • 3 (double scan, unbounded path): only the direct index-lookup path scans twice (the common SEARCH_INDEX path is single-pass); documented + TODO. Happy to file a follow-up for an index-level df count.
  • 5 ($score type): documented for release notes; type pinned in a test.
  • 7 (DEFAULT_FIELD): documented limitation. Changing the sentinel alone would break single-property indexes whose sole property is literally content (a content:term query would look up a prefixed token that doesn't exist); the proper fix (sentinel + field-equals-sole-property fallback) is a good follow-up issue.
  • 8 (executor state as params): resetState() at each public entry point mitigates; threading 4 params through the recursive collectors is a larger refactor for marginal gain.

Full-text + function suites green (186).

@claude

claude Bot commented Jun 22, 2026

Copy link
Copy Markdown

Code Review: feat/bm25-fulltext-scoring

This is a substantial, well-engineered PR. The design is sound, backward-compatibility handling is thoughtful, and test coverage is good. Below are findings ranging from correctness risks to minor style notes.


Overview

Adds Okapi BM25 ranking to FULL_TEXT indexes as the new default for freshly-created indexes. Existing indexes are unchanged (CLASSIC path). Also fixes a pre-existing compaction bug where postings were silently dropped when a single token's posting list spanned multiple compacted pages.


Correctness Issues

1. TOCTOU on staleChecked allows redundant full corpus scans

In LSMTreeFullTextIndex.ensureCounters():

if (!ftMetadata.isStaleChecked()) {
    ftMetadata.markStaleChecked();
    final long liveCount = ...countType(typeName, false);
    if (liveCount != ftMetadata.getTotalDocs())
        computeCorpusCounters(false);  // full type scan
}

isStaleChecked() reads a volatile boolean and markStaleChecked() writes it, but there is no CAS between them. Two threads racing on the first BM25 query can both see false, both mark it true, and both call computeCorpusCounters - which iterates the entire type. Since ftMetadata is shared across all bucket indexes for a type, multiple bucket indexes can race on startup. For a large collection this is an expensive redundant scan.

Suggestion: switch staleChecked to AtomicBoolean and guard the recompute with compareAndSet(false, true).

2. countDocuments(rids.length, docLen) assumes all passed RIDs have the same document length

In putWithStats, docLen is computed from the token analysis of the incoming keys, then applied across all rids.length entries. In practice the normal indexing path passes one RID per call (rids.length == 1), but the method signature implies multi-document support that does not work correctly when multiple RIDs have different document lengths. Either document the single-RID assumption or add an assertion to surface unexpected callers.


Performance Considerations

3. Two-pass posting scan for unbounded queries on common terms

In computeBM25Scores (no-candidate path), each token's posting list is scanned twice: once to count df, once to accumulate scores. For a high-frequency term on a large collection this doubles I/O. The PR acknowledges this with a TODO. Worth ensuring this is visible in user-facing docs - a direct get call on a common term with BM25 will take 2x the I/O compared to CLASSIC.

4. explainScoring scans each token's entire posting list

Acceptable for interactive EXPLAIN. Consider logging a warning when the token count is high, or documenting the scan cost in user-facing docs so operators know to expect it on large indexes.


Design / API

5. Breaking change: $score widened to float for CLASSIC indexes

Documented in the PR description and the doc file, and correctly pinned by a test. However, user code that casts $score to Integer or uses getProperty("$score", 0) (integer default) will get a ClassCastException at runtime. This should be called out explicitly in release notes - it is the one user-visible behavior change for existing CLASSIC indexes.

6. DEFAULT_FIELD = "content" collision now silently suppresses field boosts

A multi-property index containing a field literally named content will receive no field boost because boostFor(field) returns 1.0f for DEFAULT_FIELD. The limitation is documented in the class Javadoc. Consider also logging a WARNING at index-creation time when a boost is configured for a field named "content" on a multi-property index, since that boost will be silently ignored.

7. Dual score / floatScore fields on IndexCursorEntry

Both fields are public. Callers reading score after a BM25 result silently get a rounded value. Deprecating score in favor of floatScore (or at least documenting the lossy nature with @Deprecated) would prevent silent precision loss for downstream callers.


Minor / Style

8. transient volatile boolean staleChecked - transient is superfluous

FullTextIndexMetadata does not implement Serializable, so transient has no effect. A comment expressing the "don't persist this" intent would be clearer.

9. Test SQL built via string concatenation

In the test searchScores helpers, the query is a test-controlled constant so there is no injection risk. But parameterized queries would be safer and more idiomatic.

10. Documentation file location

The PR adds docs/4687-bm25-fulltext-scoring.md. Verify this fits the project's doc build pipeline - if main documentation lives in AsciiDoc or a separate repo, this file may not be discovered.


Compaction Fix

The fix in LSMTreeIndexCompacted.appendDuringCompaction is correct and well-explained:

if (writtenValues == 0 || (firstIteration && startedOnContinuation && writtenValues < values.length))

The key insight - that a key must not start on a continuation page it will then overflow - is clearly articulated. The firstIteration guard correctly limits the eager-flush to only the first attempt, letting normal overflow handling take over afterward. Well done.


Test Coverage

  • BM25ScorerTest: thorough pure math coverage (IDF formula, saturation, length normalization, zero-tf guard).
  • FullTextBM25Test: good end-to-end coverage of IDF ranking, length normalization, field boosts, caret boosts in AND/OR/NOT, CLASSIC fallback, rollback drift, parameter validation, restart persistence.
  • FullTextBM25CompactionTest: covers the compaction fix for both BM25 and CLASSIC paths with tiny page sizes. Good regression pinning for the CLASSIC case specifically.
  • @Tag("slow") applied correctly on multi-bucket and restart tests.

One gap: a test for explainScoring / EXPLAIN on an index with no documents (empty corpus) would exercise the df == 0 branch more directly.


Summary

Priority items:

  1. TOCTOU on staleChecked - concurrent first BM25 queries on a large collection can each trigger a full type scan; switch to AtomicBoolean.compareAndSet.
  2. $score type change - needs explicit mention in user-facing release notes (existing apps casting to Integer will get ClassCastException).
  3. content field name collision - log a warning at index creation when a boost for "content" on a multi-property index is silently ignored.
  4. countDocuments multi-RID assumption - document or assert the single-RID invariant.

Everything else is low priority or informational. The core design is solid.

🤖 Generated with Claude Code

…ing, empty-corpus test

- ensureCounters: claim the one-per-session staleness validation via AtomicBoolean
  compareAndSet, so concurrent first-queries across a type's shared bucket indexes
  cannot all run the live count + rescan (fixes the TOCTOU on the old read/write
  pair). Dropped the misleading `transient` (the class is not Serializable).
- Warn at index creation when a boost is configured for a field literally named
  "content" on a multi-property index (it collides with the query parser's default
  field and would be silently ignored).
- test: EXPLAIN on an empty corpus exercises the df==0 branch and must not fail.
…() multi-term FINE log, release notes

- LSMTreeIndexCompacted: note that writeEntryMultipleValues clears the scratch buffer at the start of its loop, so the fresh-page retry re-serializes from a clean buffer (the partial content is discarded, not appended) - makes the compaction-fix invariant explicit at the retry site
- FullTextIndexMetadata constructor: note it defaults to BM25 and that defaultBM25() is just a self-documenting alias
- getBM25: log at FINE for a multi-term direct get() (it does 2*T posting scans) so operators debugging a slow query can switch to SEARCH_INDEX
- RELEASE notes: add the REBUILD INDEX * WITH <setting> first-setting-dropped fix to the bug-fix list

Verified false alarm: collectingExclusion IS wired - set true in collectTermsForExclusion and checked in recordScoringToken, so MUST_NOT terms are correctly excluded from scoring tokens.

Acknowledged (no change): storeTermFrequency page-header byte (on-disk format change, out of scope; documented); multi-bucket BM25 already covered by bm25RankingHoldsAcrossMultipleBuckets (BUCKETS 4); the once-per-session drift INFO log fires at most once per session (not per query); comment density matches the heavily-commented storage layer and encodes design rationale.

Full-text + CONTAINSTEXT suites green (209).
@claude

claude Bot commented Jun 23, 2026

Copy link
Copy Markdown

Code Review: Native BM25 Full-Text Scoring (PR 4695) - see full review in following comment

@claude

claude Bot commented Jun 23, 2026

Copy link
Copy Markdown

Code Review for PR 4695 - see next comment for full review

@claude

claude Bot commented Jun 23, 2026

Copy link
Copy Markdown

Code Review: Native BM25 Full-Text Scoring (PR 4695)

This is a substantial, well-engineered feature addition. The design is carefully considered and thoroughly documented.

Overview

Adds Okapi BM25 relevance scoring to FULL_TEXT indexes as the new default; existing indexes keep CLASSIC scoring (zero behavior change on upgrade). Includes field boosts, query-time caret boosts, dollar-score exposure, EXPLAIN/PROFILE annotations, a persistence fix for analyzer config, and a pre-existing compaction bug fix.

Strengths

  • FullTextPostingRID extends DatabaseRID: BM25 per-posting stats (tf, docLength) ride through the entire existing RID-typed pipeline without signature changes. equals/hashCode inheritance from RID keeps all downstream code unchanged.
  • BM25Scorer isolation: pure math with no DB dependency makes it trivially unit-testable.
  • Compaction fix: the startedOnContinuation + firstIteration guards in LSMTreeIndexCompacted.appendDuringCompaction are surgical and correct. The INVARIANT comment on writeEntryMultipleValues makes the fix reasoning auditable.
  • Min-heap in buildScoredCursor: O(N log K) vs O(N log N) for top-K is the right optimization for ORDER BY score LIMIT k.
  • JMM ordering in addDocument/removeDocument: sumDocLength written before totalDocs (volatile-publish) is carefully annotated.

Tests are comprehensive: BM25ScorerTest (exact math, edge cases), FullTextBM25Test (ranking, boosts, CLASSIC fallback, restart persistence, EXPLAIN metadata), FullTextBM25CompactionTest (compaction regression), GraphDatabaseCheckerDiagnosticsTest (supernode fan-out collapse).

Issues

1. writeEntryValues - partial write before overflow check

In LSMTreeIndexAbstract.writeEntryValues, when storeTermFrequency is on, both the RID and the two varints are written to the scratch buffer before the overflow check. When the i-th entry causes overflow, 'return i' tells the caller 'i complete entries' but the scratch buffer holds i+1 entries worth of bytes. The compaction path re-serializes from scratch on overflow so this extra data is discarded and correctness is preserved. The pre-existing code had the same structure, so the BM25 varints add at most ~20 bytes of extra over-write - no bug, but worth a comment to make the invariant clear for future readers.

2. FullTextQueryExecutor stateful instance fields

The class now carries per-query mutable state (scoringTokens, collectingExclusion, tokensOnly, currentBoost). The Javadoc says a new executor is created per search, but resetState() exists specifically because the class can be reused. This dual-mode design should be clarified: either document that the class is not thread-safe and must not be reused across threads, or enforce single-use by making the state constructor-initialized only and removing resetState().

3. remove() docLen mismatch on field-null delta

In LSMTreeFullTextIndex.remove(), docLen is recomputed from the keys passed at remove time. If a field was non-null at index time but null at remove time (or if the analyzer changed), ftMetadata.removeDocument(docLen) under-decrements sumDocLength, drifting avgdl. This is acknowledged in the comment, but worth surfacing in the operational notes: any schema migration that changes an analyzer or nulls out a field before deletion should be followed by REBUILD INDEX ... WITH statsOnly = true.

4. Missing @tag(slow) candidates

Per project conventions, tests with noticeably long runtimes should be tagged @tag(slow). FullTextBM25Test includes restart-persistence and large corpus scenarios that may warrant checking.

5. Breaking change scope: IndexCursorEntry identity change

This is documented as an extension/plugin API change, but IndexCursorEntry is a public class. Any user code placing results from index.get() into a Set expecting score-differentiated dedup will silently see different behavior. The release note is clear, but consider flagging that plain Set callers (not just plugin authors) are affected.

Minor Observations

  • FullTextIndexMetadata duplicates BM25Scorer.DEFAULT_K1/DEFAULT_B constants to avoid a package dependency inversion. The defaultConstantsStayInSyncWithMetadata test makes this self-enforcing - good approach.
  • The writeToJSON epsilon (1e-6f) for comparing k1/b to defaults is correct practice for float round-trip through JSON. A one-line comment would prevent future simplification to !=.
  • trackMissingReference cap uses maxWarnings as maxTracked. The parameter semantics are 'max distinct tracked RIDs' not 'max warnings' - a local constant would clarify intent.
  • The lastExpansionWarnMs static throttle is cross-index: a pathological wildcard on index A suppresses the warning for index B for 60 s. Acceptable trade-off, worth noting.

Summary

The main actionable items are: (1) clarify FullTextQueryExecutor thread-safety contract, (2) document the writeEntryValues scratch-buffer over-write invariant, (3) add a docLen-mismatch note to the operational docs.

The compaction bug fix alone is worth the merge. This is a high-quality feature addition.

Generated with Claude Code

@codacy-production

codacy-production Bot commented Jun 23, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 37 complexity

Metric Results
Complexity 37

View in Codacy

🟢 Coverage 89.69% diff coverage · -7.31% coverage variation

Metric Results
Coverage variation -7.31% coverage variation
Diff coverage 89.69% diff coverage

View coverage diff in Codacy

Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (e8fee25) 130891 97215 74.27%
Head commit (95586a1) 163306 (+32415) 109355 (+12140) 66.96% (-7.31%)

Coverage variation is the difference between the coverage for the head and common ancestor commits of the pull request branch: <coverage of head commit> - <coverage of common ancestor commit>

Diff coverage details
Coverable lines Covered lines Diff coverage
Pull request (#4695) 708 635 89.69%

Diff coverage is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified: <covered lines added or modified>/<coverable lines added or modified> * 100%

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

lvca added 2 commits June 23, 2026 16:43
…overflow/throttle comments, operational notes

- FullTextQueryExecutor: correct the class Javadoc - it is NOT thread-safe (it carries per-query mutable state); create a new instance per search and do not share across threads. resetState() is a defensive guard against sequential reuse on one thread, not support for concurrent reuse. (Replaces the incorrect 'This class is thread-safe' line.)
- writeEntryValues: document that the overflow check runs after the whole entry (RID + tf/docLength) is buffered, so an entry is never split; the trailing over-written entry on overflow is discarded because callers re-serialize the kept subset from a cleared buffer before committing
- lastExpansionWarnMs: note the throttle is JVM-wide / shared across indexes (a wildcard on one index can suppress the warning for another for the window) - acceptable for a diagnostic
- docs/4687: operational note that deleting after an analyzer change or field-nulling can drift avgdl; repair with REBUILD INDEX ... statsOnly
- RELEASE notes: broaden the IndexCursorEntry note to any Set/map-key caller (e.g. Index.get() results), not just plugin authors

Verified (no change): collectingExclusion IS wired (set in collectTermsForExclusion, checked in recordScoringToken); @tag(slow) coverage is appropriate (the heavy restart + multi-bucket tests are tagged; the rest run sub-second); writeToJSON already carries the epsilon comment; defaultConstantsStayInSyncWithMetadata self-enforces the duplicated defaults. The trackMissingReference/maxWarnings note is in non-BM25 code (CHECK DATABASE), out of scope for this PR.

Full-text + function suites green (204).
@claude

claude Bot commented Jun 23, 2026

Copy link
Copy Markdown

Code Review: feat/bm25-fulltext-scoring (#4695)

This is a substantial, well-designed PR. The BM25 implementation is mathematically sound, the compaction bug fix is important and correctly reasoned, and the test coverage is excellent. Below are my findings, grouped by severity.


Architecture & Design

FullTextPostingRID as a thin wrapper over DatabaseRID is a clean choice. It threads per-posting statistics (tf, docLength) through the entire existing RID pipeline without signature changes - a good extension point. Marking the class final is correct since downstream instanceof FullTextPostingRID checks rely on exact type identity.

storeTermFrequency as a volatile flag derived from the schema (not persisted in the page header) is a pragmatic approach but creates a fragile implicit contract: the schema JSON and the on-disk page files must remain in sync. The Javadoc documents this clearly, which is appreciated, but if a user manually edits schema.json (e.g., changing "similarity":"BM25" to "CLASSIC" on an index that already has tf/docLength bytes on disk), the index will silently misparse its pages. Consider adding a guard in fromJSON or readConfiguration that detects this mismatch (e.g., checking if existing page data has the expected byte pattern) or at least documenting this risk in the rebuild instructions.

IndexCursorEntry.equals()/hashCode() change - removing score from identity is correct for deduplication (the Javadoc explains why), but this is a silent behavioral change for any external code using IndexCursorEntry in a Set/Map. Worth noting in the release notes since this is a public class.


Correctness Issues

Medium: Double-negation in FullTextQueryExecutor.collectTermsForExclusion

When the code recurses into the children of a MUST_NOT BooleanQuery, it adds all of them to the exclusion set unconditionally, regardless of their own occur. A query like NOT (A AND NOT B) - which logically means "B is required" - would incorrectly exclude B. This is an edge case but it's semantically wrong.

// Current (in collectTermsForExclusion):
for (final BooleanClause clause : ((BooleanQuery) query).clauses()) {
    collectTermsForExclusion(clause.query(), excluded);  // ignores clause.getOccur()
}

Medium: O(N) full index scan for pure-NOT queries

collectAllIndexedRids() materializes the entire index into a HashMap before subtracting the exclusion set. For a query like -the on a large corpus this could be prohibitively expensive in both time and memory. Even a warning log when the result set exceeds some threshold would help operators notice this pattern.

Low: FullTextIndexMetadata.fromJSON on a recycled instance

Scalar fields bm25K1 and bm25B are not reset before parsing:

setBm25K1(metadata.getFloat("bm25_k1", bm25K1));  // uses stale value as default if key absent
setBm25B(metadata.getFloat("bm25_b", bm25B));

Per-field maps are cleared (fieldAnalyzers.clear(), fieldBoosts.clear()) but the scalars are not. If fromJSON is ever called on a recycled instance (e.g., during a hot-reload), stale k1/b values would silently carry forward. Low risk today since fromJSON appears to be called on freshly constructed instances, but fragile.

Low: Phrase ordering not enforced in collectPhraseMatches

The comment honestly acknowledges this: "We can't verify word order without position indexing, so we just require all terms." This means "java database" and "database java" return identical results. The limitation is acceptable given Lucene's tokenization doesn't store positions in this index structure, but it should be noted prominently in the user-facing docs (it's not mentioned in docs/4687-bm25-fulltext-scoring.md).


Code Quality

storeTermFrequency propagation through splitIndex

The flag is correctly propagated when creating a new compacted index and when splitting. The setStoreTermFrequency override in LSMTreeIndexMutable also propagates to subIndex, which is the right approach. One edge case: if splitIndex is called on the LSMTreeIndex wrapper level after setStoreTermFrequency has been called, the new mutable index inherits the flag correctly. This looks solid.

Inconsistent instanceof pattern matching in FullTextQueryExecutor

Modern pattern variables (instanceof BoostQuery bq) are used on some branches but old-style casts ((BooleanQuery) query) on others. Not a bug, but inconsistent style within a single class.

IndexCursorEntry has two public score fields

Having both score (int) and floatScore (float) as public fields could confuse callers. The class Javadoc explains the relationship, but a single floatScore with a deprecated score (or an accessor method) would be cleaner. At minimum, consider whether score (the rounded int) needs to be public at all, or whether callers should always use floatScore.

MAX_EXPLAIN_TERMS = 64 silently truncates

When an EXPLAIN query has more than 64 terms, the scoring breakdown is truncated. This is reasonable, but there's no indication in the output that truncation occurred. A trailing ... (N more terms omitted) note would help users debugging complex queries.


Performance

HashMap<RID, AtomicInteger> in the CLASSIC path

The existing code uses AtomicInteger per document for score accumulation. For large result sets this creates significant allocation pressure. Since scoring is single-threaded per query, a plain HashMap<RID, Integer> with merge would be more GC-friendly. (This exists in the pre-PR code, but is worth noting for a future cleanup.)

recordScoringToken double map lookup

if (scoringTokens.containsKey(storedKey))
    scoringTokens.merge(storedKey, ...);

This does two lookups. A single compute or merge call would suffice.


Serialization Safety

The invariant in writeEntryValues that "the trailing over-written bytes are harmless because callers re-serialize from scratch" is documented in a comment and appears correct - but it is a subtle invariant that future refactors could inadvertently break. The Javadoc on writeEntryMultipleValues calls this out, which is good. Consider adding an assertion (or at least a test) that verifies the buffer is reset at the start of writeEntryMultipleValues.


Test Coverage

The test suite is comprehensive and well-structured:

  • BM25ScorerTest - exact math verification (good)
  • FullTextBM25Test - IDF ranking, length normalization, field boosts, caret syntax, CLASSIC fallback, restart persistence, EXPLAIN metadata
  • FullTextBM25CompactionTest - postings surviving compaction at tiny page sizes

One gap: no test for the pure-NOT query full-scan path (collectAllIndexedRids). A test with a negation-only query (-commonword) on a non-trivial corpus would pin the behavior and also expose the O(N) concern above.

Another gap: no test for a MUST_NOT clause wrapping a BooleanQuery with mixed occurrences (the double-negation bug described above). Even a test that documents the current behavior (so it cannot regress unintentionally) would be useful.


Minor

  • The RELEASE notes reference REBUILD INDEX ... {statsOnly: true} but the actual SQL syntax change in SQLASTBuilder / RebuildIndexStatement is not obvious from a quick scan. A test that exercises this exact syntax via SQL would provide confidence it was wired up correctly end-to-end.
  • FullTextPostingRID would benefit from a toString() override that includes tf and docLength to make debugging serialization issues easier.
  • The docs file (docs/4687-bm25-fulltext-scoring.md) is thorough and well-written. Suggest adding a callout that "phrase query" ordering is not enforced (see correctness issue above).

Summary

Severity Area Issue
Medium FullTextQueryExecutor Double-negation in NOT (A AND NOT B) misbehaves
Medium FullTextQueryExecutor Pure-NOT queries do O(N) full index scan
Low FullTextIndexMetadata.fromJSON k1/b not reset before parse on recycled instances
Low FullTextQueryExecutor Phrase ordering not enforced, not documented in user-facing docs
Low IndexCursorEntry Dual score/floatScore fields - potential caller confusion
Low FullTextQueryExecutor MAX_EXPLAIN_TERMS truncation is silent
Info LSMTreeIndexAbstract storeTermFrequency not in page header - schema/page sync must be maintained externally
Info FullTextPostingRID Missing toString() for debug visibility

Overall the implementation is solid. The compaction bug fix is particularly important and well-handled. Addressing the medium severity issues (especially the double-negation case) before merge would be my main recommendation.

lvca added 3 commits June 23, 2026 17:17
…egative warning, fromJSON reset, phrase docs

Correctness:
- collectTermsForExclusion: skip nested MUST_NOT clauses when collecting exclusion terms. A double negation like 'java -(database -tutorial)' (= java AND (NOT database OR tutorial)) previously excluded 'tutorial' documents too, wrongly returning empty; now the double-negated term is not excluded. New regression test
- FullTextIndexMetadata.fromJSON: default bm25_k1/bm25_b to the DEFAULT_BM25_* constants (not the current field values) so a key absent from the JSON resets to the default instead of carrying a stale value forward on a recycled instance

Observability:
- collectAllIndexedRids: a pure-negative query (only MUST_NOT) materializes the whole index to form the complement; log a throttled WARNING (60s, JVM-wide) when the universe exceeds PURE_NEGATIVE_WARN_THRESHOLD so operators notice the O(index) cost and add a positive clause

Docs:
- docs/4687: document that phrase queries are unordered (all-terms AND, no positional index) - '"java database"' and '"database java"' match the same documents

Note: full nested-boolean negation (representing the positive contribution of a double-negated term) would require general boolean evaluation and is out of scope; the fix at least stops wrongly excluding such terms.

Full-text + schema suites green (216; FullTextBM25Test 28).
…PLAIN omitted-term count

- FullTextQueryExecutor: convert the remaining old-style instanceof+cast pairs in collectMatches/collectTermsForExclusion to Java pattern variables (instanceof final X y), matching the BoostQuery branch's style - consistent within the class
- explainScoring: when the term breakdown is truncated at MAX_EXPLAIN_TERMS, also report termsOmitted and termsShown so a user debugging a complex query knows the view is partial (not just a bare termsTruncated flag). Test asserts termsOmitted=16 for an 80-term expansion

Acknowledged (no change): storeTermFrequency split/compaction propagation confirmed solid; IndexCursorEntry keeps both score (int, used by all integer-scored indexes) and floatScore (full precision) public - a prior review chose Javadoc over @deprecated and the field is load-bearing across the index layer, so removing/deprecating it is out of proportion; the Javadoc already directs callers to floatScore when precision matters.

Full-text + function suites green (205).
…osting toString, pure-NOT test

- recordScoringToken: replace containsKey()+merge() (two hash lookups) with a single get() then a conditional put(); the cap still drops only new tokens once the limit is reached, and an existing token whose boost does not increase costs just one lookup
- FullTextPostingRID: add toString() including tf/docLength so serialization/scoring issues are easy to spot in logs/debuggers
- test: pureNegativeQueryReturnsComplement pins the pure-negative full-index-scan path (collectAllIndexedRids) - '-java' returns exactly the documents without 'java'

Acknowledged (no change): the CLASSIC matching path's Map<RID,AtomicInteger> predates this PR - AtomicInteger is one alloc per doc with mutable increments, so the suggested HashMap<RID,Integer>+merge would actually box on every increment; a full conversion to primitive cells across the executor is a broad future cleanup, while the BM25 path I added already uses double[]. The writeEntryMultipleValues buffer-clear invariant is regression-guarded by FullTextBM25CompactionTest (removing the clear corrupts postings -> test fails) plus the Javadoc. The double-negation test (batch 20), the statsOnly SQL test (batch 4), and the phrase-ordering docs note (batch 20) already exist.

Full-text + function suites green (206; FullTextBM25Test 29).
@claude

claude Bot commented Jun 23, 2026

Copy link
Copy Markdown

Code Review - BM25 Full-Text Scoring

This is a substantial, well-structured addition. The core BM25 math is sound, the backward compatibility is carefully thought through, and the PR description/docs are exceptional. Below are specific observations organized by priority.


Breaking Changes - Needs Attention Before Merging

1. $score type change (Float vs Integer) in CLASSIC indexes

The docs mention this but it's a runtime breaking change for any user doing (Integer) result.getProperty("$score") on CLASSIC indexes. Consider whether a deprecation warning in the logs on the first use of a CLASSIC index would help users discover the change. At minimum, the release notes warning is good - confirm those notes end up in the official docs site too.

2. IndexCursorEntry.equals()/hashCode() identity change

Removing score from identity is the right call for BM25 deduplication, but this is a silent behavioral change for third-party code using Index.get() results in a Set. The release note is good, but this should be called out prominently in the migration guide since it can cause subtle bugs (duplicate suppressions disappearing silently).

3. CLASSIC cursor order reversal (ascending → descending)

Any code doing results.get(0) assuming lowest score first breaks silently. Again, documented - just flagging this is the riskiest of the three because callers won't get an exception, they get wrong results.


Functional / Correctness Concerns

Corpus counter drift from rolled-back transactions

private void countDocuments(final int numDocs, final int docLen) {

Counters are incremented at index time, before commit. This is acknowledged and the session-validation repair mitigates it, but the asymmetry (rollbacks inflate counters, never deflate) means BM25 scores trend downward over time in workloads with many rollbacks. For write-heavy workloads this could matter. Consider logging a WARNING when the session-validation check finds a drift above some threshold (e.g. >10%) so operators know to run REBUILD INDEX ... WITH statsOnly = true. Currently the repair is silent.

Schema/index file divergence is a silent misparse

If storeTermFrequency is derived from the schema but index files are restored independently, bytes are silently misread. There's no in-page flag or checksum that would catch this. The docs warn about it, which is good, but consider adding a sanity check during index open: compare the first few RIDs against the expected format and log a loud ERROR if they look wrong (e.g. a FullTextPostingRID with impossible tf > docLength, or a regular RID where a BM25 index is expected).

countDocuments() is called inside putWithStats() before the transaction commits

This is a pre-commit increment. If the caller uses the index for a BM25 get() in the same transaction, ensureCounters() will see a higher totalDocs than the committed live count. This is a rare edge case but it means BM25 scores within an open transaction can be slightly lower than post-commit. Worth documenting or adding a brief note in the method.

Token expansion cap: cap is per-query, not per-subclause

The MAX_EXPANDED_SCORING_TERMS = 4096 cap is checked per query executor. For a query like (a* OR b* OR c*) where each wildcard expands near the cap independently, the actual number of scoring tokens can far exceed 4096 before the warning fires. Suggest checking the cap against the running total across the entire query, not just individual expansions.


Performance Concerns

Direct get() two-pass scan logged only at FINE level

// No candidate set → two passes (one for df, one to accumulate)

Production operators won't see this unless they enable debug logging. Multi-term BM25 queries via get() pay 2×T I/O. Suggest logging this at INFO level (or at minimum include it in EXPLAIN output) so operators know when they're on the two-pass path and can optimize by switching to SEARCH_INDEX queries.

ensureCounters() under concurrent load

The first BM25 query on a cold index does a full type scan under a lock on ftMetadata. For large collections with many concurrent queries, this serializes all incoming BM25 queries until the scan completes. The docs suggest pre-warming with REBUILD INDEX ... WITH statsOnly = true which is good, but users who upgrade without reading the release notes will hit this. Consider a startup hook (after database open) to pre-warm BM25 counters in the background.


Minor Code Quality Issues

Dual score/floatScore fields in IndexCursorEntry is awkward

Having both score (int, lossy) and floatScore (float, precise) as public fields on the same object is confusing:

  • score = Math.round(floatScore) - this rounded field is now the "official" one but less accurate
  • Code could read entry.score and silently get a rounded value

Consider deprecating score and making floatScore the primary field, or removing score if no external code uses it directly. If backward compat prevents this, at least add a @Deprecated annotation on score to steer new code to floatScore.

DEFAULT_FIELD = "__arcadedb_default_field__"

This sentinel string is long but clear. One concern: if a user names a real field __arcadedb_default_field__ (unlikely, but possible), it would collide. Consider adding a check in schema validation that prevents field names starting with __arcadedb_.

resolveTotalDocs() naming

The method name suggests it "resolves" something but it's really just returning totalDocs.get() with a fallback. getValidatedTotalDocs() or just reading the field directly where used would be clearer.


Test Coverage Observations

The test surface is comprehensive. A few gaps worth noting:

  1. No test for schema/index divergence - the disaster-recovery scenario where index files are restored without schema (or vice versa). This is the silent misparse risk mentioned above.

  2. No test for corpus counter drift after rollbacks - would verify that session-validation correctly detects and repairs drift, not just that repair works when manually triggered.

  3. No test for per-bucket IDF discrepancy - a multi-bucket type where the same document scores differently across buckets to confirm the documented limitation is understood and bounded.

  4. BM25ScorerTest - confirm it tests edge cases: df = 0 (term not in corpus), df = totalDocs (term in every document, IDF approaches 0), tf = 0, avgdl = 0.


What's Done Well

  • BM25Scorer.java is clean, stateless, and separately unit-testable. Excellent design.
  • FullTextPostingRID extending DatabaseRID with inherited equals()/hashCode() is an elegant way to thread tf/docLength through the existing RID pipeline with zero signature changes.
  • Compaction bug fix is independent and important - the bug description (continuation pages becoming unreachable) is clear and FullTextBM25CompactionTest at tiny page sizes is exactly the right regression test.
  • EXPLAIN/PROFILE integration with per-term df/idf/boost is genuinely useful for operators debugging relevance issues.
  • Metadata persistence fix (analyzer config + BM25 settings surviving restart) addresses a real correctness bug and is well-described.
  • Documentation quality (feature doc + release notes + inline comments) is well above average. The known-limitations section in the feature doc is especially good.

Summary

This PR is ready for merge with the critical items above addressed:

  • The three breaking changes are documented; confirm migration doc is accessible.
  • Add a threshold-based WARNING for corpus counter drift so operators know to repair.
  • Consider elevating the two-pass get() log from FINE to INFO.
  • Evaluate deprecating IndexCursorEntry.score in favor of floatScore.

The compaction fix alone is worth merging even if BM25 were reverted, so if timeline is tight, consider whether that fix can ship independently.

…note, df=0 test

- ensureCounters: escalate the counter-drift log to WARNING (with a 'consider REBUILD INDEX ... statsOnly' hint) when the divergence exceeds 10% of the live count; small drift still self-heals quietly at INFO
- countDocuments: document that the pre-commit increment means a BM25 get() later in the SAME transaction sees the just-inserted (uncommitted) document in avgDocLength - marginal, resolves at commit, not a correctness issue
- BM25ScorerTest: add the df=0 edge case (term absent from corpus -> highest finite idf, above df=1)

Verified false alarm: the MAX_EXPANDED_SCORING_TERMS cap IS query-wide (checked against the single shared scoringTokens map across all subclauses), not per-subclause.

Acknowledged (no change, with reasoning):
- schema/index divergence sanity check: a tf<=docLength constructor guard looked clean but compaction may sum tf for duplicate (key,RID) postings, so a hard throw could false-positive on legitimate data; a heuristic open-time check gives false confidence. The robust fix is an on-disk format byte (out of scope); divergence stays documented (disaster-recovery note)
- two-pass get() at FINE: INFO would spam every multi-term get(); the path has no EXPLAIN (EXPLAIN is the candidate-based SEARCH_INDEX path). FINE is right
- background pre-warm on open: a DB-lifecycle/HA change out of proportion; manual REBUILD INDEX ... statsOnly is documented
- IndexCursorEntry.score @deprecated: inappropriate - score is the CORRECT field for every integer-scored index (LSM/hash/etc.), so deprecating would wrongly flag all those legitimate uses; the Javadoc already steers float-precision callers to floatScore (consistent with the earlier review decision)
- reserved __arcadedb_ field-name ban: the targeted exact-sentinel rejection at FT index creation already prevents the collision; a schema-wide prefix ban is a separate broader policy
- resolveTotalDocs naming kept (Javadoc is clear; 'validated' would mislead - it does not validate)

Full-text + function suites green (206; BM25ScorerTest 12).
@claude

claude Bot commented Jun 23, 2026

Copy link
Copy Markdown

Code Review - BM25 Full-Text Scoring

Overall this is a solid, well-thought-out feature. The BM25 math is correct, the persistence fix is needed, and the compaction bug fix is a genuine correctness improvement. Below are specific observations and a few concerns worth discussing before merge.


BM25 Mathematics - Correct

The IDF formula (ln((N - df + 0.5) / (df + 0.5) + 1)) matches the Lucene/Robertson variant. The TF formula is textbook Okapi BM25. The safeAvgdl = max(avgdl, 1.0) guard prevents divide-by-zero on an empty corpus, tested by zeroAverageDocLengthIsGuardedAndStaysFinite. No issues.

The BM25ScorerTest is thorough and covers edge cases including df=0, df=N, saturation, length normalization, and b=0.


Notable Concern: Compaction Test May Not Exercise the Split

FullTextBM25CompactionTest.postingsAndTermFrequencySurviveCompaction uses 601 documents and relies on the posting list being large enough to span multiple compacted pages (which triggers the bug being fixed). There is no assertion that the multi-page split was actually triggered - if 601 RIDs happen to fit on a single compacted page at default page size, the test would pass without testing the fix at all.

Suggestion: Either assert a minimum document count that provably overflows a single 4096-byte page (calculating: 601 RIDs at ~14 bytes each (tf varint + 3-byte RID + extras) ~ 8,400 bytes, which should overflow one page), or reduce page size to a small value (e.g., 512 bytes) so the split is guaranteed on even a small dataset. A log or counter confirming the split path was hit would make the test intent clear.


Notable Gap in Test Coverage: REBUILD INDEX * Offset Bug

The firstSettingKeyIndex fix in SQLASTBuilder.java corrects an off-by-one when the index name is *. The regression test rebuildIndexStatsOnlyRepairsCountersViaSQL only covers REBUILD INDEX ... WITH statsOnly = true (the named form). There is no test asserting that a positional setting after * is actually parsed correctly - for example:

REBUILD INDEX * WITH batchSize = 1000

would silently use the default batchSize before the fix. A test pinning this specific case (wildcard + non-first setting) would close the gap.


FullTextPostingRID - GC Pressure Tradeoff

Each posting read creates a FullTextPostingRID heap object when storeTermFrequency=true. For high-df terms (e.g., 10,000 matches), this generates significant object churn. The design is a deliberate correctness-for-memory trade-off, and the comments acknowledge it - but there are no GC measurements or benchmarks included.

For the initial implementation this is acceptable. A follow-up issue to track a possible flat-array approach (storing tf/docLength in parallel arrays instead of per-object) would be valuable given the project's GC-pressure design goals.


Thread Safety - Minor Observation

FullTextQueryExecutor is correctly documented as not thread-safe (one instance per query). One thing to note: lastExpansionWarnMs and lastPureNegativeWarnMs are static AtomicLong - JVM-wide, shared across all indexes. The CAS-based throttle is correct, but one index with pathological queries can suppress warnings from another for 60s. This is acceptable behavior but worth a comment explaining the intentional scope.


$score Null Safety - Minor

In SQLFunctionSearchIndex, when matches=true:

iContext.setVariable("$score", allResults.get(rid));

If allResults.get(rid) returns null (race or future map change), $score is set to null. Since matches is set via containsKey just before, this cannot happen in the current single-threaded SQL execution path. However, allResults.getOrDefault(rid, 0f) would make the defensive intent explicit and remove any future maintenance ambiguity.


EXPLAIN Cost on Wildcard Queries

EXPLAIN with a wildcard term (a*) walks the posting index to discover matching token keys. On a large full-text index, EXPLAIN SELECT ... WHERE SEARCH_INDEX(..., 'a*') = true could be slow because term expansion is synchronous and O(matching-keys). The MAX_EXPANDED_SCORING_TERMS=4096 cap limits the result set but not the scan itself. For operators who use PROFILE in monitoring loops this could be a surprise. A note in the docs (or a cap on scan time/keys) would help.


Backward Compatibility Notes (correctly documented)

All breaking changes are documented, but worth a summary for reviewers:

  1. $score type changes from Integer to Float - Java code casting to Integer breaks
  2. CLASSIC result sort order changes (ascending to descending)
  3. IndexCursorEntry.equals/hashCode no longer includes score - deduplication in Set/Map changes
  4. New CREATE INDEX ... FULL_TEXT defaults to BM25 - new indexes and existing indexes have different data formats (correctly gated by persisted similarity)

One unaddressed item: the schema file now writes additional keys (similarity, ft_totalDocs, etc.). If schema parsing in older versions is strict (non-lenient), a downgrade after upgrading could fail at schema load. It may be worth confirming or documenting that schema parsing ignores unknown keys.


Minor Style Notes

  • writeToJSON uses Math.abs(bm25K1 - DEFAULT_BM25_K1) > 1e-6f epsilon comparison. Due to float JSON round-trip imprecision, an explicitly-set bm25_k1 = 1.2 might still differ from DEFAULT_BM25_K1 by an ULP, causing unnecessary emission. Not a bug, just minor noise.
  • The storedKey[] single-element array reuse in computeBM25Scores is correct but a maintenance hazard if the cursor is ever made lazy. A brief comment noting the synchronous-consumption requirement is present - good.
  • putWithStats for multi-property indexes stores each token twice (prefixed + unprefixed). This roughly doubles posting storage vs CLASSIC for multi-field indexes. Worth a docs mention since it affects capacity planning.

Summary

Area Status
BM25 math Correct
Persistence fix Correct
Compaction fix Correct, but test coverage for multi-page split is uncertain
REBUILD INDEX * parser fix Correct, regression test gap
Thread safety Sound
Backward compatibility Documented
Test coverage Good overall, two specific gaps noted above

The two items I'd want addressed before merge are the compaction test certainty and the REBUILD INDEX * regression test. The rest are suggestions or notes for follow-up issues.

…REBUILD INDEX * setting parse

- FullTextBM25CompactionTest: drop the page size from 4096 to 1024 so the multi-page posting-list split is GUARANTEED, not incidental - 600 RIDs at ~6-8 bytes each (~3.6-4.8 KB) provably overflow a 1024-byte page several times, which is the spanning-multiple-compacted-pages condition the fix targets. Updated the class doc + sizing math comment (applies to both the BM25 and CLASSIC compaction tests)
- RebuildIndexStatementTestParserTest: add wildcardCapturesFirstSettingKey - parse 'REBUILD INDEX * WITH batchSize = 1000' and assert the setting actually reaches the AST settings map (checkRightSyntax only proves the grammar accepts it). Pins the firstSettingKeyIndex offset fix that previously dropped the first WITH setting on the * form; also sanity-checks the named form
- SQLFunctionSearchIndex: set $score via allResults.getOrDefault(rid, 0f) so it stays non-null even if the entry vanished between containsKey and get (cannot happen on today's single-threaded SQL path; makes the defensive intent explicit)
- docs/4687: note that EXPLAIN/PROFILE of a wildcard term walks the term index synchronously (the breakdown is capped but the expansion scan is not) - avoid broad-wildcard PROFILE in tight monitoring loops

Verified already-present: both expansion/pure-negative warning throttles already carry the JVM-wide/cross-index scope comment.

Full-text + function + parser suites green (208).
@claude

claude Bot commented Jun 24, 2026

Copy link
Copy Markdown

Code Review - Native BM25 Full-Text Scoring (#4687)

This is a substantial, well-engineered PR. The BM25 implementation is correct and the design decisions are thoughtful and well-documented.


Overview

The PR adds native Okapi BM25 ranking to FULL_TEXT indexes: BM25 scoring with configurable k1/b and per-field boosts, query-time caret boosts via Lucene parser, $score exposure in SQL results, EXPLAIN/PROFILE integration, a metadata persistence fix for custom analyzers, and a compaction bug fix.


Strengths

Excellent design documentation. The inline comments explaining design decisions (per-bucket N/df vs. type-wide avgdl, why the no-candidate path does two posting passes, the JMM happens-before reasoning in addDocument/removeDocument) are exactly the kind of documentation that prevents future regressions.

Backward compatibility is handled correctly. Existing indexes keep CLASSIC scoring on upgrade. The storeTermFrequency flag makes the storage format change entirely opt-in and the deserialization gate is clean.

FullTextPostingRID design is elegant. Extending DatabaseRID so the whole pipeline stays RID-typed (with equals/hashCode keyed on bucket+offset only) is a minimal-footprint approach. The constructor guard for negative tf/docLength is good.

Compaction fix is correct. The startedOnContinuation check in LSMTreeIndexCompacted.appendDuringCompaction correctly detects when a key would split across a shared page and forces a fresh page it fully owns.

Thread-safety is carefully considered. The volatile on storeTermFrequency and countersValid, AtomicLong for corpus counters, ConcurrentHashMap for field maps, and the double-checked lock in ensureCounters are all correct. The write ordering in addDocument (sumDocLength before totalDocs) to avoid a deflated-avgdl torn read is a subtle but correct JMM insight.


Issues and Suggestions

1. IndexCursorEntry.equals/hashCode - Breaking Change Needs Guidance

The change removes score from equals/hashCode (documented as breaking change #3). However, the public int score field still exists. Code that used it to differentiate entries in a Set will now silently deduplicate on (record, keys) only. Consider deprecating int score and guiding callers toward floatScore. Having two public score fields - one excluded from identity - is a trap for external callers.

2. Cold-Start Full Scan Holds a Lock on Shared Metadata

In ensureCounters:

synchronized (ftMetadata) {
    if (!ftMetadata.isCountersValid())
        computeCorpusCounters(false); // full type scan
}

This runs on the first BM25 query path. On a large collection, this will block all concurrent queries on that type for the duration of the scan. The docs mention this as a known limitation and recommend REBUILD INDEX ... WITH statsOnly = true as a pre-warm step, but that advice is easy to miss. Consider logging a WARNING at index-open time when counters are invalid (not just when the scan actually runs) so operators know to pre-warm before serving traffic.

3. recomputeStatistics Throws an Unclear Exception for CLASSIC Indexes

Running REBUILD INDEX myClassicIndex WITH statsOnly = true throws "has no recomputable statistics (only BM25 full-text indexes do)". A CLASSIC full-text index is a valid full-text index - the error should say "only BM25 full-text indexes have corpus statistics; switch to BM25 or omit statsOnly."

4. Overflow Check in writeEntryValues Fires After writeTermFrequency

serializer.serializeValue(database, buffer, valueType, values[i]);
if (storeTermFrequency)
    writeTermFrequency(buffer, values[i]);
if (buffer.size() > availableSpaceInPage)
    return i;  // but buffer already has i+1 entries' bytes

The comment says callers re-serialize from scratch on overflow so the trailing bytes are discarded. Please verify that this invariant holds in all callers (especially the compaction path) - a regression here would silently corrupt postings for any token whose value list spans a page boundary.

5. DEFAULT_FIELD Sentinel Guard Missing on Schema Reload Path

The property-name collision guard against __arcadedb_default_field__ is only in LSMTreeFullTextIndex.create() (builder path). A schema file edited manually or restored with a property of that name would load without error and score incorrectly. The same guard should be added in LocalSchema.readConfiguration after ftMeta.fromJSON(indexJSON).

6. trackMissingReference Uses Two Map Lookups

if (missingReferences.containsKey(target))
    missingReferences.merge(target, 1L, Long::sum);
else if (missingReferences.size() < maxTracked) {
    missingReferences.put(target, 1L);

This does containsKey + merge/put - two lookups when a single compute would suffice. Minor but inconsistent with the project's performance focus.


Minor Notes

  • The release notes file (docs/RELEASE-27.7.1.md) uses emoji; the project CLAUDE.md says to avoid them unless explicitly requested.
  • The 100-entry cap in formatTopMissingReferences is a magic number; extracting it as a named constant would make it tunable and self-documenting.

Test Coverage

Solid overall:

  • BM25ScorerTest - unit-tests the formula in isolation (correct TDD approach)
  • FullTextBM25Test - integration tests for IDF ranking, length norm, boosts, CLASSIC fallback, restart persistence, EXPLAIN metadata
  • FullTextBM25CompactionTest - targeted regression for the compaction bug at tiny page sizes
  • GraphDatabaseCheckerDiagnosticsTest - covers the new missing-reference aggregation

One gap: no test for REBUILD INDEX * WITH statsOnly = true on a mixed set of indexes (BM25, CLASSIC, non-full-text). The wildcard path correctly filters to TypeIndexes, but a test would prevent a regression if that filter were accidentally removed.


Summary

Core correctness (BM25 formula, storage format, compaction fix, metadata persistence) is solid. Main concerns:

  1. Cold-start scan blocks concurrent queries - log a WARNING at index-open time when counters are invalid so operators know to pre-warm.
  2. recomputeStatistics error message is misleading for CLASSIC indexes - clarify that statsOnly requires BM25.
  3. IndexCursorEntry identity change is documented, but the public int score field alongside float floatScore is a trap for callers - deprecate the int field.

All three are manageable. The PR is otherwise well-ready for merge.

… reserved-name guard, clearer errors

- LSMTreeFullTextIndex: warn at OPEN time (not only when the first query triggers it) when a BM25 index loads with no valid corpus counters, so operators can pre-warm with REBUILD INDEX ... statsOnly before serving traffic
- centralize the reserved-property-name check (collision with the parser default-field sentinel) into a public static checkReservedPropertyNames(); call it from BOTH the creation factory and the schema-reload path in LocalSchema, so a hand-edited/restored schema cannot reintroduce the collision undetected
- RebuildIndexStatement: clearer error for REBUILD INDEX <classic> WITH statsOnly - 'only BM25 full-text indexes keep corpus statistics; switch to BM25, or omit statsOnly to do a full rebuild'
- docs/RELEASE-27.7.1.md: drop the section-header emoji

Acknowledged (no change, with reasoning):
- IndexCursorEntry.score @deprecated: still inappropriate - score is the correct field for every integer-scored index, so deprecating would wrongly flag all those legitimate uses; the breaking change is in the release notes + class Javadoc, which steer float-precision callers to floatScore
- writeEntryValues overflow invariant re-verified: the only callers go through writeEntryMultipleValues, which clears the scratch buffer at the start of its loop and re-serializes the kept subset before committing - so trailing over-written bytes are always discarded (also regression-guarded by FullTextBM25CompactionTest at 1024-byte pages)
- trackMissingReference double lookup / formatTopMissingReferences magic number: in CHECK DATABASE code (PR #4653), not part of this BM25 PR - out of scope

Full-text + schema + parser suites green (494).
@claude

claude Bot commented Jun 24, 2026

Copy link
Copy Markdown

Code Review - PR #4695: Native BM25 Full-Text Scoring

Overall this is a well-structured and substantial feature. The algorithm is correct, the storage design is clean, and the test suite is comprehensive. The style is consistent with the project conventions (AssertJ, JSONObject, no System.out, final keywords). Here are findings organized by severity.


Major

1. remove() ignores multi-RID count for corpus counter updates (LSMTreeFullTextIndex)

put() correctly calls ftMetadata.addDocument(docLen) once per RID in the passed array. The remove() path calls ftMetadata.removeDocument(docLen) exactly once regardless of how many RIDs were passed. In practice one-RID-per-call is the current convention, but the asymmetry is a latent counter-corruption bug if the API is ever called with multiple RIDs. At minimum, an assertion or a guard comment documenting the single-RID precondition would prevent silent drift.

2. scoreCandidatesBM25 receives an empty keys[] from the SEARCH_INDEX SQL path (FullTextQueryExecutor)

FullTextQueryExecutor.executeQuery() passes new Object[]{} as the keys argument to scoreCandidatesBM25, so every IndexCursorEntry.keys produced by the BM25 SEARCH_INDEX path is empty. This is inconsistent with the CLASSIC path (which stores the matched token) and with the direct .get() BM25 path (which passes the real keys). Any caller that reads cursor.getKeys() on entries returned from SEARCH_INDEX will silently see [] instead of the matched tokens.

3. RebuildIndexStatement casts Index to IndexInternal without an instanceof guard

In the recomputeStatistics helper, the named-index branch casts the result of schema.getIndexByName(...) directly to IndexInternal. If a custom or future Index implementation does not also implement IndexInternal, this throws a bare ClassCastException at SQL execution time. An instanceof check and a clean user-facing error message would be more robust.


Minor

4. BinaryCondition.getIndexedFunctionScoringExplain re-executes the right-hand expression

The explain path calls right.execute(null, context) at plan-rendering time, mirroring the actual execution path. For literal expressions this is harmless, but for a right-hand-side sub-expression with side effects (a user function, for example), it would execute during EXPLAIN. The failure is swallowed with a try/catch Exception in the explain step's prettyPrint, so the risk is low - but ideally the already-evaluated value should be threaded through rather than re-evaluated.

5. storedKey[] reuse in computeBM25Scores relies on an undocumented synchronous-cursor contract

The comment flags that the array reuse is "SAFE ONLY because underlyingIndex.get() consumes the array synchronously". The comment is thorough and the risk is tracked, but if the cursor is ever made lazy, or this pattern is copied elsewhere, silent corruption will result. Given the small allocation cost of new String[]{token} per token, a defensive copy might be worth the safety guarantee.

6. Streams in DatabaseChecker.formatTopMissingReferences()

The project style generally avoids streams in non-trivial code and prefers arrays/primitives for GC pressure. This is a diagnostic-only path, so it has no performance impact, but a manual ArrayList.sort + subList would be more idiomatic with the rest of the codebase.


Suggestions

S1. @Tag("slow") on bm25RankingHoldsAcrossMultipleBuckets seems overly conservative

The test inserts 41 documents and runs a query over 4 buckets. This should be fast in CI. The @Tag("slow") annotation will exclude it from regular builds, the opposite of FullTextBM25CompactionTest (600 documents, not tagged). Consider dropping the slow tag unless this test has been measured to take noticeably long.

S2. No SQL-level test for REBUILD INDEX <name> WITH statsOnly = true

FullTextBM25Test verifies recomputeBM25Counters() via the Java API. An end-to-end SQL test for REBUILD INDEX Doc[content] WITH statsOnly = true that checks the result set (statsRecomputed = 1) and that scores remain unchanged would give better regression coverage for the new SQL extension.

S3. Startup log spam for large BM25 deployments

LSMTreeFullTextIndex logs a WARNING for every BM25 index opened with empty corpus counters. A database with many BM25 indexes would emit a warning per index on first open. An aggregated warning from the schema load path (e.g., "N BM25 indexes have no corpus stats; run REBUILD INDEX * WITH statsOnly=true") would be more operator-friendly.

S4. forceCompaction() assertion in FullTextBM25CompactionTest could produce a misleading failure

assertThat(forceCompaction()).isTrue() will fail with a generic assertion error if the data volume does not actually trigger compaction on the test platform. A more descriptive failure message (e.g., assertThat(forceCompaction()).as("expected at least one bucket to compact").isTrue()) would help diagnose CI failures on constrained hosts.


Style / Convention Observations (all good)

  • All new assertions use assertThat(x).isX() (AssertJ). No assertTrue / assertEquals found.
  • No System.out debug statements.
  • com.arcadedb.serializer.json.JSONObject/JSONArray used correctly throughout (not org.json or Jackson).
  • No ForkJoinPool.commonPool() usage introduced.
  • final used consistently on new variables and parameters.
  • Single-statement if blocks without curly braces follow existing project style.
  • No new external dependencies.
  • DEFAULT_BM25_K1/DEFAULT_B duplication between BM25Scorer and FullTextIndexMetadata is intentional and protected by a sync test - good mitigation.

Overall the feature is solid. The three Major items above are the ones worth addressing before merge; the rest are low-risk polish items.

…loc, docs, drop slow tag

- RebuildIndexStatement.recomputeStatistics: instanceof-guard the IndexInternal cast on the named-index path so a custom Index that is not IndexInternal yields a clean 'does not support statistics recomputation' error instead of a bare ClassCastException
- computeBM25Scores: allocate the single-element lookup key per token again (revert the shared-array reuse). The reuse depended on get() consuming the array synchronously - flagged twice as a footgun; the few tiny arrays per query are negligible GC, so safety wins
- remove(): document the single-rid-per-call symmetry with put()'s per-rid addDocument loop (one document per remove call, so one removeDocument balances it; no caller passes N documents sharing a key)
- FullTextQueryExecutor: document why SEARCH_INDEX result entries carry empty keys (a multi-token Lucene query has no single key tuple; the SQL path reads only RID + $score, never getKeys())
- drop @tag(slow) from bm25RankingHoldsAcrossMultipleBuckets (measured ~0.3s; the tag was inconsistent with the untagged 600-doc compaction test)

Verified already-present: end-to-end SQL statsOnly test (statsRecomputed=1 + scores), and the descriptive forceCompaction().as(...) message.

Acknowledged (no change): the EXPLAIN RHS re-execution is swallowed by try/catch and only re-evaluates literal args in practice; DatabaseChecker streams/magic-number are CHECK DATABASE code (out of scope). S3 (aggregate the open-time cold-counter warning): kept per-index because it names the specific cold index (more actionable than a count) and the state is a transient, bounded post-upgrade condition; aggregating would mean surgery in the large readConfiguration method.

Full-text + parser suites green (208).
@claude

claude Bot commented Jun 24, 2026

Copy link
Copy Markdown

Code Review: feat/bm25-fulltext-scoring

@claude

claude Bot commented Jun 24, 2026

Copy link
Copy Markdown

Code Review: feat/bm25-fulltext-scoring

This is a well-designed, thoroughly documented PR. The BM25 math is correct, the storage layer changes are sound, and the test coverage is comprehensive. A few items worth discussing before merge.

Behavior Changes That Affect Existing Users

1. CLASSIC path result order is now reversed (descending)

In LSMTreeFullTextIndex.get() the CLASSIC (non-BM25) path sort changed from ascending to descending score. The comment says the previous ascending sort returned the least-relevant documents first, which is true, but this is still a breaking change for any caller relying on the old ordering. If the ascending order was a pre-existing bug, that is worth calling out explicitly in the description.

2. IndexCursorEntry.equals()/hashCode() no longer include score

The PR correctly notes this is a behavioral change (documented in the class Javadoc). The reasoning is sound - score should not be part of identity - but this is a public-API class and downstream code could be using it in Sets or Maps where this change silently affects deduplication. Worth a changelog note.

Design / Architecture Observations

3. storeTermFrequency flag is not persisted in the page header

This flag lives only in-memory, derived from the schema at load time. The code comment correctly explains the happens-before guarantee during schema load, but it creates a risk: if the schema file and index files become inconsistent (manual schema edits, disaster recovery, partial restores), the index would silently misinterpret its own bytes. The page format version in the header is the traditional guard for this. Given the low frequency of this scenario and the existing documentation, acceptable as-is, but worth tracking as a known limitation.

4. FullTextIndexMetadata.writeToJSON() naming vs overriding toJSON()

The PR adds writeToJSON(JSONObject) as a new method rather than overriding the inherited toJSON(). The diff only shows the load path (readConfiguration) in LocalSchema.java. Worth verifying the schema save path (writeConfiguration) also calls writeToJSON() correctly - if it still calls the base toJSON(), the persistence fix would not take effect.

5. Two-pass streaming in the no-candidate path

The computeBM25Scores(candidates=null) path scans each posting list twice (once for df, once to accumulate). The comment explains the memory rationale clearly, and the SQL SEARCH_INDEX candidate-based path avoids this. No action needed - just noting it as a documented trade-off.

Correctness Notes

6. Compaction fix is correct

The startedOnContinuation + firstIteration logic in LSMTreeIndexCompacted.appendDuringCompaction is the most subtle change. The invariant it relies on - that writeEntryMultipleValues writes ONLY to the scratch buffer, never to a page - is now documented in the method Javadoc. This makes the fix safe (no bytes are orphaned). The compaction test at 1024-byte pages is a good regression guard.

7. REBUILD INDEX * WITH parser fix

The firstSettingKeyIndex = ctx.STAR() != null ? 0 : 1 fix in SQLASTBuilder is clean and targeted. The bug (wrong offset silently dropping the first setting) is well-explained.

8. Duplicate default constants

DEFAULT_BM25_K1 / DEFAULT_BM25_B are intentionally duplicated in BM25Scorer and FullTextIndexMetadata to avoid a package dependency inversion. The cross-check in BM25ScorerTest.defaultConstantsStayInSyncWithMetadata() makes this self-enforcing. Good defensive pattern.

Minor Style Notes

  • No System.out calls found; logging uses LogManager throughout.
  • final keyword used consistently on variables and parameters.
  • Test assertions use AssertJ throughout (assertThat(...).isCloseTo(), etc.).
  • No em dashes found in code comments.
  • IndexCursorEntry now has two constructors; a note on the int-score constructor that score is lossy for BM25 results would help readers know to prefer floatScore.

Test Coverage Assessment

  • BM25ScorerTest - excellent unit coverage of the pure math, including edge cases (df=0, df>N, zero avgdl, b=0)
  • FullTextBM25Test - comprehensive integration tests: IDF ranking, length normalization, field boosts, caret boosts, multi-bucket consistency, removal/update counter maintenance, rollback handling, SQL-based statsOnly rebuild
  • FullTextBM25CompactionTest - directly targets the posting-drop bug with deliberately small pages; verifies scores survive compaction

Missing coverage to consider:

  • No test pins the CLASSIC path new descending sort order. Adding an explicit ordering assertion to LSMTreeFullTextIndexTest would catch any future regression.
  • No test for a document where all indexed fields are null (exercising a query against such a document would be a nice edge-case addition).

Summary

The BM25 implementation is solid: correct math, good thread-safety model, sensible defaults, backward-compatible upgrade path, and a real compaction bug fix. The main things worth addressing before merge:

  1. Confirm the CLASSIC sort-order reversal is intentional and add a test asserting descending order
  2. Verify the schema save path calls writeToJSON() correctly (not just the load path)
  3. (Optional) Changelog note about IndexCursorEntry.equals() behavioral change for downstream library users

The code is otherwise merge-ready.

Review generated with Claude Code

@lvca
lvca merged commit a478394 into main Jun 24, 2026
26 of 30 checks passed
@lvca
lvca deleted the feat/bm25-fulltext-scoring branch June 24, 2026 03:26
tae898 pushed a commit to humemai/arcadedb-embedded-python that referenced this pull request Jun 28, 2026
…0 in /studio [skip ci]

Bumps [webpack-cli](https://github.com/webpack/webpack-cli) from 6.0.1 to 7.0.0.
Release notes

*Sourced from [webpack-cli's releases](https://github.com/webpack/webpack-cli/releases).*

> webpack-cli@7.0.0
> -----------------
>
> ### Major Changes
>
> * The minimum supported version of Node.js is `20.9.0`. (by [`@​alexander-akait`](https://github.com/alexander-akait) in [ArcadeData#4677](https://redirect.github.com/webpack/webpack-cli/pull/4677))
> * Use dynamic import to load `webpack.config.js`, fallback to interpret only when configuration can't be load by dynamic import. Using dynamic imports allows you to take advantage of Node.js's built-in TypeScript support. (by [`@​alexander-akait`](https://github.com/alexander-akait) in [ArcadeData#4677](https://redirect.github.com/webpack/webpack-cli/pull/4677))
> * Removed the `--node-env` argument in favor of the `--config-node-env` argument. (by [`@​alexander-akait`](https://github.com/alexander-akait) in [ArcadeData#4677](https://redirect.github.com/webpack/webpack-cli/pull/4677))
> * The `version` command only output versions right now. (by [`@​alexander-akait`](https://github.com/alexander-akait) in [ArcadeData#4677](https://redirect.github.com/webpack/webpack-cli/pull/4677))
> * Removed deprecated API, no action required unless you use `import cli from "webpack-cli";`/`const cli = require("webpack-cli");`. (by [`@​alexander-akait`](https://github.com/alexander-akait) in [ArcadeData#4677](https://redirect.github.com/webpack/webpack-cli/pull/4677))
>
> ### Patch Changes
>
> * Allow configuration freezing. (by [`@​alexander-akait`](https://github.com/alexander-akait) in [ArcadeData#4677](https://redirect.github.com/webpack/webpack-cli/pull/4677))
> * Use graceful shutdown when file system cache is enabled. (by [`@​alexander-akait`](https://github.com/alexander-akait) in [ArcadeData#4677](https://redirect.github.com/webpack/webpack-cli/pull/4677))
> * Performance improved. (by [`@​alexander-akait`](https://github.com/alexander-akait) in [ArcadeData#4677](https://redirect.github.com/webpack/webpack-cli/pull/4677))


Changelog

*Sourced from [webpack-cli's changelog](https://github.com/webpack/webpack-cli/blob/main/CHANGELOG.md).*

> 7.0.0
> -----
>
> ### Major Changes
>
> * The minimum supported version of Node.js is `20.9.0`. (by [`@​alexander-akait`](https://github.com/alexander-akait) in [ArcadeData#4677](https://redirect.github.com/webpack/webpack-cli/pull/4677))
> * Use dynamic import to load `webpack.config.js`, fallback to interpret only when configuration can't be load by dynamic import. Using dynamic imports allows you to take advantage of Node.js's built-in TypeScript support. (by [`@​alexander-akait`](https://github.com/alexander-akait) in [ArcadeData#4677](https://redirect.github.com/webpack/webpack-cli/pull/4677))
> * Removed the `--node-env` argument in favor of the `--config-node-env` argument. (by [`@​alexander-akait`](https://github.com/alexander-akait) in [ArcadeData#4677](https://redirect.github.com/webpack/webpack-cli/pull/4677))
> * The `version` command only output versions right now. (by [`@​alexander-akait`](https://github.com/alexander-akait) in [ArcadeData#4677](https://redirect.github.com/webpack/webpack-cli/pull/4677))
> * Removed deprecated API, no action required unless you use `import cli from "webpack-cli";`/`const cli = require("webpack-cli");`. (by [`@​alexander-akait`](https://github.com/alexander-akait) in [ArcadeData#4677](https://redirect.github.com/webpack/webpack-cli/pull/4677))
>
> ### Patch Changes
>
> * Allow configuration freezing. (by [`@​alexander-akait`](https://github.com/alexander-akait) in [ArcadeData#4677](https://redirect.github.com/webpack/webpack-cli/pull/4677))
> * Use graceful shutdown when file system cache is enabled. (by [`@​alexander-akait`](https://github.com/alexander-akait) in [ArcadeData#4677](https://redirect.github.com/webpack/webpack-cli/pull/4677))
> * Performance improved. (by [`@​alexander-akait`](https://github.com/alexander-akait) in [ArcadeData#4677](https://redirect.github.com/webpack/webpack-cli/pull/4677))


Commits

* [`0b116f7`](webpack/webpack-cli@0b116f7) chore(release): new release ([ArcadeData#4679](https://redirect.github.com/webpack/webpack-cli/issues/4679))
* [`e0b2f07`](webpack/webpack-cli@e0b2f07) test: improve
* [`5328fcb`](webpack/webpack-cli@5328fcb) chore(deps): bump pnpm/action-setup in the dependencies group ([ArcadeData#4699](https://redirect.github.com/webpack/webpack-cli/issues/4699))
* [`4b6f0e1`](webpack/webpack-cli@4b6f0e1) chore(deps): update ([ArcadeData#4696](https://redirect.github.com/webpack/webpack-cli/issues/4696))
* [`47fc332`](webpack/webpack-cli@47fc332) test: more ([ArcadeData#4695](https://redirect.github.com/webpack/webpack-cli/issues/4695))
* [`a199bc3`](webpack/webpack-cli@a199bc3) test: refactor config format test + more ([ArcadeData#4684](https://redirect.github.com/webpack/webpack-cli/issues/4684))
* [`20bc478`](webpack/webpack-cli@20bc478) refactor: code
* [`529352d`](webpack/webpack-cli@529352d) docs: update ([ArcadeData#4692](https://redirect.github.com/webpack/webpack-cli/issues/4692))
* [`a01f01b`](webpack/webpack-cli@a01f01b) chore: fix coverage
* [`e434e98`](webpack/webpack-cli@e434e98) refactor: make cli faster ([ArcadeData#4690](https://redirect.github.com/webpack/webpack-cli/issues/4690))
* Additional commits viewable in [compare view](https://github.com/webpack/webpack-cli/compare/webpack-cli@6.0.1...webpack-cli@7.0.0)

Maintainer changes

This version was pushed to npm by [GitHub Actions](<https://www.npmjs.com/~GitHub> Actions), a new releaser for webpack-cli since your current version.

  
[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility\_score?dependency-name=webpack-cli&package-manager=npm\_and\_yarn&previous-version=6.0.1&new-version=7.0.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
  
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot show  ignore conditions` will show all of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
robfrank pushed a commit that referenced this pull request Aug 14, 2026
…LAIN/PROFILE) (#4695)

* feat: [#4687] native BM25 full-text scoring (field boosts, caret, EXPLAIN/PROFILE)

Add Okapi BM25 ranking to FULL_TEXT indexes. BM25 (TF/IDF + document-length
normalization) becomes the default similarity for newly created full-text
indexes; existing indexes keep the legacy term-coordination (CLASSIC) scoring,
which stays available via METADATA {"similarity":"CLASSIC"}.

- Per-posting term frequency + document length are stored inline through a RID
  subclass (FullTextPostingRID), gated by a storeTermFrequency flag so every
  non-full-text LSM index keeps the byte-identical RID-only format.
- Configurable k1/b, per-field boosts (metadata) and Lucene-style caret boosts
  (e.g. title:java^3), combinable inside AND/OR/NOT and grouped queries. The
  effective weight is caret * field_boost.
- $score exposes the float BM25 relevance on every matching row. EXPLAIN/PROFILE
  annotate the full-text fetch step with the similarity, k1/b, corpus stats
  (N, avgdl) and each query term's df/idf/boost.
- Corpus counters (N, sum of document lengths) and analyzer configuration now
  persist across restart; previously toJSON dropped the metadata and silently
  reverted custom analyzers to StandardAnalyzer.

Fix (pre-existing, also affected CLASSIC): full-text index compaction dropped
postings when a single token's value list spanned multiple compacted pages. The
compacted root is a positional sparse index that cannot index one leaf page
under two keys, so a key's values left on a shared continuation page became
unreachable. Overflowing keys now start on a fresh page they fully own.

* fix: [#4687] address review - per-bucket BM25 stats + tokens-only explain

- BM25 is scored per bucket (per-shard, like Elasticsearch): the document
  frequency is read from a single bucket's postings, so N and avgdl must be
  per-bucket too. The fallback (was countType, type-wide) and recompute (was
  iterateType, whole type) now use the associated bucket's count/records,
  removing the systematic IDF bias when a type has multiple buckets. Documented
  the per-bucket scoring model.
- EXPLAIN/PROFILE scoring metadata: collect scoring tokens in a tokens-only mode
  that skips per-document score accumulation, so no document set is materialized
  just to read the term/idf/df breakdown.
- test: BM25 ranking across multiple buckets.

* fix: [#4687] address review - thread-safe corpus counters + doc/comments

- Make the BM25 corpus counters thread-safe: totalDocs/sumDocLength are now
  AtomicLong (countersValid volatile). Concurrent transactions indexing into the
  same bucket share the per-bucket metadata and were racing on bare long fields.
- Clarify in the compaction fix that no bytes are orphaned on the continuation
  page: the key's entry lives only in the scratch buffer until putByteArray, so
  the continuation page is flushed with just the previous keys' entries.
- Comment the EXPLAIN/PROFILE delegation chain at the FetchFromIndexedFunctionStep
  call site.
- Docs: note the $score type change (now Float, incl. CLASSIC where it was an
  Integer match count) as the one user-visible change for existing indexes; fold
  the compaction issue into a "fixed" section.

* fix: [#4687] address review - unbiased per-bucket IDF, streaming scorer, validation

- IDF consistency (multi-bucket): the FullTextIndexMetadata (and its corpus
  counters) is shared across a type's bucket indexes, so the counters are
  type-wide. Since document frequency is read per bucket, N for IDF now comes
  from the bucket's live record count (matching df scope); the shared counters
  feed only the average document length. Recompute scans the whole type (not a
  single bucket, which would corrupt the shared counters).
- Unify the two BM25 scoring paths (direct get() and Lucene-syntax executor)
  into one streaming computeBM25Scores helper: df is stream-counted without
  materializing the posting list, and only candidate/result postings are held -
  bounding memory for high-frequency terms and removing duplicated formula code.
- ensureCounters() now keys off countersValid (not totalDocs > 0), so a fresh
  empty index is not rescanned on every query; lazy recompute is in-memory only
  (no saveConfiguration on the read path).
- Validate BM25 params (k1 >= 0, b in [0,1]) in the setters and the METADATA
  path; reject misconfiguration at index creation.
- Centralize the QueryParser default-field sentinel as DEFAULT_FIELD with the
  collision limitation documented.
- Per-field maps -> ConcurrentHashMap (read on the query path, iterated by
  writeToJSON). Document corpus-counter drift on rollback / analyzer change.
- IndexCursorEntry.score: document the BM25 rounding/precision loss (use
  floatScore). Comment that EXPLAIN reports one representative bucket's stats.
- tests: BM25 param validation, document removal + recompute (incl. empty type).

* fix: [#4687] address review - single-pass candidate scoring, similarity validation, tests

- computeBM25Scores: when a candidate set is given (the SEARCH_INDEX path) score
  in a SINGLE pass, counting df while collecting only candidate postings - halves
  cursor I/O for high-frequency terms vs the previous two-pass. The no-candidate
  path stays two-pass to keep memory bounded. Added a TODO to source df from a
  per-key index count if the LSM layer ever exposes one.
- setSimilarity rejects unknown names (e.g. "LUCENE") instead of silently falling
  back to CLASSIC; the METADATA path routes through it.
- EXPLAIN scoring JSON carries a "note" stating the statistics are per-bucket, so
  the explained IDF is not mistaken for a global value.
- countDocuments: document that rids.length is 1 per document on the standard path.
- Docs: corpus-counter drift / no-background-recompute behavior; $score is Float.
- Use the imported JSONObject short name in explainScoring.
- tests: CLASSIC postings survive compaction (independent regression); $score type
  pinned to Float for CLASSIC; unknown-similarity rejection; @tag("slow") on the
  multi-bucket and restart tests.

* fix: [#4687] address review - logging, marker guard, state reset, drift test

- readEntryValue: keep deletion markers (negative bucket id) as a plain RID
  instead of wrapping them in FullTextPostingRID, so nothing mistakes a marker
  for a scorable posting (the tf/docLength varints are still read to stay aligned).
- FullTextQueryExecutor: reset per-query matching state (scoringTokens,
  collectingExclusion, tokensOnly, currentBoost) at the top of search() and
  explainScoring() - defensive against executor reuse.
- associatedBucket(): log a WARNING instead of silently returning null (IDF then
  falls back to N=1); log the cold-start corpus recompute at INFO (it full-scans
  the type).
- countDocuments: document the rollback-drift risk (counters feed only avgdl;
  recomputeBM25Counters repairs).
- EXPLAIN explainScoring: note the per-term posting scan cost.
- docs: fix stale RIDWithStats -> FullTextPostingRID; document the single-pass
  (candidate) vs two-pass (unbounded) scoring and the bounded-memory trade-off.
- tests: rolled-back insert is not indexed and recompute repairs counters;
  relaxed the fragile hasSize(601/600) checks to >= + containsKey.

* fix: [#4687] address review - double-precision scoring, restart staleness self-heal

- computeBM25Scores accumulates in double and narrows to float only when building
  the cursor entry, removing per-term float rounding error.
- Restart staleness: persisted corpus counters can lag the on-disk data (docs
  indexed after the last schema save). On the first BM25 query of a session the
  counters are validated once with a cheap live document count (countType) and
  rebuilt only if they disagree - a clean restart pays nothing, a stale one
  self-heals. (staleChecked is transient.)
- EXPLAIN scoring JSON now includes the bucket name alongside the per-bucket note.
- BM25Scorer: removed the trailing blank line.

* fix: [#4687] address review - CAS staleness check, content-boost warning, empty-corpus test

- ensureCounters: claim the one-per-session staleness validation via AtomicBoolean
  compareAndSet, so concurrent first-queries across a type's shared bucket indexes
  cannot all run the live count + rescan (fixes the TOCTOU on the old read/write
  pair). Dropped the misleading `transient` (the class is not Serializable).
- Warn at index creation when a boost is configured for a field literally named
  "content" on a multi-property index (it collides with the query parser's default
  field and would be silently ignored).
- test: EXPLAIN on an empty corpus exercises the df==0 branch and must not fail.

* docs/test: [#4687] address review - phrase-boost & format-fragility notes, removal-stats test

- Document that phrase-query terms do not receive the configured per-field boost
  (matched against the unprefixed token; an enclosing caret boost still applies).
- Document that storeTermFrequency is schema-derived, not stored in the page
  header, so schema and index files must stay consistent.
- Doc: note the deliberate mixed scope (per-bucket N/df, type-wide avgdl) and how
  it differs from Elasticsearch/Lucene shard-local avgdl.
- test: removal updates BM25 statistics (df/N) so survivors' IDF reflects the
  deleted document.

* fix: [#4687] BM25 review batch - df deletion-marker guard, volatile flag, allocation/sort hoists, test gaps

- skip deletion markers (negative bucket id) when counting document frequency in both the candidate and no-candidate scoring passes so deleted postings no longer inflate df/IDF
- make storeTermFrequency volatile: set once post-construction but read on every read/write path, ensuring visibility so a reader never misparses value bytes on a stale false
- hoist the per-token candidate hits list out of the token loop (reuse with clear()) to avoid per-token allocations
- guard the candidate result sort with size() > 1
- replace fully-qualified com.arcadedb.database.Record with an import
- document the remove() docLen-recompute drift (analyzer/field changes) and that recomputeBM25Counters() repairs it
- tests: static-import assertThatThrownBy/within; add single-document scoring (avgdl edge) and content-field-name vs default-field-sentinel collision tests

* fix: [#4687] BM25 review batch 2 - float score plumbing, classic ordering, perf, test gaps

- FetchFromIndexStep: read cursor.getFloatScore() into a float field so full-text BM25 scores below 1.0 are no longer truncated to 0 and suppressed (the CONTAINSTEXT path); integer-scored indexes are unaffected
- LSMTreeFullTextIndex.get() CLASSIC path: sort most-relevant-first (was ascending, returning least-relevant first) with a deterministic RID tiebreaker, and actually honor the limit via subList (was only used as a list capacity hint)
- IndexCursorEntry equals/hashCode: identity is (record, keys) only; the relevance score is a derived value and including it could let the same document appear twice in a result Set when scored differently
- explainScoring: skip deletion markers when counting df so the explained df/idf matches what computeBM25Scores uses
- FullTextQueryExecutor: collect candidate RIDs via a new raw LSMTreeFullTextIndex.getPostings() instead of index.get(), avoiding running (and discarding) the full BM25 scoring pipeline once per term before scoreCandidatesBM25 scores them
- align the scoreCandidatesBM25 limit sentinel (limit > -1) with getBM25 and the classic path
- remove the redundant startKey != null check in iterateAndMatch (rangeScan already encodes it)
- BM25 single-property indexing: exclude null tokens from document length while still flowing them to the underlying put so NULL_STRATEGY is enforced

Tests:
- add UPDATE-path regression (old posting removed, new added, no length double-count vs recompute)
- add builder-API coverage for withBM25()/withFieldBoost() (metadata + end-to-end ranking/boost)
- assert persisted similarity/k1/b and corpus counters directly after restart
- add CLASSIC cursor ordering + limit regression
- replace brittle float exact-equality with isCloseTo
- drop @tag(slow) from FullTextBM25CompactionTest: it is a sub-second correctness regression that must run in CI

* fix: [#4687] BM25 review batch 3 - posting validation, invariant docs, explain logging

- FullTextPostingRID: reject negative tf/docLength in the constructor so corrupt statistics fail fast instead of producing a nonsensical BM25 contribution; add a regression test
- writeTermFrequency: document the alignment invariant - a given index instance writes and reads every page (leaf and compacted root) with the same storeTermFrequency flag, so a root-page pointer's zero varints are always consumed back symmetrically; the pointer may be reconstructed as FullTextPostingRID(0,0) but only its position (target page number) is read, so it is harmless
- avgDocLength: document the concurrency approximation boundary (widest skew is one in-flight document, ~avgdl/n, only affects the length-normalization denominator; recomputeBM25Counters() gives an exact value)
- FetchFromIndexedFunctionStep: log the EXPLAIN/PROFILE scoring-metadata failure at FINE instead of silently swallowing it

* fix: [#4687] BM25 review batch 4 - content-field collision fix, MoreLikeThis IDF, SQL stats repair, release notes

Correctness:
- fix the DEFAULT_FIELD collision: the query-parser default field is now a non-identifier sentinel and 'unqualified' is resolved index-aware (isUnqualified): the sentinel OR the sole property of a single-property index. A multi-property index with a real 'content' field now scores and boosts content:term correctly (was silently treated as unqualified). Removes the now-obsolete creation-time content-boost warning.
- MoreLikeThis: use the live per-bucket document count for IDF instead of the max document-frequency proxy (which understated IDF for shared terms and skewed term selection).

Robustness:
- TypeFullTextIndexBuilder: guard the FullTextIndexMetadata casts (ftMetadata() helper + a clear error in withMetadata(IndexMetadata)) so misuse fails with an actionable message instead of a bare ClassCastException.
- document the writeEntryMultipleValues no-partial-page-write invariant that the compaction continuation-page fix relies on.
- BM25Scorer.idf: document the df>N edge (log argument always > 0; idf >= 0 when df <= N; a stale df>N gives a small harmless negative idf).
- avgDocLength: document the lock-free approximation boundary.
- staleChecked: document that it is intentionally never reset and recompute/rebuild is the in-session recovery path.

Feature:
- REBUILD INDEX <name|*> WITH statsOnly = true: recompute BM25 corpus counters by rescanning live data, without a full reindex (IndexInternal.recomputeStatistics() default no-op, overridden by LSMTreeFullTextIndex and fanned out by TypeIndex). Repairs counter drift from SQL.

Misc:
- stale-restart recompute now logs the resulting document count / avgdl for operators.
- new test files use the © copyright header for consistency.
- RELEASE-27.7.1.md: document the two breaking changes ($score Integer->Float; CLASSIC cursor order now descending) with migration notes.

Tests: content-field collision + boost, MoreLikeThis unaffected, builder guards, statsOnly SQL repair (and CLASSIC rejection); full-text + index packages green (637 index tests).

* fix: [#4687] BM25 review batch 5 - cold-start rescan guard, sort tiebreaker, REBUILD INDEX * fix, explain/JSON polish

- ensureCounters: serialize the cold-start counter rebuild with a double-checked lock on the shared metadata, so concurrent first-queries across a type's bucket indexes don't all run a full type scan
- getBM25: add the RID tiebreaker to the result sort so equal-scored documents have a deterministic order, consistent with scoreCandidatesBM25 and the CLASSIC path
- fix a pre-existing AST bug: REBUILD INDEX * WITH <setting> silently dropped the first setting (the STAR form has no index-name identifier, but the builder still skipped identifier(0)); affected batchSize/maxAttempts too, now also statsOnly
- SQLFunctionSearchIndex.getScoringExplain: on a multi-bucket type, state explicitly that the EXPLAIN/PROFILE statistics are the FIRST of N buckets so per-bucket df/IDF is not mistaken for type-wide
- FullTextIndexMetadata.writeToJSON: emit bm25_k1/bm25_b only when tuned away from the defaults (named DEFAULT_BM25_K1/B constants); add defaultBM25(...) factory to make the new-index default-metadata intent explicit
- release notes: call out the CLASSIC cursor-order change explicitly as affecting CLASSIC indexes

Tests: REBUILD INDEX * {statsOnly} wildcard form; default-vs-tuned k1/b JSON round-trip. Full-text + schema + parser/DDL suites green.

* docs: [#4687] BM25 review batch 6 - getter convention, equals/recompute docs, operational notes

- FullTextPostingRID: replace public tf/docLength fields with getTf()/getDocLength() getters, matching the RID/DatabaseRID convention; update the (few) call sites
- IndexCursorEntry: document the equals/hashCode change (identity is (record, keys) only; score deliberately excluded since #4687) so the semantic shift is discoverable
- TypeIndex.recomputeStatistics: document why returning on the first success is correct (all bucket sub-indexes of a type share one similarity + metadata object)
- docs/4687: add an Operational notes / known limitations section - rollback counter drift + one-time rescan (repair via REBUILD INDEX ... statsOnly), type-wide avgdl vs per-bucket N/df bias on unbalanced multi-bucket types, and the two-pass I/O on the direct (non-SEARCH_INDEX) lookup path

No behavior change (getters are equivalent; the rest is documentation). Full-text + schema suites green.

* fix: [#4687] BM25 review batch 7 - reject negative per-field boosts

- FullTextIndexMetadata.setFieldBoost: reject boost < 0 (a negative boost would produce negative BM25 term contributions and invert ranking); route the METADATA {...} parse path through the setter so '<field>_boost' gets the same validation as the builder, consistent with the existing k1/b validation
- test: negative content_boost is rejected at index creation

Full-text + schema suites green.

* fix: [#4687] BM25 review batch 8 - load-flag visibility, REBUILD setting NPE, encapsulation, docs, tests

Correctness:
- RebuildIndexStatement: render WITH-setting values via Expression.toString() instead of .value.toString(). The integer literal's .value is null in this context; the bug was latent (the * form never parsed settings until the prior fix, and parser tests only check syntax) and surfaced as an NPE on 'rebuild index * with batchSize = 1000'
- LSMTreeIndexAbstract: make storeTermFrequency private (was protected); subclass reads it via isStoreTermFrequency(). Document that it is published during single-threaded schema load (happens-before any concurrent reader)
- FullTextPostingRID: mark final so a subclass cannot pass the instanceof checks with different statistics

Polish:
- SQLFunctionSearchIndex: rename bm25BucketCount -> ftBucketCount, add !isBM25() guard, fix JSONObject import ordering, document the Float::sum one-bucket-per-RID invariant
- RebuildIndexStatement: simplify cast to 'instanceof TypeIndex ti'
- rename terse pattern var s -> posting in writeTermFrequency
- document: global-vs-per-field tf semantics on multi-field indexes; TODO(perf) on the no-candidate double scan; setCounters/staleChecked session semantics; set-once metadata fields need no volatile; the -1 bucketId sentinel in LocalSchema reload; FullTextPostingRID package placement

Tests: avgdl=0 guard stays finite; stale df>N yields small negative idf that dampens (not inverts) ranking; null indexed field under BM25 is skipped without inflating length.

Full-text + schema suites green; full index package re-run clean after the NPE fix.

* perf: [#4687] BM25 review batch 9 - kill scoring-loop allocations, top-K selection, guards

Performance (CLAUDE.md low-GC):
- computeBM25Scores accumulates into a per-RID double[1] cell instead of a boxed Double: a document matched by T query terms was boxed T times via Map.merge(Double::sum); now it is a primitive += into a cell allocated once per unique document
- reuse a single-element String[] lookup-key buffer across query terms instead of allocating one per term (each underlyingIndex.get() consumes it synchronously)
- top-K selection: getBM25 and scoreCandidatesBM25 now share buildScoredCursor(), which uses a bounded min-heap (O(N log K)) when a limit smaller than the result set is requested instead of a full O(N log N) sort - the common ORDER BY $score DESC LIMIT k shape; falls back to a full sort otherwise. Preserves the descending + RID-tiebreaker order

Correctness / API:
- REBUILD INDEX <name> WITH statsOnly: report "not found" for an unknown index instead of NPE
- setBm25K1: document the k1=0 edge (degenerates BM25 to pure IDF / binary presence)
- getPostings: make package-private (pre-analyzed key, bypasses analysis; only FullTextQueryExecutor should call it)

Tests: BM25 get honors limit returning top-K in descending order (heap path); statsOnly on an unknown index reports not-found.

Full-text + schema suites green; full index package (641 tests) green.

* fix: [#4687] BM25 review batch 10 - null-token posting, default-detect epsilon, term-expansion cap, docs/tests

Correctness:
- single-property putWithStats: a null indexed value no longer becomes a FullTextPostingRID with a bogus tf - the null token is kept out of the stats and, if NULL_STRATEGY indexes it, written as a plain RID (tf=0/docLength=0). NULL_STRATEGY skip/error/index behavior unchanged
- FullTextIndexMetadata.writeToJSON: detect default k1/b with an epsilon instead of != (1.2f/0.75f are inexact in float32; a JSON round-tripped value could otherwise be persisted as non-default forever)

Hardening:
- FullTextQueryExecutor: cap the number of distinct BM25 scoring tokens (MAX_EXPANDED_SCORING_TERMS = 4096) so a pathological wildcard/fuzzy expansion cannot add unbounded posting-list scans to the re-rank pass; matching is unaffected (result set stays correct), excess terms just stop contributing to the score, logged once per query

Docs:
- BM25Scorer.termScore: note the tf<=0 guard also defends the architecturally-impossible root-pointer RID
- setCounters: note the volatile-write happens-before edge that publishes the counters
- resolveAvgDocLength: clarify the 1.0 fallback also covers an empty corpus
- FullTextBM25CompactionTest: correct the 'tiny page size' comment (scenario is driven by document volume at the default page size)

Tests: unqualified query aggregates tf across fields (global tf = sum) while a field-qualified query sees only that field's tf.

Full-text + schema suites green (FullTextBM25Test 24).

* Partial commit

* fix: [#4687] BM25 review batch 11 - fromJSON field-config reset, null similarity, doc clarity, test strength

Correctness:
- FullTextIndexMetadata.fromJSON: clear fieldAnalyzers/fieldBoosts before re-parsing so a reload onto a reused instance replaces the per-field config instead of merging stale entries (these maps are now final ConcurrentHashMaps, no longer swapped wholesale)
- setSimilarity(null) now throws IllegalArgumentException instead of silently resetting to BM25, consistent with setBm25K1/setBm25B (fromJSON already guards with has('similarity'), so no caller passes null)

Performance:
- cache getPropertyNames() in FullTextQueryExecutor (consulted per matched term by isUnqualified) instead of calling it each time

Docs / clarity:
- getBM25: document that the direct index.get() path does NOT support Lucene query syntax (caret boosts, boolean, phrase, wildcard) - use SEARCH_INDEX for that
- ensureCounters stale-check: note both sides are type-wide (shared counter vs countType), so it does not spuriously rescan multi-bucket types; IDF's N is the deliberately-different per-bucket scope
- DEFAULT_BM25_K1/B: note they mirror BM25Scorer by value and why they are not a direct reference (package dependency direction)

Tests:
- idf assertions now use asymmetric df (10, 80) so the N - df numerator term is actually exercised (df = N/2 would mask a dropped N)
- fromJSON replaces stale per-field config; setSimilarity(null) rejected

Full-text + schema suites green (229).

* fix: [#4687] BM25 review batch 12 - drift logging, EXPLAIN term cap, doc/comment clarity

- ensureCounters: log at INFO when the session stale-check finds the persisted counters diverged from the live count (so operators notice rollback-induced drift without reading EXPLAIN)
- explainScoring: cap the per-term posting scan at MAX_EXPLAIN_TERMS (64) and emit "termsTruncated": true beyond it, so an EXPLAIN of a huge wildcard/fuzzy expansion cannot become a very long scan
- avgDocLength: document the intentional type-wide-avgdl vs per-bucket-N/df scope mismatch (and why it must not be 'fixed') so future maintainers understand the trade-off
- ensureCounters synchronized block: note it is the only lock needed (counters are AtomicLong elsewhere); it just serializes the cold-start rebuild
- docs/4687: add a disaster-recovery note that BM25 index files must be kept in sync with their schema (storeTermFrequency is schema-derived, not page-stored)

Test: EXPLAIN of a wildcard expanding past the cap reports termsTruncated.

Already covered (verified, no change): $score-is-Float CLASSIC assertion exists; the two-pass TODO already gives the SEARCH_INDEX tip; no Set<IndexCursorEntry> in the codebase relies on score in identity (all add score=1); DEFAULT_BM25_K1/B already carry a keep-in-sync comment.

Full-text + schema suites green (225).

* docs: [#4687] BM25 review batch 13 - per-bucket/cold-start/drift docs, caret hint, sync test

- docs/4687: document that $score is per-bucket (not globally calibrated across a multi-bucket type's buckets); that the first BM25 query on a cold index does a one-time full scan and how to pre-warm with REBUILD INDEX ... statsOnly; and that avgdl drift is only document-count-validated (replace-without-count-change can drift it, repaired by statsOnly recompute)
- RELEASE-27.7.1.md: add the IndexCursorEntry equals/hashCode identity change as an extension-API migration note
- getBM25: log at FINE when a direct-path query contains a caret, since the direct lookup silently ignores Lucene syntax
- BM25ScorerTest: assert FullTextIndexMetadata.DEFAULT_BM25_K1/B equal BM25Scorer.DEFAULT_K1/B so the intentionally-duplicated constants stay in sync (self-enforcing, no compile dependency)

Decisions (no change, with reasoning): kept the cold-start rebuild under synchronized - the non-blocking alternatives either distort ranking (concurrent queries scoring with avgdl=1.0) or do N redundant full scans; a one-time post-upgrade stall, pre-warmable via statsOnly, is the soundest trade-off for a search feature. statsOnly name-vs-* asymmetry left as-is (explicit target warrants an explicit error; wildcard is best-effort). Confirmed ensureCounters() precedes resolveAvgDocLength() on the explain path; the double-scan TODO already points at SEARCH_INDEX.

Full-text + schema suites green (226; BM25ScorerTest 11).

* fix: [#4687] BM25 review batch 14 - reserved property name, varint cast doc, two-pass rationale, operator docs

- reject a property whose name equals the reserved default-field sentinel at full-text index creation (would be ambiguous on a multi-property index); fail fast instead of mis-scoring silently. New test.
- readEntryValue: document the tf/docLength varint->int narrowing (token counts fit in a signed int; widen here and at the write side if a larger statistic is ever stored)
- computeBM25Scores: replace the no-candidate 'TODO halve I/O' with the actual design rationale - the two-pass streaming is a deliberate memory-vs-I/O trade-off favouring bounded peak memory (single-pass would hold a common term's whole posting list alongside the score map); this path is the direct index.get() API, while the primary SQL SEARCH_INDEX path is already single-pass. The clean single-pass needs an LSM per-key entry count (larger change, tracked separately)
- RELEASE-27.7.1.md: add an operator note on REBUILD INDEX ... statsOnly for repairing/pre-warming corpus counters after bulk import, heavy rollbacks, or a pre-BM25 migration

Verified already-covered (no change): rollback-drift repair test and REBUILD INDEX * statsOnly wildcard test already exist; sumDocLength can't be cheaply validated in the session stale-check (would need a full scan) - documented; storeTermFrequency page-header flag and per-bucket-N/type-wide-avgdl remain documented design choices; IndexCursorEntry identity change already in the release notes.

Full-text + schema suites green (232; FullTextBM25Test 26).

* perf: [#4687] BM25 review batch 15 - throttle expansion warning, bias avgdl high under concurrency

- FullTextQueryExecutor: throttle the scoring-token-cap WARNING to once per 60s across the JVM (static AtomicLong + CAS) instead of once per query. A new executor is created per query, so a repeated huge wildcard would otherwise log on every execution; this matches the engine's 60s saturation-warning pattern
- FullTextIndexMetadata: order the corpus-counter writes so a concurrent avgDocLength() read is only ever consistent or biased HIGH (under-penalizing), never momentarily low. addDocument now writes sumDocLength before totalDocs (the counter avgDocLength reads first); by the JMM, observing the new totalDocs implies the new sumDocLength. removeDocument keeps totalDocs-first for the same safe-side bias. Documented the guarantee on avgDocLength()
- LSMTreeFullTextIndex: strengthen the storedKey-reuse comment to warn that it is safe only because get() consumes the array synchronously - a future async/lazy read path would need to revisit it

Acknowledged (no change): the no-candidate two-pass is a documented memory-vs-I/O trade-off on the non-primary index.get() path; double-checked locking on the shared metadata is correct; counter-drift maintenance via REBUILD INDEX ... statsOnly already documented and surfaced in the release notes.

Full-text + schema suites green (227).

* docs: [#4687] BM25 review batch 16 - explain get()/CONTAINSTEXT token semantics, fromJSON counter note, tests

- getBM25 Javadoc: explain that the direct get() path is intentionally token-based (not Lucene-parsed) because it also backs the SQL CONTAINSTEXT operator, whose argument is literal text - routing it through the Lucene parser would misread 'a-b'/'foo AND bar' and throwing would reject legitimate literal text. Corrected the caret description: StandardAnalyzer tokenizes 'java^3' into [java, 3], so it matches on 'java' with the ^3 silently ignored (not stripped to nothing)
- fromJSON: comment that counters are restored by setting fields directly (NOT setCounters()), so the load path does not consume the once-per-session stale-check that self-heals counters lagging the on-disk data after a restart
- test: directGetWithLuceneSyntaxIsGracefulNotLuceneParsed documents the get() behavior (caret query matches via tokenization, no crash, no boost applied)

Verified (no change): TempIndexCursor.getFloatScore returns the entry floatScore, which for CLASSIC is the integer coordination count widened to float (correct); no existing CHANGELOG/release-notes convention in the repo (only the GitHub-publish template), so RELEASE-27.7.1.md at root is reasonable.

Decision: did NOT route get() through the Lucene executor nor throw on Lucene syntax - both would break CONTAINSTEXT, which shares this path and needs literal-token semantics. SEARCH_INDEX remains the deliberate home for Lucene syntax.

Full-text + CONTAINSTEXT suites green (220; FullTextBM25Test 27).

* docs: [#4687] BM25 review batch 17 - counter-drift follow-up note, get() perf note, release-notes location

- countDocuments: document the deferred follow-up for rollback drift - deferring the update to an after-commit callback (TransactionContext.addAfterCommitCallback exists) would avoid it, but is left out because the counter must stay consistent across an HA cluster (the current update is replayed on every node; an after-commit callback fires only on the committing node and would diverge replicas). Point at recomputeBM25Counters() / REBUILD INDEX ... statsOnly as the repair
- getBM25 Javadoc: add the performance note that the direct path does 2*T posting scans (vs single-pass SEARCH_INDEX)
- RELEASE notes: state explicitly that the CLASSIC cursor-order change also affects direct Index.get() callers, not just SQL
- move RELEASE-27.7.1.md to docs/ (no root release-notes convention exists; co-located with the design doc)

Verified (no change): resolveStringParam at EXPLAIN time is safe - a variable that resolves to null yields a null query and getScoringExplain returns null (no SCORING line), and an execute() exception is caught by FetchFromIndexedFunctionStep (FINE); DEFAULT_FIELD stays package-private (the reserved-name test uses the literal string, not the constant); IndexCursorEntry contract change already in the release notes; EXPLAIN executor-per-call is bounded by the term cap.

Full-text + CONTAINSTEXT suites green (209).

* docs: [#4687] BM25 review batch 18 - compaction/factory comments, get() multi-term FINE log, release notes

- LSMTreeIndexCompacted: note that writeEntryMultipleValues clears the scratch buffer at the start of its loop, so the fresh-page retry re-serializes from a clean buffer (the partial content is discarded, not appended) - makes the compaction-fix invariant explicit at the retry site
- FullTextIndexMetadata constructor: note it defaults to BM25 and that defaultBM25() is just a self-documenting alias
- getBM25: log at FINE for a multi-term direct get() (it does 2*T posting scans) so operators debugging a slow query can switch to SEARCH_INDEX
- RELEASE notes: add the REBUILD INDEX * WITH <setting> first-setting-dropped fix to the bug-fix list

Verified false alarm: collectingExclusion IS wired - set true in collectTermsForExclusion and checked in recordScoringToken, so MUST_NOT terms are correctly excluded from scoring tokens.

Acknowledged (no change): storeTermFrequency page-header byte (on-disk format change, out of scope; documented); multi-bucket BM25 already covered by bm25RankingHoldsAcrossMultipleBuckets (BUCKETS 4); the once-per-session drift INFO log fires at most once per session (not per query); comment density matches the heavily-commented storage layer and encodes design rationale.

Full-text + CONTAINSTEXT suites green (209).

* docs: [#4687] BM25 review batch 19 - fix executor thread-safety doc, overflow/throttle comments, operational notes

- FullTextQueryExecutor: correct the class Javadoc - it is NOT thread-safe (it carries per-query mutable state); create a new instance per search and do not share across threads. resetState() is a defensive guard against sequential reuse on one thread, not support for concurrent reuse. (Replaces the incorrect 'This class is thread-safe' line.)
- writeEntryValues: document that the overflow check runs after the whole entry (RID + tf/docLength) is buffered, so an entry is never split; the trailing over-written entry on overflow is discarded because callers re-serialize the kept subset from a cleared buffer before committing
- lastExpansionWarnMs: note the throttle is JVM-wide / shared across indexes (a wildcard on one index can suppress the warning for another for the window) - acceptable for a diagnostic
- docs/4687: operational note that deleting after an analyzer change or field-nulling can drift avgdl; repair with REBUILD INDEX ... statsOnly
- RELEASE notes: broaden the IndexCursorEntry note to any Set/map-key caller (e.g. Index.get() results), not just plugin authors

Verified (no change): collectingExclusion IS wired (set in collectTermsForExclusion, checked in recordScoringToken); @tag(slow) coverage is appropriate (the heavy restart + multi-bucket tests are tagged; the rest run sub-second); writeToJSON already carries the epsilon comment; defaultConstantsStayInSyncWithMetadata self-enforces the duplicated defaults. The trackMissingReference/maxWarnings note is in non-BM25 code (CHECK DATABASE), out of scope for this PR.

Full-text + function suites green (204).

* fix: [#4687] BM25 review batch 20 - nested-negation exclusion, pure-negative warning, fromJSON reset, phrase docs

Correctness:
- collectTermsForExclusion: skip nested MUST_NOT clauses when collecting exclusion terms. A double negation like 'java -(database -tutorial)' (= java AND (NOT database OR tutorial)) previously excluded 'tutorial' documents too, wrongly returning empty; now the double-negated term is not excluded. New regression test
- FullTextIndexMetadata.fromJSON: default bm25_k1/bm25_b to the DEFAULT_BM25_* constants (not the current field values) so a key absent from the JSON resets to the default instead of carrying a stale value forward on a recycled instance

Observability:
- collectAllIndexedRids: a pure-negative query (only MUST_NOT) materializes the whole index to form the complement; log a throttled WARNING (60s, JVM-wide) when the universe exceeds PURE_NEGATIVE_WARN_THRESHOLD so operators notice the O(index) cost and add a positive clause

Docs:
- docs/4687: document that phrase queries are unordered (all-terms AND, no positional index) - '"java database"' and '"database java"' match the same documents

Note: full nested-boolean negation (representing the positive contribution of a double-negated term) would require general boolean evaluation and is out of scope; the fix at least stops wrongly excluding such terms.

Full-text + schema suites green (216; FullTextBM25Test 28).

* style: [#4687] BM25 review batch 21 - consistent pattern matching, EXPLAIN omitted-term count

- FullTextQueryExecutor: convert the remaining old-style instanceof+cast pairs in collectMatches/collectTermsForExclusion to Java pattern variables (instanceof final X y), matching the BoostQuery branch's style - consistent within the class
- explainScoring: when the term breakdown is truncated at MAX_EXPLAIN_TERMS, also report termsOmitted and termsShown so a user debugging a complex query knows the view is partial (not just a bare termsTruncated flag). Test asserts termsOmitted=16 for an 80-term expansion

Acknowledged (no change): storeTermFrequency split/compaction propagation confirmed solid; IndexCursorEntry keeps both score (int, used by all integer-scored indexes) and floatScore (full precision) public - a prior review chose Javadoc over @deprecated and the field is load-bearing across the index layer, so removing/deprecating it is out of proportion; the Javadoc already directs callers to floatScore when precision matters.

Full-text + function suites green (205).

* perf: [#4687] BM25 review batch 22 - single-lookup token recording, posting toString, pure-NOT test

- recordScoringToken: replace containsKey()+merge() (two hash lookups) with a single get() then a conditional put(); the cap still drops only new tokens once the limit is reached, and an existing token whose boost does not increase costs just one lookup
- FullTextPostingRID: add toString() including tf/docLength so serialization/scoring issues are easy to spot in logs/debuggers
- test: pureNegativeQueryReturnsComplement pins the pure-negative full-index-scan path (collectAllIndexedRids) - '-java' returns exactly the documents without 'java'

Acknowledged (no change): the CLASSIC matching path's Map<RID,AtomicInteger> predates this PR - AtomicInteger is one alloc per doc with mutable increments, so the suggested HashMap<RID,Integer>+merge would actually box on every increment; a full conversion to primitive cells across the executor is a broad future cleanup, while the BM25 path I added already uses double[]. The writeEntryMultipleValues buffer-clear invariant is regression-guarded by FullTextBM25CompactionTest (removing the clear corrupts postings -> test fails) plus the Javadoc. The double-negation test (batch 20), the statsOnly SQL test (batch 4), and the phrase-ordering docs note (batch 20) already exist.

Full-text + function suites green (206; FullTextBM25Test 29).

* fix: [#4687] BM25 review batch 23 - drift WARNING threshold, same-tx note, df=0 test

- ensureCounters: escalate the counter-drift log to WARNING (with a 'consider REBUILD INDEX ... statsOnly' hint) when the divergence exceeds 10% of the live count; small drift still self-heals quietly at INFO
- countDocuments: document that the pre-commit increment means a BM25 get() later in the SAME transaction sees the just-inserted (uncommitted) document in avgDocLength - marginal, resolves at commit, not a correctness issue
- BM25ScorerTest: add the df=0 edge case (term absent from corpus -> highest finite idf, above df=1)

Verified false alarm: the MAX_EXPANDED_SCORING_TERMS cap IS query-wide (checked against the single shared scoringTokens map across all subclauses), not per-subclause.

Acknowledged (no change, with reasoning):
- schema/index divergence sanity check: a tf<=docLength constructor guard looked clean but compaction may sum tf for duplicate (key,RID) postings, so a hard throw could false-positive on legitimate data; a heuristic open-time check gives false confidence. The robust fix is an on-disk format byte (out of scope); divergence stays documented (disaster-recovery note)
- two-pass get() at FINE: INFO would spam every multi-term get(); the path has no EXPLAIN (EXPLAIN is the candidate-based SEARCH_INDEX path). FINE is right
- background pre-warm on open: a DB-lifecycle/HA change out of proportion; manual REBUILD INDEX ... statsOnly is documented
- IndexCursorEntry.score @deprecated: inappropriate - score is the CORRECT field for every integer-scored index (LSM/hash/etc.), so deprecating would wrongly flag all those legitimate uses; the Javadoc already steers float-precision callers to floatScore (consistent with the earlier review decision)
- reserved __arcadedb_ field-name ban: the targeted exact-sentinel rejection at FT index creation already prevents the collision; a schema-wide prefix ban is a separate broader policy
- resolveTotalDocs naming kept (Javadoc is clear; 'validated' would mislead - it does not validate)

Full-text + function suites green (206; BM25ScorerTest 12).

* test: [#4687] BM25 review batch 24 - guarantee compaction split, pin REBUILD INDEX * setting parse

- FullTextBM25CompactionTest: drop the page size from 4096 to 1024 so the multi-page posting-list split is GUARANTEED, not incidental - 600 RIDs at ~6-8 bytes each (~3.6-4.8 KB) provably overflow a 1024-byte page several times, which is the spanning-multiple-compacted-pages condition the fix targets. Updated the class doc + sizing math comment (applies to both the BM25 and CLASSIC compaction tests)
- RebuildIndexStatementTestParserTest: add wildcardCapturesFirstSettingKey - parse 'REBUILD INDEX * WITH batchSize = 1000' and assert the setting actually reaches the AST settings map (checkRightSyntax only proves the grammar accepts it). Pins the firstSettingKeyIndex offset fix that previously dropped the first WITH setting on the * form; also sanity-checks the named form
- SQLFunctionSearchIndex: set $score via allResults.getOrDefault(rid, 0f) so it stays non-null even if the entry vanished between containsKey and get (cannot happen on today's single-threaded SQL path; makes the defensive intent explicit)
- docs/4687: note that EXPLAIN/PROFILE of a wildcard term walks the term index synchronously (the breakdown is capped but the expansion scan is not) - avoid broad-wildcard PROFILE in tight monitoring loops

Verified already-present: both expansion/pure-negative warning throttles already carry the JVM-wide/cross-index scope comment.

Full-text + function + parser suites green (208).

* fix: [#4687] BM25 review batch 25 - open-time counter warning, reload reserved-name guard, clearer errors

- LSMTreeFullTextIndex: warn at OPEN time (not only when the first query triggers it) when a BM25 index loads with no valid corpus counters, so operators can pre-warm with REBUILD INDEX ... statsOnly before serving traffic
- centralize the reserved-property-name check (collision with the parser default-field sentinel) into a public static checkReservedPropertyNames(); call it from BOTH the creation factory and the schema-reload path in LocalSchema, so a hand-edited/restored schema cannot reintroduce the collision undetected
- RebuildIndexStatement: clearer error for REBUILD INDEX <classic> WITH statsOnly - 'only BM25 full-text indexes keep corpus statistics; switch to BM25, or omit statsOnly to do a full rebuild'
- docs/RELEASE-27.7.1.md: drop the section-header emoji

Acknowledged (no change, with reasoning):
- IndexCursorEntry.score @deprecated: still inappropriate - score is the correct field for every integer-scored index, so deprecating would wrongly flag all those legitimate uses; the breaking change is in the release notes + class Javadoc, which steer float-precision callers to floatScore
- writeEntryValues overflow invariant re-verified: the only callers go through writeEntryMultipleValues, which clears the scratch buffer at the start of its loop and re-serializes the kept subset before committing - so trailing over-written bytes are always discarded (also regression-guarded by FullTextBM25CompactionTest at 1024-byte pages)
- trackMissingReference double lookup / formatTopMissingReferences magic number: in CHECK DATABASE code (PR #4653), not part of this BM25 PR - out of scope

Full-text + schema + parser suites green (494).

* fix: [#4687] BM25 review batch 26 - IndexInternal guard, safer key alloc, docs, drop slow tag

- RebuildIndexStatement.recomputeStatistics: instanceof-guard the IndexInternal cast on the named-index path so a custom Index that is not IndexInternal yields a clean 'does not support statistics recomputation' error instead of a bare ClassCastException
- computeBM25Scores: allocate the single-element lookup key per token again (revert the shared-array reuse). The reuse depended on get() consuming the array synchronously - flagged twice as a footgun; the few tiny arrays per query are negligible GC, so safety wins
- remove(): document the single-rid-per-call symmetry with put()'s per-rid addDocument loop (one document per remove call, so one removeDocument balances it; no caller passes N documents sharing a key)
- FullTextQueryExecutor: document why SEARCH_INDEX result entries carry empty keys (a multi-token Lucene query has no single key tuple; the SQL path reads only RID + $score, never getKeys())
- drop @tag(slow) from bm25RankingHoldsAcrossMultipleBuckets (measured ~0.3s; the tag was inconsistent with the untagged 600-doc compaction test)

Verified already-present: end-to-end SQL statsOnly test (statsRecomputed=1 + scores), and the descriptive forceCompaction().as(...) message.

Acknowledged (no change): the EXPLAIN RHS re-execution is swallowed by try/catch and only re-evaluates literal args in practice; DatabaseChecker streams/magic-number are CHECK DATABASE code (out of scope). S3 (aggregate the open-time cold-counter warning): kept per-index because it names the specific cold index (more actionable than a count) and the state is a transient, bounded post-upgrade condition; aggregating would mean surgery in the large readConfiguration method.

Full-text + parser suites green (208).

(cherry picked from commit a478394)
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.

Native BM25 full-text scoring (TF/IDF + length normalization, field boosts, explain)

1 participant