Query rewriting vs retrieval quality¶
Date: 2026-08-26 · Ticket: IX-4447 · Data: 500 production Langfuse tool-query-rewriter spans (2026-08-18 → 2026-08-25); three purpose-built eval datasets (query-rewriter-v1, query-rewriter-elliptic-v1, query-rewriter-elliptic-holdout) run against staging LightRAG.
Question¶
The chat pipeline rewrites the visitor's message before retrieval
(packages/ixchat/ixchat/utils/query_rewriter.py, policy in chat_routing.py).
Does that rewrite improve knowledge retrieval, and if not, what would?
The rewriter's only consumer is LightRAG retrieval. langgraph_retriever.py
confirms retrieval sees only the query string — conversation history is
explicitly not forwarded, and invoke() drops it if a caller passes it. So a
visitor turn like Business or Oui reaches the index with no context
whatsoever unless something puts it there. That is the case the rewriter exists
to serve.
Method¶
Three instruments, built in sequence as each one proved insufficient.
1. Production span audit. 500 tool-query-rewriter spans joined to their
sibling tool-lightrag-retrieval spans and to the final answer, to check
whether span-level defects propagate downstream.
2. A retrieval eval, rose-eval query-rewriter. Neither existing eval kind
measures the right thing: e2e judges the final answer (which the answer writer
repairs, because it receives full history independently), and feature runs on
one synthetic site. The new kind runs rewrite → real LightRAG retrieval →
context-sufficiency judge: an LLM judge reads the conversation, the visitor's
real information need, and the chunks LightRAG actually returned, then scores
0–10 whether those chunks let you answer. Deterministic hygiene checks
(truncation, markdown, language preservation, graft) run alongside for free.
Code in packages/ixevaluation/ixevaluation/rewriter/.
3. Datasets. All items are real production turns lifted from Langfuse, so
every (history, message) pair is one a visitor actually produced.
| dataset | n | composition |
|---|---|---|
query-rewriter-v1 |
50 | 10 clients × 5, tagged elliptical / pronoun / self_sufficient / topic_shift / no_retrieval |
query-rewriter-elliptic-v1 |
123 | elliptical turns only (≤5-word reply to a question the agent just asked), 22 sites, capped 14/site, tagged qualification / case_request / topic_shift / no_retrieval |
query-rewriter-elliptic-holdout |
110 | same 401-turn harvest, held out — never used to pick a threshold or a prompt rule |
Comparison discipline. Per-item delta stdev is ~0.20, so a 43-item run carries SE ≈ 0.03 — the same size as the effects being measured. Arms are compared paired per item over N≥2 runs, never mean-vs-mean. Later rounds added bootstrap CIs, an exact sign test, and a per-run noise floor measured by re-judging identical input.
Findings¶
1. The production rewriter is defective, and the defects are absorbed¶
Path split: 70% (349/500) of turns never reach the LLM at all — first turn falls
to a Python regex fallback that handles English you/your only, so it is a
no-op on FR/ES/IT traffic. 30% (151) take the LLM path (ministral-3b-latest);
2.6% of those time out at the 1s ceiling with max_retries=0.
Of the 143 non-empty LLM rewrites:
- 24% (34/143) truncate mid-word, comma or digit.
max_tokens=50is token-dense-language-hostile (~230–275 chars in French). The only guard islen(rewritten) < 2, so the fragment goes straight into the embed call. - 57% (81/143) contain
**bold**, 19% are wrapped in quotes — copied from the assistant's formatted messages, which_format_conversation_historypasses verbatim. Pure embedder noise. - 74% (111/151) are >4× longer than the input, median 144 chars for inputs of 2–15 chars.
- Language flips within a single conversation; greetings and gibberish get a
fabricated query (
hello→ a 200-char product question,sffs→ a full client query,todo→ a Spanish travel itinerary).
Cost: p50 451ms / p90 740ms on the LLM path, ≈147ms blended per turn on the retrieval critical path.
But downstream the damage is not visible. Retrieved document count is flat
across every rewrite category (27.5–29.5 docs, zero zero-doc retrievals) —
it saturates at chunk_top_k and cannot discriminate. KB-miss phrasing in the
final answer does not correlate with rewrite quality (truncated 0/31, full LLM
1/72, Python fallback 1/196). Reading the worst cases end to end, the answers
are correct: truncation removes the tail of an over-expansion the rewriter
itself invented, and the answer writer repairs the rest from history.
So the defects are real but the pipeline absorbs them. The rewriter is sloppy, not harmful — which reframes the question from "fix it" to "does it earn its 147ms at all".
2. No prose rewrite beats sending the raw query¶
query-rewriter-v1, 2 runs per arm, 43 judged items, paired:
| arm | sufficiency | vs off (paired) | better / worse / tie |
|---|---|---|---|
| off (raw query) | 0.430 | — | — |
| current (production) | 0.413 | −0.017 ± 0.030 | 9 / 15 / 19 |
| candidate (hygiene prompt) | 0.405 | −0.026 ± 0.021 | 7 / 11 / 25 |
| minimal (telegraphic) | 0.416 | −0.014 ± 0.030 | 12 / 13 / 18 |
The candidate prompt fixes every defect it targets — markdown failures 29 → 0,
truncation 23 → 0, inflation 21 → 5 — and retrieval does not move. Its one
regression explains why: the pass-through rule ("already stands on its own →
output unchanged") is too easy for a 3B model to reach for, so 11 of 26
elliptical/pronoun items come back unexpanded.
The eval independently reproduced the production defect rate (58% markdown here vs 57% measured on production spans), which is the dataset validating itself.
3. The metric had a bias, and finding it was the point¶
The v1 judge rubric contained an escape hatch — "if the message needs no
knowledge lookup, score 10". The judge applied it to elliptical replies:
Logiciel interne (a bare answer to "which payroll tool do you use?") scored the
do-nothing control 1.00 with the reasoning "no knowledge lookup was needed".
That inflated off on exactly the items where rewriting is mandatory.
v2 removes the hatch, states that an elliptical message still carries an
information need supplied by the conversation, and drops no_retrieval items
from the judged mean. Elliptical off fell 0.62 → 0.34 — the control is now
penalised where expansion is required, which was the acceptance criterion.
Of 43 judged items, 27 discriminate; 9 are floor items no arm can win (competitor questions, pure qualification turns, real KB gaps) and 7 are ceiling items.
4. Prose was being written for a consumer that throws it away¶
LightRAG has two retrieval channels (operate.py:_build_query_context):
- the query text drives the vector / chunk side,
hl_keywords/ll_keywordsdrive the graph side (entities, relations).
When keywords are not supplied, get_keywords_from_query spends its own LLM call
deriving them from the query text. The rewriter was therefore writing prose for a
consumer that immediately re-extracts keywords from it — a lossy round trip we
never controlled. All the prompt work was on the wrong side of it.
Same client, same message Santé:
LightRAG's own keywords ['Santé', 'Skello']
→ top chunk: "Odento is a client of Skello in the health sector" (a client name)
supplied ['santé', 'tarifs'] / ['pricing']
→ top chunk: "Santé et Bien-Être is a sector where Skello provides
optimized scheduling solutions…" (the answer)
A rival hypothesis — that verbose rewrites inflate the extracted keyword count and fan the graph budget out — did not hold: correlation between rewrite length and extracted keyword count is +0.17. The extractor normalises. The problem was never keyword volume, it was having no say in which keywords.
So: stop rewriting the query, resolve straight into keywords. The visitor's own message keeps driving the vector side; resolved keywords drive the graph side.
| arm | sufficiency | vs off (paired) | better / worse / tie |
|---|---|---|---|
| keywords | 0.477 | +0.047 ± 0.016 | 16 / 4 / 23 |
| fused (raw + keywords appended) | 0.494 | +0.064 ± 0.033 | 18 / 11 / 14 |
| both (2nd LLM call for prose) | 0.460 | +0.030 ± 0.030 | 15 / 10 / 18 |
This table did not replicate — see section 10
These arms were scored while the judge was shown each arm's graph keywords
appended to the query text, which the control arms had nothing equivalent to.
With that annotation removed, keywords scores −0.015 ± 0.028 against the
raw query on the same dataset. The mechanism described below is real and the
mode split holds; the headline number does not.
By mode:
| mode | n | off | current | keywords |
|---|---|---|---|---|
| elliptical | 21 | 0.32 | 0.36 | 0.42 |
| pronoun | 5 | 0.72 | 0.48 | 0.64 |
| self_sufficient | 14 | 0.53 | 0.50 | 0.55 |
| topic_shift | 3 | 0.27 | 0.27 | 0.33 |
keywords wins on elliptical — the mode that justifies having a rewriter at
all. fused has the higher mean but loses head-to-head against keywords
(+0.014 ± 0.028, 10 better / 13 worse): more items get worse than better, so the
headline number is noise. keywords is the arm with only 3 losses out of 43.
both confirms the through-line: paying a second LLM call for resolved prose on
the vector side scored below free keyword-appending. Prose loses on both
channels.
Side effect: supplying keywords skips LightRAG's own extraction call. Median retrieval 1.86s → 1.62s (n=6), roughly offsetting the resolver's own call.
5. Lite-tier clients have no graph, and today's rewriter hurts them¶
TIER_TO_RAG_MODE = {"lite": "naive", "advanced": "global"}, and naive_query
calls _get_vector_context(query, …) and nothing else — no keyword extraction,
no graph. On a lite client, supplied keywords are ignored outright.
--rag-mode naive forces the mode so the same turns stand in for a lite client:
| arm | sufficiency | vs off | better / worse |
|---|---|---|---|
| off | 0.505 | — | — |
| current (production) | 0.460 | −0.044 ± 0.039 | 8 / 14 |
| keywords | 0.516 | +0.012 ± 0.018 | 6 / 4 |
| fused | 0.514 | +0.009 ± 0.029 | 10 / 8 |
Two things fall out. Today's rewriter is actively hurting the tier paying
least. And this looked like a free calibration of the instrument: keywords
produces a byte-identical query on 43/43 items under naive (as it must, with no
graph) and still drifted +0.012, suggesting anything under ~0.02 was judge noise.
That floor is too low, and section 10 shows why. Identical query text does not give identical passages: when no keywords are supplied, LightRAG derives its own with an LLM call, so the control arm's retrieval is itself non-deterministic. Measuring the floor by re-judging the same passages (0.009) prices only the judge, not the retrieval it is judging.
6. Additive beats substitutive — six strategies, two model tiers¶
Testing whether better prose could beat the raw query on the chunks-only path:
| arm (naive mode) | vs off |
|---|---|
| minimal (telegraphic) | −0.067 |
| current (production prose) | −0.052 |
| candidate (clean resolved question) | −0.043 |
| HyDE, gpt-oss-120b | −0.044 |
| HyDE, ministral-3b (declarative) | −0.019 |
| fused (raw + keywords) | +0.001 |
| off (raw) | 0.000 |
A 40× bigger model made it worse, not better. It writes better prose and more confident invented specifics; on an embedding channel a fluent wrong passage is worse than a crude one. That falsified the second mechanistic theory (after keyword fan-out): the ceiling was not resolver precision either.
Everything that replaces the visitor's text loses. The only thing reaching parity is the one that keeps their text and adds to it. Consistent with the graph result, which won by adding a channel rather than by altering what the visitor wrote.
The mechanism behind the asymmetry: a wrong keyword on the graph side simply fails to match an entity (harmless); the same keyword on the embedding side drags the vector off-target (harmful). Same input, opposite risk profile — which is why the identical resolver is worth +0.050 on graph and 0.000 on chunks.
7. Elliptical turns are a different, worse regime — and the fix is a gate¶
Elliptical turns are where the raw query is most useless, and every arm so far
sat flat there. Isolating them (query-rewriter-elliptic-v1, 123 items) gives a
baseline of 0.369 vs 0.513 on the general set: not a weak spot within
retrieval, a separate regime.
The failing mechanism turned out to be speech act, not topic. When the agent
asks "shall I share a customer example?" and the visitor types Oui, every arm
resolved the subject (healthcare HR) and dropped the request (give me a
customer example) — answering "what is this about" when the question is "what are
they asking for".
An intent arm that resolves the speech act splits hard by mode:
| mode | n | off | intent | better / worse |
|---|---|---|---|---|
| qualification | 66 | 0.27 | 0.35 | 24 / 5 |
| case_request | 8 | 0.35 | 0.45 | 2 / 0 |
| topic_shift | 20 | 0.63 | 0.40 | 1 / 10 |
Large win on the dominant mode (70% of elliptical turns), disaster on
topic_shift — where the visitor ignored the question and asked something new,
the raw query is already a real question and resolving damages it.
A length heuristic separates the modes almost as well as a classifier, for none of the cost:
| mode | mean words | median |
|---|---|---|
| qualification | 2.8 | 3 |
| case_request | 2.6 | 3 |
| topic_shift | 4.0 | 5 |
| reply length | n | delta (intent − off) | better / worse |
|---|---|---|---|
| 1 word | 19 | +0.189 | 9 / 2 |
| 2 words | 19 | +0.074 | 7 / 1 |
| 3 words | 14 | −0.014 | 0 / 1 |
| 4 words | 21 | +0.038 | 9 / 3 |
| 5 words | 21 | −0.186 | 2 / 8 |
| gate | delta | resolves |
|---|---|---|
| none (always resolve) | +0.018 | 94/94 |
| ≤4 words | +0.060 | 73/94 |
skip if reply ends with ? |
+0.068 | 74/94 |
Both gates triple the effect and select nearly the same population (73 vs 74) —
they are two probes of the same thing, since a 5-word elliptical reply usually
is a question. Shipped rule: ≤4 words AND does not end with ?
(query_keywords.should_resolve). Two lines, no classifier, no extra LLM call.
8. A prompt defect was suppressing the signal, and fixing it exposed a smaller one¶
The intent prompt asked the model to classify into four numbered cases and then
write a query. ministral-3b sometimes emitted the classification instead:
'5 à 10 ans' → '2. The assistant asked for the duration of the curre…'
'Beauté' → '2. The assistant asked for a specific type of exampl…' (−0.40)
4 of 20 gate firings were scaffold leakage — 20% garbage output. Making the
classification implicit and validating the output fixed it 4 → 0, and the broken
items recovered (Beauté 0.00 → 0.60). The aggregate did not move, because
the prompt edit changed every resolution, not just the broken three — including
removing a lucky accident (Around 500 leads → "2000 credits", the literal
answer, which happened to sit on top of the right chunk). At 16 fired items a
single 0.80 swing is ±0.05 on the mean, so both the before and after numbers were
noise.
Rewriting the prompt against the measured features — mandate the company name and the visitor's own words (enforced in code, not merely requested), target 5–7 words instead of "at most 12" — doubled the effect and halved the variance:
| prompt | mean delta | better / worse | SE |
|---|---|---|---|
| intent (old) | +0.018 | 27 / 15 | ±0.032 |
| gated (new) | +0.036 | 18 / 6 | ±0.017 |
Mean rewrite length dropped 7.6 → 3.7 words, and topic_shift returned to 0.63
with 0 better / 0 worse — the gate leaves those turns untouched by
construction, closing the worst failure mode structurally rather than by hoping
the model behaves.
9. Held-out replication¶
Methodology hardening before the final read: a held-out dataset never used for
tuning; the judge display leak fixed (graph keywords are now shown only when the
graph actually ran — under naive they were annotated but ignored, which was
inflating an identical-input arm); bootstrap CI and an exact sign test replacing
mean ± SE; and the noise floor measured in the same run rather than inferred.
query-rewriter-elliptic-holdout, 86 judged items, 2 runs per arm:
off 0.281
gated 0.322
delta +0.041 95% CI [+0.005, +0.078] sign test 21+ / 10− p = 0.071
judge run-to-run on identical input: mean |diff| 0.009, moved on 4/86 items
The tuning set gave +0.036; the held-out set gives +0.041 — the effect did not shrink, which is what usually happens to an in-sample number. The CI excludes zero; the sign test is marginal (the mean is carried partly by size-of-win, not only count-of-wins). The effect is ~4.5× the directly measured noise floor.
Baseline here is 0.281, lower than the tuning set's 0.353, because the holdout is
pure qualification — the hardest mode. 0.28 → 0.32 on the worst band in the
product.
10. Re-measured after the instrument was corrected — the graph result needed a gate¶
A code review found that the judge was being shown each keyword arm's resolved keywords appended to the query text, while the control arms had no equivalent annotation. That is a difference between arms which is not a difference in the passages being scored, and it is the arm-selection signal the whole comparison rests on. The keywords are now reported on the result object only; the judge sees the same query text for every arm.
Everything in section 4 was re-run under the corrected judge. query-rewriter-v1,
43 judged items, 2 runs per arm, paired:
| arm | sufficiency | vs off | better / worse / tie |
|---|---|---|---|
| off (raw query) | 0.462 | — | — |
| current (production) | 0.434 | −0.028 ± 0.033 | 8 / 16 / 19 |
| keywords (resolve every turn) | 0.447 | −0.015 ± 0.028 | 10 / 11 / 22 |
| keywords-gated | 0.470 | +0.008 ± 0.017 | 9 / 8 / 26 |
The +0.047 does not survive. Resolving every turn is no better than sending the raw query. What the corrected measurement shows instead is a clean mode split:
| mode | n | off | keywords | keywords-gated |
|---|---|---|---|---|
| elliptical | 21 | 0.357 | 0.414 | 0.414 |
| topic_shift | 3 | 0.267 | 0.367 | 0.333 |
| self_sufficient | 14 | 0.546 | 0.507 | 0.507 |
| pronoun | 5 | 0.780 | 0.460 | 0.680 |
The gain is entirely on elliptical turns; the loss is entirely on turns whose raw
query was already the best in the dataset. pronoun collapses from 0.780 to
0.460 — and those five items are not what the label suggests. Their only pronoun
is a possessive naming the company (votre logiciel, my traffic, avez-vous),
which retrieval already binds, because it queries Question about "X": …. There
is nothing to resolve, but the resolver cannot decline, so it invents a frame:
It read bureaux as office space, not a company's office count. Score 1.00 → 0.00, identically in both runs, and the judge is explicit about why: on the raw query "the passages directly provide that: Orisha has 20 offices"; on the resolved keywords "the passages are about product/platform concepts… nothing here helps".
This falsifies the asymmetry claimed in section 6. A wrong keyword on the
graph side was described as harmless — it simply fails to match an entity. It is
not: the graph spends a fixed top_k, so wrong keywords match something and
displace the entities the raw query was already retrieving. Retrieved document
count moved 16 → 21 on that item while the answer left the result set.
Applying should_resolve — already shipping on the chunks-only path — to the
graph channel recovers most of it: pronoun −0.320 → −0.100, elliptical gain kept
in full, and the arm becomes the best of the four. It fires on 16 of 43 items and
scores +0.056 where it fires, 6 better / 1 worse.
Out-of-sample, on a gate-dense dataset. query-rewriter-v1 contains only 16
gate-eligible turns, which is why its overall number stays inside the noise. The
held-out elliptical set is the right instrument — 83 judged qualification items,
graph-capable clients, 2 runs per arm:
off 0.294
keywords-gated 0.326
delta +0.032 ± 0.020 18 better / 11 worse / 54 tie
gate fired on 68/83 items; on those: +0.038 17 better / 10 worse
A better noise control. The 15 items where the gate declines are a control
built into the run itself: their retrieval input is byte-identical to off. They
moved by +0.003, on 2 of 15 items. On query-rewriter-v1 the same control moved
10 of 27 items by up to 0.20 — worth knowing, because it means identical query
text does not give identical passages. With no keywords supplied LightRAG
derives its own with an LLM call, so the control arm's retrieval is itself
non-deterministic, and the 0.009 floor quoted in section 9 prices only the judge.
The lite-tier result re-verified. Section 9's +0.041 was measured before the
display leak was fully closed, so it was re-run from scratch under the corrected
judge, same held-out dataset, retrieval forced to naive:
| section 9 | re-measured | |
|---|---|---|
| off | 0.281 | 0.293 |
| gated | 0.322 | 0.339 |
| delta | +0.041 | +0.046 ± 0.016 |
| 95% CI | [+0.005, +0.078] | [+0.018, +0.078] |
| better / worse | 21 / 10 | 20 / 7 |
It replicates and tightens: the confidence interval still excludes zero and its lower bound moved up. The gate fires on 52 of 83 items and scores +0.071 where it fires, 18 better / 6 worse. The 31 items it declines are the in-run noise control — they moved +0.003, on 3 items.
And the two channels turn out to be interchangeable. If the gate is what matters, then on a graph-capable client the text resolver built for the lite tier should do just as well as the keyword channel. It does — same 83 held-out items, same two runs, client-default retrieval modes:
| arm | sufficiency | vs off | 95% CI | better / worse |
|---|---|---|---|---|
| off | 0.294 | — | — | — |
| gated keyword channel | 0.326 | +0.032 ± 0.020 | [−0.004, +0.073] | 18 / 11 |
| gated text resolver | 0.327 | +0.033 ± 0.020 | [−0.006, +0.073] | 19 / 11 |
| gated text vs gated keywords | — | +0.001 ± 0.023 | [−0.045, +0.046] | 18 / 16 |
Head to head they are a coin flip. The gate is the intervention; the channel it resolves into is not. That reverses the framing of section 4, which treated the graph channel as the discovery and the gate as a later refinement. The graph channel's remaining advantage is cost, not quality: supplying keywords skips LightRAG's own extraction call, which the text resolver still pays.
So the two claims came out of the correction differently. The chunks-only path is confirmed: +0.046, CI excluding zero, on the mode that dominates elliptical traffic. The graph path is directionally positive by the same amount through either channel, but its confidence interval includes zero (sign test p = 0.265), and two runs of 83 items cannot close that — the per-item spread, not run-to-run noise, is what limits it, so the fix is more held-out items rather than more runs.
11. Powered properly, on 325 held-out items¶
Every number up to here was underpowered. With a per-item delta stdev of ~0.15, 83 items resolve only an effect of ~0.05 at 80% power, while the effect under test is ~0.03 — so "CI includes zero" was never evidence of absence. And more runs cannot fix it: decomposing the holdout variance, run-to-run noise is 4% and item-to-item difference 96%, so two runs give SE 0.0203 and eight give 0.0201. Items are the only lever.
query-rewriter-elliptic-v2 adds 242 judged items harvested fresh from
production and deduped against every earlier set (154 candidates were already
seeded). Pooled with the holdout, the detectable effect drops to ~0.023.
Lite tier (chunks only) — confirmed:
| set | n | off → gated | delta | 95% CI | better / worse | p |
|---|---|---|---|---|---|---|
| holdout | 83 | 0.293 → 0.339 | +0.046 | [+0.018, +0.078] | 20 / 7 | 0.019 |
| elliptic-v2 | 242 | 0.316 → 0.342 | +0.025 | [+0.006, +0.045] | 53 / 29 | 0.011 |
| pooled | 325 | — | +0.030 ± 0.008 | [+0.014, +0.047] | 73 / 36 | 0.001 |
The effect (0.030) now exceeds the detectable threshold (0.023), the CI excludes zero on both sets independently, and the sign test clears 0.05 on each. Where the gate fires — 116 of 242 turns — it is +0.054, 47 better / 19 worse.
Graph tier — positive but still unresolved:
| set | n | delta | 95% CI | better / worse | p |
|---|---|---|---|---|---|
| holdout | 83 | +0.032 | [−0.004, +0.073] | 18 / 11 | 0.265 |
| elliptic-v2 | 242 | +0.014 | [−0.006, +0.034] | 46 / 33 | 0.177 |
| pooled | 325 | +0.018 ± 0.009 | [+0.000, +0.037] | 64 / 44 | 0.067 |
Both headline numbers shrank on more data, which is what a marginal detection does: the graph tier halved (+0.032 → +0.014) and the lite tier fell by nearly half (+0.046 → +0.025). The lite effect survived that shrinkage; the graph one did not clear the bar, and settling it would take ~635 items.
The instrument is sound. The turns the gate declines are byte-identical to the
control by construction, and they moved −0.001 (lite, 126 items) and −0.004
(graph, 78 items). And topic_shift — the mode where resolution used to cost
−0.23 — sits at −0.000 on 40 items. That failure is closed by construction.
Verdict¶
| tier | retrieval mode | strategy | effect (325 held-out items) | status |
|---|---|---|---|---|
| lite | naive (chunks only) |
gated text resolver | +0.030, CI [+0.014, +0.047], 73 / 36, p=0.001 | confirmed |
| advanced | mix / global (graph) |
gated keyword channel | +0.018, CI [+0.000, +0.037], 64 / 44, p=0.067 | positive, unresolved |
Both beat today's production rewriter, which is negative on the lite tier
(−0.044) and negative on the advanced tier (−0.028 on query-rewriter-v1). That
part is not in question: the current rewriter is worse than doing nothing on
both tiers, and turning it off is a strict improvement independent of anything
that replaces it.
One intervention, not two. The gate is what works. Resolving only turns the visitor could not have searched as written helps on both tiers; the channel it resolves into does not matter (+0.001 ± 0.023 head to head). Resolving every turn is worthless-to-harmful, which is what nine rounds of prompt work kept rediscovering without naming.
How much traffic this touches. Production tool-query-rewriter spans carry
the visitor's message and the history length, so the gate can be replayed over
real turns. Two pulls of recent production spans (3000 spans, and 1450 spans over
1442 distinct traces, 2026-08-25 → 08-27):
| bucket | share |
|---|---|
| first turn — nothing to resolve, never fires | 44% |
| multi-turn, searchable as written — gate declines | 33–35% |
| gate fires | 19–23% |
So roughly one retrieval turn in five is eligible, and a third of all turns are ones where today's rewriter runs, measurably hurts, and the gate would stop it running at all. Traffic-weighted, the retrieval win lands on about 7% of turns and the regression on about 2%; the cost saving lands on every declined turn.
Caveat on transferring the effect size: the held-out set is elliptical qualification turns specifically, while the production gate-eligible population is a broader mix of one- to four-word replies. Direction should transfer, magnitude is not guaranteed to.
Scale of it, stated honestly. +0.046 is not every item improving a little. On the lite tier: 56 of 83 items unchanged, 20 improved, 7 regressed; the gate declines a third of turns outright and those are identical by construction. Where it fires, +0.071. So roughly one turn in four gets meaningfully better retrieval, one in twelve gets worse, two-thirds are untouched — real, replicated, and not a visibly better bot. It is worth shipping because the cost is negative: the gate removes today's rewriter call on every turn it declines.
Falsified along the way, each with its own measurement:
- The prompt is the problem — the hygiene prompt fixed every defect and moved retrieval by nothing.
- Verbose rewrites fan out the graph budget — length/keyword-count correlation +0.17.
- Resolver precision is the ceiling — a 40× larger model scored worse.
- Enrichment format is the ceiling — four forms tested under
naive, all substitutive forms lost.
The surviving generalisation: additive beats substitutive. The visitor's own words are the highest-precision signal in the system; every rewrite trades that away for context, and on the chunks channel the trade never pays.
What this does not measure¶
- Not answer quality. Measured separately at the outset and flat — the answer writer receives history independently and repairs bad retrieval, which is exactly why the metric stops at retrieval.
- The judge cannot exceed the KB. 9 of 43 items are floor items no arm can win. Being paired, they dilute the mean without biasing the comparison.
- Self-consistency, not ground truth. No human labelled these items. A judge systematically wrong about a client's domain is wrong for every arm equally — pairing protects the comparison, not the absolute 0.51. Before anyone treats a sufficiency score as a KPI, hand-label a subset and check judge agreement.
- Staging KBs, two runs, one judge. Enough to rank arms; not enough to declare a production number.
Shipping state¶
Production behaviour remains unchanged until this branch is merged and deployed.
The branch sets query_rewriter.enabled = true in every environment TOML;
deployment activates tier-aware resolution for every tenant in that environment.
Turning the sole switch off sends the visitor's message through untouched; it
does not restore the legacy prose rewriter.
ixchat/utils/query_keywords.py— resolves the message intoSPECIFIC:/THEME:lists plus theshould_resolvegate. Falls back to LightRAG's own extraction on first turn, on LLM failure, on an unparsable response, and on a half-filled response:globalmode readshl_keywordsalone andlocalreadsll_keywordsalone, so returning one channel would silently strip the graph side for whichever mode needs the other.ixchat/nodes/knowledge_retriever.py— routes onhas_graph = retriever.rag_mode != "naive". Graph-capable clients get the keyword channel; lite clients get the gated resolver (ixchat/utils/query_intent.py), which resolves only turns that cannot be searched as written and leaves everything else untouched.ixrag/lightrag/langgraph_retriever.py—ainvoke(query, ll_keywords=…, hl_keywords=…). Additive; callers passing nothing are unaffected.ixevaluation/rewriter/— the eval kind and its arms (production,off,legacy/current,candidate,minimal,keywords,fused,both,hyde,intent,gated). The defaultproductionarm mirrors the shipping tier branch;legacyreproduces the retired prose rewriter. Runs are recorded as Langfuse dataset runs with per-item scores, and a run that drops or errors on any item is reported incomplete rather than scored — a partial run is a different dataset, not a weaker result.- Three seed commands under
apps/cli/cli/langfuse/commands/; datasets live in Langfuse and are re-seedable. Every item is a real production turn with visitor PII and credential-bearing URLs replaced with synthetic values, enforced byapps/cli/tests/unit/test_seed_datasets_pii.py.
Both tier strategies are enabled in development, test, staging, and production configuration on this branch. Lite's powered effect is confirmed; the graph estimate remains positive but unresolved and should be monitored after deploy.
Reproducing¶
# shipping behavior (the default arm); force naive to model a lite tenant
rose-eval query-rewriter --arm production --env staging
rose-eval query-rewriter query-rewriter-elliptic-holdout --arm production --rag-mode naive --env staging
# control, ~50s on the 50-item set
rose-eval query-rewriter --arm off --env staging
# graph channel: `keywords` resolves every turn, `keywords-gated` is what ships
rose-eval query-rewriter --arm keywords --env staging
rose-eval query-rewriter --arm keywords-gated --env staging
# the held-out set is the instrument for anything gate-related — the 50-item set
# has only 16 gate-eligible turns, too few to move a mean
rose-eval query-rewriter query-rewriter-elliptic-holdout --arm keywords-gated --env staging
rose-eval query-rewriter query-rewriter-elliptic-holdout --arm gated --rag-mode naive --env staging
Two runs per arm, minimum, compared paired per item. Read the items the gate declines as the in-run noise control: their retrieval input is identical to the control's by construction, so whatever they drift by is the resolution limit of that run.
Implementation notes and per-round detail: docs/plans/query-rewriter-eval.md.