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.
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_modes:
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
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 production spans, the gate declines about four turns in five: 44% are first turns with nothing to resolve, another third are multi-turn but already searchable.
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 | ll_keywords / hl_keywords, raw text untouched |
tool-query-keywords |
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 resolver runs 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.
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, LLM failure,
unparsable response, incomplete response, scaffold leak.
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.
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. 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 |
ixchat/utils/query_keywords.py |
graph keyword resolver, should_resolve |
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 |