Query resolution for retrieval¶
How a visitor's message becomes the input to knowledge retrieval, and why that is a decision with more than one right answer.
Measurements behind every design choice here live in Query rewriting vs retrieval quality. This page describes the architecture; that page says why it is shaped this way.
The constraint everything follows from¶
Retrieval sees only the query string. LightRAGRetriever deliberately does not
forward conversation history — only_need_context=True returns before LightRAG
would ever read it, and invoke() drops a conversation_history kwarg if a caller
passes one.
So when a visitor answers a question with Santé, Oui or BTP, that is the
entire input the knowledge base gets. Nothing downstream can recover the context,
because nothing downstream is given it. Resolving that reference before retrieval
is the only opportunity.
The answer writer is a different matter: it receives the full conversation independently, which is why a bad retrieval often still produces an acceptable answer, and why quality here is measured on retrieved passages rather than on the final reply.
LightRAG has two retrieval channels¶
This is the part that is easy to miss, and it decides the whole design.
| channel | input | searches |
|---|---|---|
| vector / chunk | the query text, embedded | document chunks by similarity |
| graph | ll_keywords (entities) / hl_keywords (themes) |
entities and relations |
If no keywords are supplied, LightRAG derives its own with an LLM call
(operate.get_keywords_from_query) from whatever text it was handed. Supplying
them skips that call entirely.
Two consequences worth internalising before changing anything here:
- Rewriting the query text steers only half the system, and on a graph-capable client roughly half the retrieved context comes from the other half.
- Because retrieval spends a fixed
top_kacross whatever keywords it has, a wrong keyword is not inert. It matches something else and displaces entities the raw query was already finding.
Which channels a client actually has¶
Retrieval mode comes from the tenant's tier, via tier_to_rag_mode in
ixinfra.config:
| tier | rag_mode |
channels available |
|---|---|---|
lite |
naive |
vector only — naive_query calls _get_vector_context and nothing else |
advanced |
mix |
vector chunks plus graph entities and relationships |
A lite tenant has no graph, so supplied keywords are ignored outright: on that
tier the query text is the only lever there is. The tier boundary is therefore
behavioural, not just commercial — see IXRag for the ingestion side of
the same split.
The pipeline¶
retrieval_node (ixchat/nodes/knowledge_retriever.py) either preserves the raw
query or picks the resolver for the tenant's retrieval mode.
On graph clients, a longer follow-up starts the search on the visitor's own words
while resolve_reference decides; the search is repeated with the appended name
only when it resolves. See References in longer follow-ups.
enabled is the only runtime switch. Turning it off sends the visitor's message
through untouched. It never restores the legacy prose rewriter, which measured
worse than raw retrieval.
Query rewriter¶
“Query rewriter” is the retained configuration and evaluation name. In the shipping path it no longer means “turn the message into a standalone prose question.” It means: inspect the tenant's retrieval mode, apply the shared gate, and resolve the message into the input that mode can actually use.
Runtime schema¶
input:
query: string # visitor's message, unchanged on entry
messages: list[role, content] # resolver uses at most the last two turns
company_name: string
rag_mode: naive | mix | global | hybrid | local
enabled: boolean
output_when_disabled:
query: input.query
retriever_kwargs: {}
output_for_lite_naive:
query: string # raw text, or resolved text capped at 8 words
retriever_kwargs: {}
output_for_graph_short_reply:
query: input.query # vector/chunk channel remains raw
retriever_kwargs:
ll_keywords: list[string] # at most 5 entity/specific terms
hl_keywords: list[string] # at most 3 relation/theme terms
output_for_graph_longer_follow_up:
query: input.query # KEEP / AMBIGUOUS / any failure
| "<input.query> — <name>" # RESOLVE: one name copied from the history
retriever_kwargs: {} # LightRAG extracts graph keywords from the text
The graph keyword lists are both present or both absent. Absence means that LightRAG performs its normal keyword extraction; it never means “search the graph with empty keywords.”
The gate: should_resolve¶
Both channel-aware paths resolve only turns that cannot be searched as written.
def should_resolve(query: str) -> bool:
stripped = query.strip()
if not stripped or stripped.endswith("?"):
return False
return len(stripped.split()) <= MAX_RESOLVE_WORDS # 4
Two lines, no classifier, no extra LLM call. It is not an optimisation — it is the
intervention. On a query that already stands on its own the resolver cannot
decline; asked to produce a resolution it will produce one, and an invented frame
is worse than no frame at all. Combien de bureaux avez-vous ? resolved to
"bureaux, capacité, espace" — office space rather than a company's office count —
and the passage stating the count left the result set.
Replayed over 1,628 production retrieval turns (October 2026): 52% are first turns with nothing to resolve and 14% are short replies the gate resolves. The remaining third are longer follow-ups; on graph clients (98% of turns) those go to the reference check below rather than straight to retrieval.
References in longer follow-ups¶
The gate declines full questions, but some of them point at a product the conversation named without naming it: "Quelles enseignes l'ont déjà installée ?", "Which retailers already run it?". Searched alone, the product is lost.
resolve_reference (utils/query_keywords.py) asks a model one question for each
such turn on graph clients, and gets one line back:
| answer | meaning | retrieval input |
|---|---|---|
RESOLVE: <name> |
the message refers to one product, plan, module, feature or offer the conversation names | "<message> — <name>" |
KEEP |
already searchable: it names its subject, asks about the company ("vous", "your"), or changes subject | the message |
AMBIGUOUS |
refers to something the conversation names several of, or none | the message |
Unlike the short-reply resolvers, this one can decline, which is what keeps
Combien de bureaux avez-vous ? untouched. The name is appended, never
substituted: LightRAG then extracts graph keywords from the enriched text, so the
graph and the chunks both see the product.
In production the check sees about a third of turns and appends a name on about
3%. It runs on the query_reference route (gpt-5.4-mini, reasoning off, 2 s
timeout, no retry): ministral-3b declined most references and added products
to standalone questions.
Latency. Retrieval is the slowest branch before the answer, so a check run
before it would add its full time (~0.4 s) to every longer follow-up. The
retrieval node instead starts the search on the visitor's words immediately and
runs the check alongside it. On KEEP or AMBIGUOUS it uses that search; on
RESOLVE it cancels it and searches again with the name. Only resolved turns pay
the check, about +0.5 s. The early search is also cancelled when the check fails
and when the turn is redirected.
Lite clients do not run the check: appending a name to a chunks-only search measured no gain.
Resolver implementations¶
| module | path | produces | span |
|---|---|---|---|
utils/query_intent.py |
lite / naive |
a short resolved query (≤8 words) | tool-query-intent |
utils/query_keywords.py |
graph-capable, short replies | ll_keywords / hl_keywords, raw text untouched |
tool-query-keywords |
utils/query_keywords.py (resolve_reference) |
graph-capable, longer follow-ups | the message, or the message plus one name | tool-query-reference |
utils/query_rewriter.py remains only to reproduce the historical current
evaluation arm. Production retrieval no longer imports or calls it.
query_keywords returns a KeywordQuery whose retriever_kwargs are forwarded to
LightRAGRetriever.ainvoke. It exposes a vector_text property that appends the
keywords to the query text; that is used by eval arms only, not by the shipping
path.
What is enforced in code rather than asked of the model¶
The short-reply resolvers run on a 3B model, which does not reliably honour instructions. The constraints that matter are therefore applied to its output:
- Company name present — appended if the model omitted it.
- Length ceiling — truncated to
MAX_WORDS. - Reasoning scaffold rejected — output starting with a digit, or containing
the assistant/le visiteurand equivalents, is discarded. The model intermittently emits its own classification instead of the query. NONEand empty responses fall back to the visitor's words.- Both keyword lists or neither — retrieval modes read different lists
(
globaluseshl_keywordsalone,localll_keywordsalone,mix/hybridboth) and the resolver does not know the mode, so a half-filled result would silently strip whichever channel the client queries. - Only an established name is appended (
resolve_reference) — the name must occur in the formatted history the model saw, must not be the company itself or already in the message, and must be at most six words; otherwise the message is searched as written. A gloss the model echoes after the name ("Eudonet - the message is about…") is cut off.
The fallback contract¶
Every failure path returns the visitor's own words, and for keywords an empty result means "no kwargs", which leaves LightRAG to extract its own exactly as before. Returning empty keyword lists to the retriever would strip the graph side of retrieval entirely.
Falls back on: first turn (len(messages) < 2), gate declines (lite), LLM
failure, unparsable response, incomplete response, scaffold leak, and for the
reference check KEEP, AMBIGUOUS or a discarded name.
Configuration¶
Configuration schema¶
| key | schema default | effect |
|---|---|---|
query_rewriter.enabled |
True |
on ⇒ resolve for the tenant's available channel; off ⇒ raw message |
The key lives under QueryRewriterConfig in ixinfra/config/settings.py, populated
from the per-environment TOML in backend/apps/shared_data/config/<env>.toml.
There is no per-tenant control and no environment-variable override. The key
is not part of the unified (Supabase) config system, and get_config() wires env
overrides only for the vector-database and LightRAG sections. Changing enabled
means editing an environment's TOML and deploying it — all tenants in that
environment change at once:
All four environment TOMLs—development, test, staging, and production—set
enabled = true on this branch. Activation still requires deploying the branch;
the switch is not runtime-configurable. A per-client canary would require moving
the key into the unified config system first.
Observability¶
Each resolver opens a child span under agent-knowledge-retriever, so a resolution
is always attributable to the retrieval it fed. Spans record the model and provider
actually routed to, the resolved output, and a fallback reason when one was
taken — null on the success path.
tool-query-reference records the decision (resolve, keep, ambiguous,
error), the entity, the rejected reason when a proposed name was discarded
(not_in_history, company, already_named, too_long) and the
resolved_query; its metadata carries changed. Filter on it to see why a
follow-up was or was not resolved.
The historical tool-query-rewriter span records
conversation_history_length; it remains available when reproducing the legacy
evaluation arm, but production retrieval no longer emits it.
Evaluating a change here¶
rose-eval query-rewriter stops at retrieval and judges context sufficiency of the
returned chunks. query-rewriter-reference-v1 and query-rewriter-reference-holdout
cover references in longer follow-ups; --json includes per-item results for
pairing. See E2E Evaluation for the harness, and
packages/ixevaluation/AGENTS.md for the arm-comparison rules — in particular that
arms must be compared paired per item over N≥2 runs, and that the judge must see
identical text for every arm.
Key files¶
| file | role |
|---|---|
ixchat/nodes/knowledge_retriever.py |
path selection, tier branch, master switch, parallel search during the reference check |
ixchat/utils/query_keywords.py |
graph keyword resolver, should_resolve, resolve_reference |
ixchat/chat_routing.py |
query_rewriter and query_reference model routes |
ixchat/utils/query_intent.py |
text resolver for graph-less clients |
ixchat/utils/query_rewriter.py |
legacy prose rewriter, evaluation-only |
ixrag/lightrag/langgraph_retriever.py |
forwards keywords to QueryParam, drops history |
ixinfra/config/settings.py |
QueryRewriterConfig, tier_to_rag_mode |