Skip to content
bits
Retrieval without tears · Lesson 9 of 9

Checkpoint: ask my docs

Tutor:

Time to put the whole course together: a tiny “ask my docs” tool in TypeScript. Point it at a folder of Markdown files, ask a question, get an answer with sources. About 80 lines, plus chunk.ts from lesson 2 and answer.ts from lesson 7.

It runs in two steps. Index once: chunk every file, embed the chunks, save them. Ask as often as you like: embed the question, shortlist the 50 closest chunks, rerank them down to 5, and hand those to Claude as search results with citations on.

import { readdir, readFile, writeFile } from "node:fs/promises";
import { chunk, type Chunk } from "./chunk";
import { embed } from "./voyage";
async function main() {
const dir = process.argv[2] ?? "docs";
const chunks: Chunk[] = [];
for (const file of await readdir(dir)) {
if (!file.endsWith(".md")) continue;
chunks.push(...chunk(file, await readFile(`${dir}/${file}`, "utf8")));
}
// Embed in batches of 100 chunks per request.
const vectors: number[][] = [];
for (let i = 0; i < chunks.length; i += 100) {
const batch = chunks.slice(i, i + 100).map((c) => c.text);
vectors.push(...(await embed(batch, "document")));
}
const index = chunks.map((c, i) => ({ ...c, vector: vectors[i]! }));
await writeFile("index.json", JSON.stringify(index));
console.log(`Indexed ${index.length} chunks from ${dir}/`);
}
main();
TypeScriptUTF-8
Tutor:

The ask step searches, then answers. answer.ts is the one from lesson 7.

import { readFile } from "node:fs/promises";
import type { Chunk } from "./chunk";
import { embed, rerank } from "./voyage";
type Entry = Chunk & { vector: number[] };
export async function search(question: string, topK = 5) {
const index = JSON.parse(await readFile("index.json", "utf8")) as Entry[];
const [q] = await embed([question], "query");
const similarity = (v: number[]) => v.reduce((sum, x, i) => sum + x * q![i]!, 0);
// Retrieve wide: the 50 closest chunks (dot product = cosine for Voyage).
const shortlist = index
.map((entry) => ({ entry, score: similarity(entry.vector) }))
.sort((a, b) => b.score - a.score)
.slice(0, 50)
.map(({ entry }) => entry);
// Rerank narrow: keep the best few.
const ranked = await rerank(question, shortlist.map((e) => e.text), topK);
return ranked.map((r) => shortlist[r.index]!);
}
TypeScriptUTF-8● lines 12–20: retrieve wide, rerank narrow
Tutor:

You need two keys: one for Claude and a Voyage model API key from MongoDB Atlas.

# Claude: console.anthropic.com · Voyage: a model API key from MongoDB Atlas
ANTHROPIC_API_KEY=sk-ant-…
VOYAGE_API_KEY=…
PropertiesUTF-8
Tutor:

Index once, then ask. Example output from one run (yours will differ):

OUTPUT
npx tsx --env-file=.env index.ts docs && npx tsx --env-file=.env ask.ts "How long is the battery warranty?"
Indexed 42 chunks from docs/
E-bike batteries have a 2-year warranty from delivery, covering capacity that drops below 70%. File any claim within 30 days of noticing the fault.
Sources:
warranty.md (warranty.md#0)
warranty.md (warranty.md#1)
example output, not a live run
Tutor:

Taking it to production on Cloudflare. index.json is fine for a few thousand chunks. Past that, swap it for Vectorize, Cloudflare's vector database. Create the index with the same shape as your vectors, because dimensions and metric can't change later.

npx wrangler vectorize create docs \
--dimensions=1024 --metric=cosine
ShellUTF-8
Tutor:

Upsert each chunk with its id, vector and a little metadata (source, text), then query it with topK. Limits to plan for (as of October 2026): 1,536 dimensions at most, 10 KiB of metadata per vector, ids up to 64 bytes, and topK up to 50 when you ask for metadata back. Or skip the plumbing: Cloudflare's AI Search indexes your files and runs hybrid search for you.

What's next for this pipeline is everything you just learned: add BM25 and fuse the two lists (lesson 4), add context to each chunk (lesson 5), and write twenty test questions to measure each change (lesson 8).

quick check+20 neurons
Tutor:

In search.ts, why does the code shortlist 50 chunks before calling rerank?

    Type your answer or tap an option · Enter checks

    Tutor:

    Last step, and the one that decides how the answers feel: the instructions that go with the search results. Write the prompt for Orbit Bikes' docs assistant. The passages and a customer question are in the box. Tell Claude who it is, to answer only from the passages, to cite them like [1], to say plainly what the passages don't cover, and to keep it short. Run it a few times. The answer should change its words, never its facts.

    Your turn

    Run your prompt and the replies land here.

      • Gives it a role, not yet
      • Limits Claude to the passages, not yet
      • Asks for citations, not yet
      • Says what to do when it's missing, not yet
      • Keeps it short, not yet
      • Ran at 1.0, not yet
      run at
      ~0 tokens

      Turn on JavaScript to run prompts.

      Tutor:

      Finish this checkpoint and the Retrieval without tears badge is yours. You can now decide when to paste and when to search, find the right passages three ways, hand them to Claude with sources, and prove each change helped.