Skip to content

ContextSearch ranks the correct session out of the top 10 every time: English-only stopwords, unnormalised cross-source scores, no IDF #1755

Description

@xmasyx

ContextSearch returns the correct session 0 times out of 24 on a benchmark of 12 real work sessions queried two ways each. Median latency is fine (157 ms) — the ranking is the problem. Three independent defects in LifeOS/install/skills/ContextSearch/Tools/ContextSearch.ts combine to produce it, and all three are in shipped code, not local config.

Measurement

12 work sessions that exist in the corpus (121 registered sessions across two roots, ~1,200 conversation logs). Each queried two ways: natural (project name in the query — the easy ceiling) and vague (from the symptom, mechanically constrained to share zero non-stopword tokens with the target slug). Queries were frozen with a checksum before the first run; nothing was rewritten after seeing results. A shuffled-pairing A/A control scores 4% against 33% for true pairing, so the metric discriminates.

band recall@1 recall@3 recall@10
natural (full list, as shipped) 0% 0% 0%
vague (full list, as shipped) 0% 0% 0%
natural, stopwords stripped, conversation logs excluded 25% 33% 83%

The bottom row is the same tool with the two defects below neutralised, and it is what the fixes are worth.

Defect 1 — the stopword list is English-only, so non-English queries drown in their own grammar

ContextSearch.ts line 57:

const STOPWORDS = new Set([
  "the", "a", "an", "on", "in", "of", "to", "that", "it",
  "is", "was", "we", "you", "i", "and", "or", "for", "with",
]);

A user writing in any other language sends every function word into the query. In Italian, a normal question like "quando abbiamo riparato i caratteri sovrapposti nei PDF" searches for quando, abbiamo, i, nei — each matching thousands of log lines — and the two content words are buried under the noise.

Measured: mechanically stripping the query's function words moves recall@3 on the vague band from 0% to 17%, changing nothing else.

Same function, line 147:

.split(/[^a-z0-9]+/)

Accented letters are treated as separators, so accessibilità tokenizes to accessibilit and più to pi. Every language with diacritics silently loses word endings.

Suggested fix — Unicode-aware split, and stopword filtering that does not assume English:

.split(/[^\p{L}\p{N}]+/u)

For stopwords, the robust fix is to stop maintaining a word list at all and let IDF (defect 3) demote high-frequency tokens automatically — a function word has near-zero IDF by definition, in every language. If a list is kept as a fast path, it needs at minimum the major LifeOS user languages, and tokenize() should fold diacritics before lookup.

Defect 2 — source scores are on incomparable scales, so a session can never outrank a chat log

Two families of sources are merged into one ranking with raw, unnormalised scores.

Name-matching sources (work.json, work dirs, session names) score how many query tokens appear in a short string — bounded by query length, so 1–5 in practice:

const overlap = tokenOverlap(tokens, blob);
...
score: overlap * multiplier,     // lines 266, 310, 341

Content sources (ISA bodies, conversation logs) score how many lines of the file contain any query token, via rg -c — unbounded, hundreds to thousands:

const out = await ripgrep(pattern, WORK_DIR, ["--glob", "*ISA.md", "-c"]);   // line 386
...
score: info.hits * multiplier,   // lines 413, 548

They then compete in the same sorted list. The session whose directory is named exactly what you typed cannot win, by construction.

Concrete repro — searching for a session by its literal, unique directory name:

194  jsonl              <a conversation log>
 86  jsonl              <another conversation log>
 10  isa-body           <an unrelated session with a long ISA>
 10  work-dir+isa-body  <the session actually named after the query>   ← 4th

Across the benchmark, 70% of the top-10 slots are conversation logs. Excluding them is worth +25 points of recall@3.

Suggested fix — normalise within each source before merging, so each source contributes a comparable [0,1] signal, then apply an explicit per-source prior:

// after collecting each source's results, before the merge
function normalize(results: Result[], weight: number): Result[] {
  const max = Math.max(...results.map(r => r.score), 1);
  return results.map(r => ({ ...r, score: (r.score / max) * weight }));
}

// weights make the ranking policy explicit and tunable instead of accidental:
//   a session named after your query should outrank a chat that merely mentions it
const merged = [
  ...normalize(workJson,  1.0),
  ...normalize(workDirs,  1.0),
  ...normalize(isaBodies, 0.7),
  ...normalize(jsonl,     0.4),
];

Today those weights exist implicitly, set by whichever source happens to have the largest raw numbers.

Defect 3 — no IDF, so a common word counts as much as a distinctive one

rg -c with an alternation of all query tokens counts a line once whether it matched the rarest term or the most common one. Long documents therefore win regardless of relevance, and rare high-signal terms carry no extra weight.

Observed: for a query of three generic words plus one unique identifier that appears in exactly one session's directory name, a very long unrelated ISA that happens to contain the generic words throughout scores 28 and outranks the session literally named after the unique identifier, which scores 26.

Suggested fix — count per-token document frequency and weight by rarity, which also makes the stopword list redundant:

// per token, not one alternation regex for all tokens
const df = new Map<string, number>();
for (const t of tokens) df.set(t, await countFilesMatching(t, dir));

const N = totalDocs;
const idf = (t: string) => Math.log(1 + N / (1 + (df.get(t) ?? 0)));

// score a document by summing its per-token hits weighted by rarity,
// damped so a long file cannot win on length alone
score = tokens.reduce((s, t) => s + idf(t) * Math.log(1 + hits(doc, t)), 0);

log(1 + hits) is the length damping: the difference between 1 and 10 occurrences matters, the difference between 200 and 400 does not.

Defect 4 (consequence) — the score cannot express "not found"

Four control queries about work that never happened returned a top result scoring 2,210–3,164, squarely inside the 83–3,208 range of top-1 scores for queries whose target does exist. There is no threshold a caller could use to tell a hit from noise, so the tool always answers confidently. Defects 2 and 3 are the cause: unnormalised line counts make the absolute score a function of corpus size, not of match quality. Normalising (defect 2) makes an absence threshold meaningful for the first time.

Impact

The bug is worst for non-English users, who hit all four at once, but defects 2–4 are language-independent: an English install still ranks chat logs above the sessions those chats produced. Since ContextSearch is what the Algorithm and the /cs command rely on to recover prior work, silent 0% recall means the system reports "no prior work" for work that plainly exists — the same failure class as #1494 and #1498, one layer further in.

I'm happy to open a PR with the three fixes plus the benchmark harness if that's useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions