Skip to content

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.

flowchart LR Q["visitor's message"] --> R{resolution} R -->|query text| V["_get_vector_context<br/>chunk / passage search"] R -->|ll_keywords / hl_keywords| G["_build_query_context<br/>entity + relation graph search"] V --> M["merge → rerank → Documents"] G --> M
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_k across 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.

flowchart TD A["retrieval_node"] --> B{"query_rewriter.enabled"} B -->|false| L["visitor's raw query"] B -->|true| D{"rag_mode == naive"} D -->|yes, no graph| T["query_intent.resolve_search_query<br/>gated text resolution"] D -->|no, has graph| K["query_keywords.build_keyword_query<br/>gated graph keywords"] T --> R K --> R L --> R

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 visiteur and equivalents, is discarded. The model intermittently emits its own classification instead of the query.
  • NONE and empty responses fall back to the visitor's words.
  • Both keyword lists or neither — retrieval modes read different lists (global uses hl_keywords alone, local ll_keywords alone, mix/hybrid both) 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

class QueryRewriterConfig(BaseModel):
    enabled: bool = True
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:

[query_rewriter]
enabled = true

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