Easier to read with cream paper, a tint, more contrast or larger text? Choose your colours and text, once, for every page.

Free lesson

Query rewriting for better search

After this lesson: I can rewrite a question so it finds more, and check against known sources that the rewrite helped.

65 min lesson7 commandsAI coding agent lab: use opencode to explain and fix code, and check its work

The question is not the query

Query rewriting means changing a question before you search with it, so the search finds more. It matters because people type short, vague or two-part questions, and an index only knows the words it holds.

"How do I get my money back" shares no word with the page that says "refund". "And how long?" shares no useful word with any page.

By the end of this lesson

Note

I can rewrite a question so it finds more, and check against known sources that the rewrite helped.

Key words

WordWhat it means
Querythe text a search actually receives
Expansionadding related words to a query
Decompositionsplitting a question into smaller ones
Follow-upa question that only makes sense with the chat so far
Synonyma different word for the same idea
Known sourcethe page a test question should find

The cells students fill in are left blank in this preview.

Expanding adds the words the page uses

A page about refunds says "refund". A user says "money back". Expansion adds related words to the query: "money back" becomes "money back refund return". A table of synonyms does it. A model can suggest them too.

Keep the original words and add to them. An expansion that replaces the user's words can lose the one exact word that mattered.

A two-part question is two searches

Take "Are you closed on Sunday and can I return a parcel". One search returns the page that fits one half best. The other half is lost.

Decomposition splits the question where it says "and" and searches each part. Then you merge the lists, for example with reciprocal rank fusion.

A follow-up needs the chat so far

"And how long do I have?" means nothing alone. Join it to the earlier question, "can I return a parcel", or have a model write one question that stands alone.

Never send the whole chat. Long queries drift. Add only the last question, and only when the new one is short.

A common mistake

Warning

Many people think a rewrite always helps. A synonym can match the wrong page and a long history can pull the query off course. Keep a set of test questions with known sources and count the hits before and after every change.

Write the model helper, part 1

Command
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const base = process.env.AI_URL || \"http://ai:4000\";' '// The token counts of a reply, or null when the reply carries none.' 'function count(data, name) {' '  return data.usage && typeof data.usage[name] === \"number\" ? data.usage[name] : null;' '}' > model.js"

Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.

Write the model helper, part 2

Command
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'async function ask(messages, sample, max) {' '  try {' '    const res = await fetch(base + \"/v1/chat/completions\", {' '      method: \"POST\",' '      headers: { \"content-type\": \"application/json\", authorization: \"Bearer lab\" },' '      body: JSON.stringify({ model: \"lab\", messages: messages, max_tokens: max || 100 }),' '      signal: AbortSignal.timeout(30000),' '    });' '    if (res.ok === false) throw new Error(\"status \" + res.status);' '    const data = await res.json();' '    const text = data.choices[0].message.content;' '    return { text, inTokens: count(data, \"prompt_tokens\"), outTokens: count(data, \"completion_tokens\"), recorded: false };' '  } catch (error) {' '    console.log(\"Recorded sample: no model answered (\" + error.message + \").\");' '    return { text: sample, inTokens: null, outTokens: null, recorded: true };' '  }' '}' 'module.exports = { ask };' >> model.js"

Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.

Write the rewriter, part 1

Command
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const { ask } = require(\"./model\");' 'const docs = {' '  hours: \"the shop opens at nine on saturday and closes at one\",' '  sunday: \"the shop is closed on sunday\",' '  returns: \"you can return a parcel within thirty days for a refund\",' '  delivery: \"delivery takes three days and costs four pounds\",' '};' 'const synonyms = { money: [\"refund\", \"return\"], back: [\"return\"] };' > rewrite1.js"

Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.

Write the rewriter, part 2

Command
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function stem(w) { return w.length > 3 && w.endsWith(\"s\") ? w.slice(0, -1) : w; }' 'function words(t) { return t.toLowerCase().replace(/[?,]/g, \"\").split(\" \").map(stem); }' 'function best(q) {' '  const w = words(q);' '  let id = \"nothing\", top = 0;' '  Object.keys(docs).forEach(function (k) {' '    const n = words(docs[k]).filter(function (x) { return w.includes(x); }).length;' '    if (n > top) { top = n; id = k; }' '  });' '  return id;' '}' 'function expand(q) {' '  const extra = [];' '  q.toLowerCase().split(\" \").forEach(function (w) {' '    (synonyms[w] || []).forEach(function (s) { extra.push(s); });' '  });' '  return q + \" \" + extra.join(\" \");' '}' >> rewrite1.js"

Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.

Write the rewriter, part 3

Command
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const cases = [' '  { q: \"when do you open on saturday\", want: \"hours\" },' '  { q: \"how do I get my money back\", want: \"returns\" },' '  { q: \"how long does delivery take\", want: \"delivery\" },' '];' 'let plain = 0, wider = 0;' 'cases.forEach(function (c) {' '  const a = best(c.q), b = best(expand(c.q));' '  if (a === c.want) plain++;' '  if (b === c.want) wider++;' '  console.log(c.q + \" | plain: \" + a + \" | expanded: \" + b);' '});' 'console.log(\"hits plain:\", plain, \"expanded:\", wider, \"of\", cases.length);' >> rewrite1.js"

Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.

Write the rewriter, part 4

Command
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const two = \"are you closed on sunday and can I return a parcel\";' 'console.log(\"whole question finds:\", best(two));' 'console.log(\"split finds:\", two.split(\" and \").map(best).join(\" + \"));' 'async function main() {' '  const text = \"Earlier: can I return a parcel. Now: how long do I have. Rewrite the last question so it stands alone. Reply with the question only.\";' '  const reply = await ask([{ role: \"user\", content: text }], \"How long do I have to return a parcel?\", 40);' '  console.log(\"rewritten:\", reply.text.trim());' '  console.log(\"finds:\", best(reply.text));' '}' 'main();' >> rewrite1.js"

Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.

Run and count the hits

Command
docker exec -w /home/node/project agent node rewrite1.js

Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.

This is a stand-in

The finder counts shared words in four pages. A real system searches a real index. The one model call rewrites a follow-up, and the loop around it uses a recorded rewrite when no model answers.

The method is the same at any size: change the query, then count hits on known sources.

What the Run prints

QuestionPlain findsExpanded finds
when do you open on saturdayhourshours
how do I get my money backnothingreturns
how long does delivery takedeliverydelivery

The cells students fill in are left blank in this preview.

About this lab and its source: AI coding agent lab: use opencode to explain and fix code, and check its work

Teaching a class? Set this module for your students with a teacher guide, marked assignments and a lab for every student.