Skip to content

Query Transforms

A transform rewrites the question before it’s searched with. Retrieval fails most often because the user’s words and the document’s words don’t match — someone asks “how long before I can walk away?” and the contract says “termination for convenience on thirty days written notice.” They share almost no vocabulary. Every transform on this axis is an attempt to close that gap, one way or another.

Set it with transform in Config, or grid.transform in a config file:

from contextgrid.transform import TRANSFORMS, get_transform
TRANSFORMS.names()
['expand', 'none']

Only none and expand are in that registry. The other four transforms are model-backed and can’t be built from a bare spec string — more on why below.

Every transform’s .transform(query) returns the same shape, TransformedQuery:

originalstr

The question exactly as asked.

queriestuple[str, ...]

What actually gets searched with — one query for none, more for anything that fans out.

llm_callsintdefault 0

Model calls this transform spent.

llm_tokensintdefault 0

Tokens this transform spent.

fan_outint

Property. len(queries) — how many searches this question now costs.

is_identitybool

Property. True when queries == (original,) — nothing was actually rewritten.

The cost that never goes away

Four of the six transforms need a model call. That cost isn’t like chunking or embedding, which you pay once when you build the index — it’s paid on every query, forever. So the real question is never “does HyDE help?” — it’s “does HyDE help enough to justify a model call on every query, forever?” Often the answer is no, and that’s a real finding, not a disappointment.

none — searching with the question exactly as asked — is the arm every other transform has to beat. It isn’t a placeholder: most transforms don’t clear their own cost, and you can’t show that without none sitting on the same chart with the same cost column.

Every transform reports what it spent, so a sweep can attribute the cost to the configuration that caused it:

from contextgrid.evalset.llm import RecordingLLM
from contextgrid.transform import get_transform, describe_cost
llm = RecordingLLM(default="Either party may terminate on thirty days written notice.")
t = get_transform("hyde", llm=llm)
result = t.transform("How much notice is needed to terminate for convenience?")
result.queries
('How much notice is needed to terminate for convenience?', 'Either party may terminate on thirty days written notice.')
result.fan_out, result.llm_calls
(2, 1)
describe_cost([result])
1.0 model calls and 2.0 searches per question, on every query forever

RecordingLLM is a fake LLM that returns scripted text instead of calling a real provider — it ships with the package for exactly this: writing runnable examples, and tests, with no key and no network. fan_out is how many searches this one question now costs — each query in TransformedQuery.queries gets searched separately and the results are fused, a real cost in latency as well as tokens.

Why the model-backed ones aren’t in the registry

none and expand build from a plain spec string, no model required, so they live in the ordinary plugin registry (contextgrid.transform.TRANSFORMS). hyde, multi-query, decompose and step-back don’t — a transform built with no model would silently fall back to doing nothing, and a config that looks like it’s testing HyDE while testing nothing is worse than an error.

from contextgrid.transform import get_transform, available_transforms, MODEL_BACKED
available_transforms()
('decompose', 'expand', 'hyde', 'multi-query', 'none', 'step-back')
MODEL_BACKED
('hyde', 'multi-query', 'decompose', 'step-back')
get_transform("hyde")
Traceback (most recent call last):
...
contextgrid.evalset.llm.LLMError: the 'hyde' transform needs a model. Set `run.model` in your config, or use one of the model-free transforms: expand, none

In a config file, the model comes from run.model — one name, so every model-backed transform, plus the LLM-backed ingestion strategies and the generation judge, share one key and one price:

grid:
transform: [null, hyde, multi-query]
run:
model: openai:gpt-4o-mini

The six transforms

namespecneeds a modelwhat it does
nonenonenoSearch with the question as asked. The arm to beat.
expandexpand:RPO=recovery point objectivenoSpell out configured acronyms before searching.
hydehydeyesInvent a hypothetical answer and search with that instead of the question.
multi-querymulti-query or multi-query:5yesParaphrase the question several ways and fuse the results.
decomposedecompose or decompose:2yesSplit a compound question into sub-questions.
step-backstep-backyesAdd a more general question alongside the specific one.

noneNoTransform

from contextgrid import NoTransform
NoTransform()

Returns the question unchanged — TransformedQuery(original=query, queries=(query,)). Zero model calls, zero fan-out.

expandExpandAcronyms

No model, and free. Unglamorous, but it moves lexical-search scores more than most of the fancier transforms below — a corpus that says “recovery point objective” won’t be found by a query that says “RPO,” and no embedding fixes a term the model has never seen.

expansionsdict[str, str]default {}

Short form to long form, e.g. {"RPO": "recovery point objective"}.

It appends the expansion next to the abbreviation rather than replacing it, so the acronym stays searchable too:

from contextgrid import ExpandAcronyms
ExpandAcronyms(expansions={"RPO": "recovery point objective"}).transform("What is our RPO?").queries
('What is our RPO recovery point objective?',)

From a spec string — a config file, or Lab.grid(transform=...) — each acronym is one key=value pair, and several are separated by commas:

import contextgrid as cg
cg.get_transform("expand:RPO=recovery point objective,RTO=recovery time objective")
ExpandAcronyms(expansions={'RPO': 'recovery point objective', 'RTO': 'recovery time objective'})

With no expansions configured, or nothing in the query to expand, it’s the identity — no extra query is produced:

cg.get_transform("expand").transform("What is our RPO?").queries
('What is our RPO?',)

hydeHyDE

llmLLM

Required, no default.

include_questionbooldefault True

Keep the original question alongside the invented passage.

max_tokensintdefault 200

Cap on the invented passage.

Searches with a hypothetical answer rather than the question. A question and its real answer share little vocabulary; a plausible fake answer and the real one usually share a lot. So the model invents a short passage that would answer the question, and that passage — not the question — gets embedded and searched:

from contextgrid import HyDE
hyde_llm = RecordingLLM(default="Either party may terminate the agreement on thirty days written notice.")
HyDE(llm=hyde_llm).transform("How much notice is needed to terminate for convenience?").queries
('How much notice is needed to terminate for convenience?', 'Either party may terminate the agreement on thirty days written notice.')

It works best where the model already knows the domain, and worst where it doesn’t — on internal jargon it invents confident nonsense matching nothing in the corpus. Keeping include_question=True (the default) hedges that failure: when the invention is nonsense, the real question is still in the fused results.

multi-queryMultiQuery

llmLLM

Required, no default.

variantsintdefault 3

How many paraphrases to generate. multi-query:5 sets variants=5.

max_tokensintdefault 250

Cap on the model’s reply.

Asks the same question several different ways and fuses the results. The most reliable of these transforms and the least clever — it doesn’t need the model to know anything about the domain, only to paraphrase:

import json
from contextgrid import MultiQuery
mq_llm = RecordingLLM(replies=[json.dumps(["how long is notice", "what is the notice period"])])
result = MultiQuery(llm=mq_llm, variants=2).transform("How much notice is needed to terminate for convenience?")
result.fan_out
3

decomposeDecompose

llmLLM

Required, no default.

max_partsintdefault 3

Cap on how many sub-questions come back. decompose:2 sets max_parts=2.

max_tokensintdefault 250

Cap on the model’s reply.

The only one of these that fixes a structural failure rather than a vocabulary one. “Which vendor has the shortest notice period and what is their monthly fee?” can’t be answered by any single passage, however well it’s embedded — the question itself has to become two:

from contextgrid import Decompose
dc_llm = RecordingLLM(replies=[json.dumps(["what is the notice period", "is there an early termination fee"])])
Decompose(llm=dc_llm, max_parts=2).transform(
"What is the notice period and is there an early termination fee?"
).queries
('what is the notice period', 'is there an early termination fee')

Duplicate sub-questions are dropped.

step-backStepBack

llmLLM

Required, no default.

max_tokensintdefault 120

Cap on the model’s reply.

Asks the more general question alongside the specific one — “what is Northwind’s notice period?” becomes “what do the termination clauses say?” as well. It helps when the specific answer sits inside a passage about the general topic, and hurts when the general query drags in every document that mentions termination:

from contextgrid import StepBack
sb_llm = RecordingLLM(default="What do the termination clauses say?")
StepBack(llm=sb_llm).transform("What is Northwind's notice period?").queries
("What is Northwind's notice period?", 'What do the termination clauses say?')

If the model just repeats the question back, StepBack treats that as a no-op and returns the identity.

Failure handling, uniformly

Every model-backed transform degrades to NoTransform’s behavior — search with the original question — when the model call errors, returns nothing usable, or returns something that doesn’t parse as expected:

fail_llm = RecordingLLM(default="I'm sorry, I can't help with that.")
result = MultiQuery(llm=fail_llm, variants=2).transform("How much notice is needed?")
result.queries, result.is_identity, result.llm_calls
(('How much notice is needed?',), True, 0)

Searching with an empty query would score zero and look like a retrieval failure; falling back to the original question means a broken transform costs a wasted model call, not a corrupted result. And llm_calls is 0 in this fallback case too — a failing transform is invisible in the cost report unless you also check is_identity.

What ends up in the config

grid:
transform: [null, expand, hyde, "multi-query:5", decompose, step-back]
run:
model: openai:gpt-4o-mini # supplies the model to hyde, multi-query, decompose, step-back

null in YAML is None in Python, which get_transform treats the same as "none" — no rewriting.

See also