Skip to content

fix(recall): stabilize relevance threshold and CJK domain inference - #278

Open
m0Nst3r873 wants to merge 1 commit into
Tencent:mainfrom
m0Nst3r873:feature/recall-idf-domain-isolation
Open

fix(recall): stabilize relevance threshold and CJK domain inference#278
m0Nst3r873 wants to merge 1 commit into
Tencent:mainfrom
m0Nst3r873:feature/recall-idf-domain-isolation

Conversation

@m0Nst3r873

Copy link
Copy Markdown
Collaborator

Summary

Two measured defects in recall's relevance gating, plus a regression baseline for a third that is deliberately deferred.

Both fixes came out of a real incident investigation where recall was of no help — the root causes turned out to be mechanical, not knowledge gaps.

1. --check compared a hardcoded absolute score against two incompatible scales

RECALL_RELEVANCE_THRESHOLD = 4.0 was applied to whichever result sorted first, but allResults mixes:

Source Scale Drifts with corpus size?
learnings (search()) unbounded TF-IDF sum yes — IDF numerator is total entry count
codebase graph (recall.ts ~L313) min(10, log2(s+1)*2), bounded [0,10] no

Measured drift: adding 12 unrelated-domain documents moved one entry from 14.0 → 30.6 (+119%) without its own content changing.

Verdicts are now taken per source — codebase keeps the absolute threshold, learnings normalizes against computeIdfBaseline() (the IDF of a single-occurrence token).

Cold-start regression this surfaced

The relative cutoff alone made small corpora looser, which is the opposite of what the initial framing assumed:

N relative cutoff old 4.0 newly passing
1 1.35 4.0 single title match (2.55), single tag (1.70)
2 1.90 4.0 3.58 / 2.39
5 2.83 4.0 single tag match (3.57)
≥25 4.81+ 4.0 none (new is stricter)

That lands squarely on new users and new projects. LEARNINGS_ABSOLUTE_FLOOR keeps the stricter pre-existing behavior until the corpus is large enough (N ≥ 7) for the ratio to exceed it on its own.

2. inferQueryDomain was unreachable for Chinese queries

The three tag vocabularies contained only ASCII entries, so any pure-Chinese query scored zero across all three and fell back to 'neutral' — making the technical / ops / support rows of DOMAIN_WEIGHT dead code for CJK users.

Chinese entries added. Only 2-char words, deliberately:

  • the tokenizer emits bigrams, so single chars are too ambiguous (, , )
  • 3+ char words are unreachable by construction数据库 only ever yields 数据 / 据库
  • cross-domain terms excluded (配置, 失败, 服务, 文档) — 文档 in particular would hijack 接口文档更新 and 故障复盘文档 into support

Verified after the change: 接口文档更新technical, 部署流程文档ops, 数据库连接池超时technical.

3. Deferred: cross-domain IDF pollution (regression baseline only)

The invariant "adding entries of one domain must not change scores in another" does not hold today. df and N are global (search-index.ts L549-554, L641), so any new domain reprices shared tokens.

Fixing it means partitioning IDF per domain → SEARCH_INDEX_VERSION 6→7 → full index rebuild for every user. Deferred, and the case is marked it.fails() so CI stays green while the contract is retained — if the assertion ever starts passing, it.fails() reports it and the case can be promoted to a plain it().

Measured effect, which is why it was deprioritized:

[idf-drift] N: 3 -> 15
[idf-drift] shared    "timeout":  df 1->13 | idf 1.6931 -> 1.1335  (-33.1%)
[idf-drift] exclusive "techonly": df 3->3  | idf 1.0000 -> 2.3863 (+138.6%)

Absolute scores shift substantially, but same-domain ordering is preserved — IDF is a per-token multiplier that scales all candidates for a given query alike. The real damage was therefore concentrated in the absolute-threshold comparison, which is what §1 fixes.

Test plan

  • tsc --noEmit clean
  • Full suite 1913/1913 passing, 146 files
  • New: recall-relevance-threshold.test.ts — floor vs. relative governance either side of the N≈7 cross-over, legacy/no-df fallback, idfBaseline=0 defense, computeIdfBaseline excluding legacy indexes when picking max N
  • New: search-idf-domain-isolation.test.ts — global-df characterization, IDF drift measurement, CJK domain resolution (both directions: ratio 2.0 = 1.0/0.5 technical-query, 1.4286 = 1.0/0.7 ops-query), plus the deferred it.fails() invariant with fixture sanity split into its own it() so it cannot pass vacuously
  • --check stdout format byte-identical (RELEVANT score=X.X\n) — asserted by existing recall-check.test.ts
  • SEARCH_INDEX_VERSION and DOMAIN_WEIGHT values untouched; no index rebuild triggered

Known limitations recorded, not fixed

  • allResults.sort still compares the two scales directly — as the corpus grows, learnings hits increasingly crowd out codebase hits regardless of true relevance. Marked with a TODO(cross-scale) at the sort site; arguably worth more than the per-domain IDF work.
  • computeIdfBaseline takes the max entry count across scopes — a conservative approximation. Precise handling means carrying each index's baseline alongside its results; noted in the docstring.
  • 内存 / 资源 / 权限 / 证书 / 磁盘 are classified ops, so technically-framed memory questions get the ×0.7 cross-domain penalty. Known bias, left as-is.

🤖 Generated with Claude Code

Two defects in recall's relevance gating, both measured rather than
inferred, plus a regression baseline for a third that is deferred.

1. --check compared a hardcoded absolute score (4.0) against results
   drawn from two incompatible scales. Learnings scores are unbounded
   TF-IDF sums whose IDF numerator is the total entry count, so they
   drift with corpus size: adding 12 unrelated documents moved one
   entry from 14.0 to 30.6 (+119%). Codebase scores are log-compressed
   into [0,10] and do not drift. Verdicts are now taken per source --
   codebase keeps the absolute threshold, learnings normalizes against
   the IDF baseline of a single-occurrence token.

   The relative cutoff alone regressed cold starts: at N<=5 a lone tag
   match scored 1.7-3.6 and would newly pass where 4.0 rejected it, so
   LEARNINGS_ABSOLUTE_FLOOR keeps the stricter behavior until the
   corpus is large enough (N>=7) for the ratio to exceed it.

2. inferQueryDomain matched only ASCII tag entries, so every Chinese
   query scored zero and fell back to 'neutral' -- the technical/ops/
   support rows of DOMAIN_WEIGHT were unreachable for CJK users. The
   three vocabularies now carry Chinese entries. Only 2-char words are
   added: the tokenizer emits bigrams, making single chars ambiguous
   and 3+ char words unreachable by construction.

Also adds a regression baseline for cross-domain IDF pollution. The
invariant "adding entries of one domain must not change scores in
another" does not hold today; fixing it requires partitioning IDF per
domain, which bumps SEARCH_INDEX_VERSION and forces a full rebuild.
That work is deferred, so the case is marked it.fails() -- CI stays
green, and if the assertion ever starts passing, it.fails() reports it
and the case can be promoted to a plain it().

Measurements show the pollution shifts absolute scores (shared tokens
-33%, domain-exclusive tokens +139%) but preserves same-domain ordering,
since IDF scales all candidates for a given query alike. That is why
the threshold fix above is the higher-value half of the pair.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant