Skip to content
academia.sh

Lesson 11 / 16

Query Transformation

Translating the query the user typed into the document language with a single model call before it reaches the index, and counting what this step gains and what it loses separately: on a fictional set of forty queries the short phrasing has a baseline of 0.3250 recall and 0.1500 generation, while expansion raises the two to 0.7500 and 0.7000, which is where a query written in the index language leaves off. The same setup drops the long phrasing from a baseline of 0.5500 recall to 0.5000 with expansion and to 0.2750 with rewriting, because the share with which the transformer finds the correct concept falls from 0.9500 to 0.3500. The cost on both sets is one call per query and more tokens: from 4609 to 5804 on the short set and from 5827 to 8553 on the long set; the run-to-run difference was measured at 0.0500 in recall and 0.0500 in generation.

Published Updated

In this lesson

  • Builds query transformation as a one-step model call and counts the call and the tokens it adds separately.
  • Compares expansion with rewriting on the same query set and shows the case in which the two diverge.
  • Measures that transformation gains on the short phrasing and, on the long phrasing, drops retrieval recall below the baseline.
  • Judges the gain and loss claims by whether they stand above the run-to-run difference.
Contents

The previous four lessons built the document side of the pipeline: the corpus was prepared, split into chunks, indexed and refreshed. All four stayed silent about one assumption: the query goes to the index in the form the user typed it.

The subscriber writes “which band”, and the corpus says “tier limit”. One way to close the gap is to give the query to a model before sending it to the index and have it translated into the document language; the cost is one call per query. The transformation is one step; seeing the result and querying again is the subject of the AI Agents course.

  • RP41. The code is a simulator; no real embedding or model endpoint is called. The corpus, the concepts, the queries and the user phrasings are fictional, seed 20260218.
  • RP42. The target has two parts. The target of a query is both a concept and a record type; a chunk is relevant only if it carries both. Recall@k is the share of queries with a relevant chunk in the top k, and precision@k is the share of the top k that is relevant.
  • RP43. The pipeline uses the hybrid search measured in embeddings/06: token overlap is added next to cosine similarity. Its procedure is not repeated, and the weight is fixed. The baseline of this lesson is retrieval without transformation; the baseline of the no-retrieval call was set up in retrieval-pipeline/01.
  • RP44. The generation simulator picks one of the chunks in the context and cites its code; the chunk’s position changes the weight. That weight is the subject of retrieval-pipeline/07 and is fixed here.
  • RP45. Two phrasings of the same forty queries are measured: the short phrasing is the abbreviated form the subscriber types, and the long phrasing is the same question extended with context sentences. The target is the same in both.
  • RP46. If the transformation hits the rate limit, the pipeline falls back to the raw query; the tokens of the failed call are still counted.

The Pipeline and the Target

The pipeline works at chunk level. The block below sets up the corpus, the embedding simulator, hybrid retrieval and the generation simulator; then it prints where the pipeline stands when the query is written in the form the index recognizes: the upper bound that transformation can reach.

# SIMULATOR -- FICTIONAL. No real embedding or model endpoint is called; the corpus,
# concepts and queries are fictional, seed 20260218.
import math
SEED, M32 = 20260218, 0xFFFFFFFF
DIM = 32


def rng(t):
    x = ((t ^ (t >> 16)) * 2246822507) & M32
    x = ((x ^ (x >> 13)) * 3266489909) & M32
    s = [(x ^ (x >> 16)) & M32]

    def next_value():
        s[0] = (s[0] * 1664525 + 1013904223) & M32
        return s[0] / 4294967296
    return next_value


def pick(u, w):
    t = 0.0
    for i, x in enumerate(w):
        t += x
        if u < t:
            return i
    return len(w) - 1


def tokenize(m):
    b = []
    for s in m.lower().replace(",", " , ").split():
        while len(s) > 5:
            b.append(s[:5])
            s = s[5:]
        b.append(s)
    return b


# ---- FICTIONAL concept axis: each concept has a separate surface form on the document side and on the query side
CLASSES = ["tariff", "fault", "meter", "outage", "maintenance"]
CODE = {"tariff": "TR", "fault": "AR", "meter": "SY", "outage": "KS", "maintenance": "BK"}
CONCEPTS = {                     # concept: (label, document-side form, query-side form)
    "tier":      ("tariff", "tier limit", "which band am i in"),
    "unit":      ("tariff", "unit price", "what a ton of water costs"),
    "account":   ("tariff", "account type", "home or business"),
    "switch":    ("tariff", "plan switch", "moving to another package"),
    "faultcode": ("fault", "fault code", "error id"),
    "leak":      ("fault", "line leak", "water escaping"),
    "pressure":  ("fault", "pressure drop", "weak flow"),
    "crew":      ("fault", "field crew", "technician when does it show up"),
    "index":     ("meter", "index value", "digits on the dial"),
    "read":      ("meter", "reading routine", "how it gets written down"),
    "swap":      ("meter", "meter swap", "changing the hardware"),
    "verify":    ("meter", "reading check", "typed in by mistake"),
    "planned":   ("outage", "planned outage", "stoppage announced ahead"),
    "notice":    ("outage", "notice channel", "where do i find out"),
    "duration":  ("outage", "outage duration", "how long does it last"),
    "restore":   ("outage", "service restoration", "when does it show up"),
    "periodic":  ("maintenance", "periodic maintenance", "regular inspection"),
    "valve":     ("maintenance", "valve check", "tap shutoff order"),
    "filter":    ("maintenance", "filter replacement", "cleaning part"),
    "calib":     ("maintenance", "calibration record", "accuracy tweak"),
}
KEYS = list(CONCEPTS)
COMMON = ["entry", "area", "term", "task", "form", "okay", "note"]

# ---- FICTIONAL corpus
DOCUMENTS = []
for i in range(200):
    r = rng(SEED + 7919 * i)
    s = CLASSES[pick(r(), [0.22, 0.24, 0.20, 0.18, 0.16])]
    pool = [k for k in KEYS if CONCEPTS[k][0] == s]
    main = pool[int(r() * len(pool))]
    n = 2 + int(r() * 4)
    paras, concepts = [], [main]
    for _ in range(n):
        y = pool[int(r() * len(pool))]
        concepts.append(y)
        o = COMMON[int(r() * len(COMMON))]
        paras.append(f"{CONCEPTS[y][1]} for {o} goes with {CONCEPTS[main][1]} and is filed")
    DOCUMENTS.append({"code": f"{CODE[s]}{i + 1:03d}", "label": s, "main": main,
                      "concepts": concepts, "title": f"{CONCEPTS[main][1]} procedure",
                      "paras": paras})


def text_of(d):
    return d["title"] + " " + " ".join(d["paras"])


# ---- FICTIONAL embedding: the surface is first reduced to a concept, the concept then to dimensions
def _to_concepts(m, domain=True):
    v = [0.0] * len(KEYS)
    dm = " " + m.lower() + " "
    for i, k in enumerate(KEYS):
        doc, qry = CONCEPTS[k][1], CONCEPTS[k][2]
        if doc in dm:
            v[i] += 1.0
        if qry in dm:
            v[i] += 1.0 if domain else 0.0      # the out-of-domain embedding does not recognize the query language
    if not domain:
        for o in COMMON:
            if " " + o + " " in dm:
                for i in range(len(KEYS)):
                    v[i] += 0.05
    return v


PROJECTION = []
for i in range(len(KEYS)):
    r = rng(SEED ^ (i + 1) * 2654435761)
    PROJECTION.append([r() * 2 - 1 for _ in range(64)])


def embed(m, dim=DIM, domain=True):
    k = _to_concepts(m, domain)
    v = [0.0] * dim
    for i, a in enumerate(k):
        if a:
            for j in range(dim):
                v[j] += a * PROJECTION[i][j % 64]
    n = sum(x * x for x in v) ** 0.5
    return [x / n for x in v] if n else v


def cosine(a, b):
    return sum(x * y for x, y in zip(a, b))


# ---- FICTIONAL query set: the query language is separate from the document language
QUERIES = []
for i in range(40):
    r = rng(SEED + 104729 * i)
    d = DOCUMENTS[int(r() * len(DOCUMENTS))]
    QUERIES.append({"text": f"{CONCEPTS[d['main']][2]} {COMMON[int(r() * len(COMMON))]}",
                    "label": d["label"], "concept": d["main"]})

# ---- pipeline: chunks, hybrid retrieval, target definition and generation
CHUNKS = [{"code": d["code"], "main": d["main"], "text": p} for d in DOCUMENTS for p in d["paras"]]
for p in CHUNKS:
    p["vec"], p["bag"] = embed(p["text"]), set(tokenize(p["text"]))
for s in QUERIES:
    s["record"] = s["text"].split()[-1]


def retrieve(q, k=5, w=0.5):
    qv, qb = embed(q), set(tokenize(q))
    s = sorted(((cosine(qv, p["vec"]) + w * len(qb & p["bag"]) / len(qb | p["bag"]), i)
                for i, p in enumerate(CHUNKS)), key=lambda t: (-t[0], t[1]))
    return [CHUNKS[i] for _, i in s[:k]]


def relevant(s, p):
    return CONCEPTS[s["concept"]][1] in p["text"] and s["record"] in p["bag"]


def trace_id(m):
    h = 2166136261
    for c in m:
        h = ((h ^ ord(c)) * 16777619) & M32
    return h


WINDOW = 240
COUNTER = [0]
ANSWER_INSTRUCTION = "Answer by looking at the chunks below and write the code of the chunk you used . "


def answer(query, chunks, seed=SEED, temperature=0.12):
    """FICTIONAL generation: one chunk is picked from the context, and the chunk's POSITION changes the weight."""
    COUNTER[0] += 1
    prompt = ANSWER_INSTRUCTION + " ".join(f"[{p['code']}] {p['text']} ." for p in chunks) + " Question: " + query
    toks = tokenize(prompt)
    if len(toks) > WINDOW:
        return {"status": "context_overflow", "input": len(toks), "output": 0, "chunk": None}
    r, qb, n = rng(seed ^ trace_id(prompt)), set(tokenize(query)), len(chunks)
    score = [(1.0 if i == 0 else 0.8 if i == n - 1 else 0.45) * (0.3 + len(qb & x["bag"]) / len(qb))
             for i, x in enumerate(chunks)]
    w = [math.exp(a / temperature) for a in score]
    chosen = chunks[pick(r(), [a / sum(w) for a in w])]
    return {"status": "ok", "input": len(toks), "chunk": chosen,
            "output": len(tokenize(f"{chosen['code']} : {chosen['text']}"))}


rec = gen = 0
for s in QUERIES:
    g = retrieve(s["text"], 5)
    rec += any(relevant(s, p) for p in g)
    gen += relevant(s, answer(s["text"], g)["chunk"])
print("corpus %d documents %d chunks ; average chunk %.1f tokens ; example query %r"
      % (len(DOCUMENTS), len(CHUNKS), sum(len(p["bag"]) for p in CHUNKS) / len(CHUNKS),
         QUERIES[0]["text"]))
print("target chunk set average %.1f chunks"
      % (sum(len([p for p in CHUNKS if relevant(s, p)]) for s in QUERIES) / 40))
print("query written in the index language  k=5  recall %.4f  generation %.4f" % (rec / 40, gen / 40))
corpus 200 documents 693 chunks ; average chunk 12.0 tokens ; example query 'how it gets written down form'
target chunk set average 8.9 chunks
query written in the index language  k=5  recall 0.8000  generation 0.6750

The target set averages 8.9 chunks, a little above one percent of six hundred ninety-three. When the query is written in the index language, the top five chunks bring one from this set with a share of 0.8000, and generation turns 0.6750 of them into an answer.

Two Phrasings and the Transformer

The subscriber does not write these sentences. “how it gets written down form” is an index query; the subscriber writes “is it written form” or describes the problem at length. The block below sets up the two phrasings of the same forty targets and runs three setups on both: the baseline (the raw query), expansion (a term in the document language is appended to it) and rewriting (the raw query is discarded and a sentence in the document language takes its place).

# FICTIONAL: the short form the user types -- it does not carry the surface the index recognizes
SHORT = {"tier": "which band", "unit": "costs per ton", "account": "home or office",
         "switch": "moving plans", "faultcode": "error code", "leak": "might be escaping",
         "pressure": "flow weak", "crew": "technician never came", "index": "dial digits",
         "read": "is it written", "swap": "hardware changed", "verify": "typed wrong",
         "planned": "announced ahead", "notice": "where to look", "duration": "hours it will last",
         "restore": "will it show up again", "periodic": "regular upkeep", "valve": "tap shutoff",
         "filter": "cleaning filter", "calib": "accuracy calibration"}
PREFIX = ["previous week", "for a few days", "since this morning", "yesterday evening"]
CONNECTOR = ["one more thing i wanted to ask", "also i wrote to the relevant department", "by the way i was curious",
             "i had also called before"]
SHORT_QUERIES, LONG_QUERIES = [], []
for i, s in enumerate(QUERIES):
    r = rng(SEED + 31337 * i)
    y, o2 = KEYS[int(r() * 20)], COMMON[int(r() * 7)]
    z = KEYS[int(r() * 20)]
    SHORT_QUERIES.append(dict(s, text=f"{SHORT[s['concept']]} {s['record']}"))
    LONG_QUERIES.append(dict(s, text=f"{PREFIX[int(r() * 4)]} {CONCEPTS[y][2]} {o2} "
                                     f"{CONNECTOR[int(r() * 4)]} {s['text']} {CONCEPTS[z][2]}"))

QUERY_TRACE = {}                    # query language -> concept
for k in KEYS:
    for t in tokenize(CONCEPTS[k][2]):
        if len(t) > 3:
            QUERY_TRACE.setdefault(t, k)
TRANSFORM_INSTRUCTION = "Translate the query into the document language . Query: "


def transform(q, mode, seed=SEED, temperature=0.5):
    """FICTIONAL transformer: a one-step model call, no loop and no planning."""
    COUNTER[0] += 1
    toks = tokenize(TRANSFORM_INSTRUCTION + q)
    if COUNTER[0] % 37 == 0:                     # rate limit: the raw query
        return {"status": "rate_limit", "input": len(toks), "output": 0, "text": q, "concept": None}
    r, score = rng(seed ^ trace_id(q)), {}
    for t in toks:
        if t in QUERY_TRACE:
            score[QUERY_TRACE[t]] = score.get(QUERY_TRACE[t], 0.0) + 1.0
    if not score:
        return {"status": "empty", "input": len(toks), "output": 0, "text": q, "concept": None}
    keys = sorted(score)
    w = [math.exp(score[x] / temperature) for x in keys]
    key = keys[pick(r(), [x / sum(w) for x in w])]
    common = next((t for t in toks if t in COMMON), "")
    out = f"{CONCEPTS[key][1]} for {common} goes" if mode == "rewriting" else f"{q} {CONCEPTS[key][1]}"
    return {"status": "ok", "input": len(toks), "output": len(tokenize(out)),
            "text": out, "concept": key}


def measure(qset, mode, seed=SEED, k=5):
    COUNTER[0] = 0
    rec = gen = calls = toks = failed = right = 0
    for s in qset:
        q = s["text"]
        if mode != "baseline":
            d = transform(q, mode, seed)
            q, calls, toks = d["text"], calls + 1, toks + d["input"] + d["output"]
            failed, right = failed + (d["status"] != "ok"), right + (d["concept"] == s["concept"])
        g = retrieve(q, k)
        rec += any(relevant(s, p) for p in g)
        y = answer(s["text"], g, seed)
        calls, toks = calls + 1, toks + y["input"] + y["output"]
        gen += y["chunk"] is not None and relevant(s, y["chunk"])
    return rec / 40, gen / 40, calls, toks, failed, right / 40


print("average query: short %.1f  long %.1f tokens"
      % (sum(len(tokenize(s["text"])) for s in SHORT_QUERIES) / 40,
         sum(len(tokenize(s["text"])) for s in LONG_QUERIES) / 40))
print("query setup          recall@5 generation calls   tokens correct concept failed")
gap = [0.0, 0.0]
for name, qset in (("short", SHORT_QUERIES), ("long ", LONG_QUERIES)):
    for mode in ("baseline", "expansion", "rewriting"):
        a, u, c, b, failed, right = measure(qset, mode)
        a2, u2 = measure(qset, mode, SEED + 1)[:2]
        gap = [max(gap[0], abs(a - a2)), max(gap[1], abs(u - u2))]
        print("%s %-14s %8.4f %10.4f %5d %8d %15s %6d"
              % (name, mode, a, u, c, b, "-" if mode == "baseline" else "%.4f" % right, failed))
print("run-to-run difference (seed 20260219): recall at most %.4f  generation at most %.4f"
      % (gap[0], gap[1]))
average query: short 4.5  long 26.5 tokens
query setup          recall@5 generation calls   tokens correct concept failed
short baseline         0.3250     0.1500    40     4609               -      0
short expansion        0.7500     0.7000    80     5804          0.9500      1
short rewriting        0.7500     0.7000    80     5749          0.9500      1
long  baseline         0.5500     0.2250    40     5827               -      0
long  expansion        0.5000     0.1250    80     8553          0.3500      1
long  rewriting        0.2750     0.1000    80     7647          0.3500      1
run-to-run difference (seed 20260219): recall at most 0.0500  generation at most 0.0500

The Gain on the Short Phrasing

The baseline of the short phrasing is 0.3250 recall and 0.1500 generation. The gap to the upper bound is not a defect of the pipeline but the query not touching the index: “which band” appears in no chunk, and all that is left is the overlap of the record type.

Transformation closes the gap. Expansion raises recall to 0.7500 and generation to 0.7000; both stand above the run-to-run difference (0.0500 and 0.0500), and generation reaches the upper bound within that difference. The reason is written in the table: the transformer finds the correct concept with a share of 0.9500.

That expansion and rewriting give the same result here is not a coincidence: keeping the raw query has neither a cost nor a gain, because there is no signal to be thrown away.

The Loss on the Long Phrasing

The baseline of the long phrasing is 0.5500, higher than that of the short phrasing: while lengthening the problem, the subscriber also writes the phrase the index recognizes. Transformation lowers the baseline, to 0.5000 with expansion and to 0.2750 with rewriting. Rewriting stands far above the run-to-run difference, so it is a measured loss. Expansion is a smaller story: its recall (0.5000) sits at the edge of the run-to-run difference, and only its generation, which goes from 0.2250 to 0.1250, clearly leaves it behind.

The cause is again in the correct concept column: the hit rate falls to 0.3500. The long query has three concept surfaces and two record types; a one-step call has to choose which one is the real question, and it is wrong most of the time. When it is wrong, expansion appends a wrong term but the raw query stays in place; rewriting deletes the one thing that was right, the phrase the subscriber wrote, and generation falls to 0.1000. This is the instance, in this lesson, of the course’s second claim: the added machinery made the job worse.

The Bill for Transformation

Calls go from one to two per query, from forty to eighty. Tokens go from 4609 to 5804 on the short phrasing with expansion and from 5827 to 8553 on the long one; having the model read the long query as well is the most expensive line of the pipeline, and one of the forty calls hits the rate limit. Latency is counted as a class: the pipeline turns from a single-call step into a two-stage chain. While the extra tokens on the short phrasing raise recall from 0.3250 to 0.7500, the larger extra tokens on the long phrasing lower it from 0.5500 to 0.5000 with expansion and to 0.2750 with rewriting.

Summary

  • The target of a query is both a concept and a record type; the share with which a chunk carrying both is found in the top five is retrieval, and the share with which that chunk turns into the answer is the generation measure.
  • Because the short form the user typed does not touch the index, the baseline is 0.3250 / 0.1500; the same query in the index language gives 0.8000 / 0.6750.
  • Expansion raises recall to 0.7500 and generation to 0.7000 on the short phrasing; the share of finding the correct concept is 0.9500.
  • On the long phrasing the same machinery drops the baseline recall from 0.5500 to 0.5000, and rewriting to 0.2750; the correct concept share falls to 0.3500, and rewriting also deletes the phrase the subscriber wrote.
  • The cost is one call per query and more tokens (5804 and 8553 against 4609 and 5827); the pipeline turns into a chain.

Next Step

The measurements of this lesson count the top five chunks together: if a relevant chunk is among them, recall is written, and the order is not asked. Yet precision@5 says most of the top five are irrelevant. The next lesson splits retrieval in two: a cheap stage that gathers a wide candidate set and an expensive stage that reads every candidate together with the query.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close