Naive RAG, Once
Fixed 512-token chunks, one embedding, top-k, answer: the baseline every later change must beat.
By the end of this session you will be able to:
- Build a retrieval pipeline end to end: fixed 512-token chunks, one embedding model, one Qdrant collection, top-k, one generated answer.
- Name every decision that pipeline makes, and write those decisions into a config file you can version and re-run.
- Explain why you will not touch a single one of those decisions until you can measure them.
A baseline is an instrument, not a product
Naive RAG works. It works badly, and it works on the first afternoon, and both of those matter. What you are building today is not the system you will ship. It is the zero mark on the ruler you will spend the rest of the course reading.
Skip it and here is what happens. Three weeks in you add contextual retrieval, the demo queries look better, and you ship it. Two weeks after that someone asks whether it was worth the extra embedding cost per document, and you have nothing. No before, no after, only a memory of the demo. Every later technique in this course, reranking, hybrid search, late chunking, query rewriting, costs latency or money or both, and every one of them makes some queries worse while making others better. Without a fixed starting point you cannot tell those two groups apart.
So build the crude thing first, on purpose, and leave it standing.
Four decisions, all of them wrong
The pipeline you write today makes exactly four choices. Say them out loud, because later sessions attack them one at a time.
Chunking. Fixed 512-token windows, no overlap, no awareness of headings or sentences. This will cut a definition in half and put the two halves in different chunks. It will also put the last paragraph of one section and the first paragraph of the next into a single chunk that is about nothing.
Embedding. One model, BAAI/bge-small-en-v1.5, 384 dimensions, chosen because it is small and fast. Not because you measured it on your corpus. Its maximum sequence length is 512 tokens, which is why the chunk size is 512: anything longer gets silently truncated before it is embedded, and you would never see it happen.
Indexing. One Qdrant collection, cosine distance, default HNSW parameters, no filters, no quantization, no sparse vectors.
Retrieval. Top 5 by vector similarity. No reranking, no fusion, no deduplication. Whatever comes back goes into the prompt in score order.
Every one of those is a defensible default and none of them is right for your documents. Resist fixing them today. A baseline you tuned is not a baseline.
The pipeline, end to end
Start Qdrant and install the four libraries:
docker run -p 6333:6333 -v "$(pwd)/qdrant_storage:/qdrant/storage" qdrant/qdrant:v1.18.0
pip install "chonkie[all]" fastembed "qdrant-client>=1.18" anthropicNow ingest. Put your documents as .md files under a corpus/ directory. Note that the chunker is given the embedding model's own tokenizer, so that 512 means 512 to the model that has to read the chunk:
from pathlib import Path
from chonkie import TokenChunker
from fastembed import TextEmbedding
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, PointStruct, VectorParams
MODEL = "BAAI/bge-small-en-v1.5"
COLLECTION = "baseline"
chunker = TokenChunker(tokenizer=MODEL, chunk_size=512, chunk_overlap=0)
embedder = TextEmbedding(model_name=MODEL)
client = QdrantClient(url="http://localhost:6333")
client.create_collection(
collection_name=COLLECTION,
vectors_config=VectorParams(size=384, distance=Distance.COSINE),
)
points = []
paths = sorted(Path("corpus").glob("**/*.md"))
for path in paths:
chunks = chunker.chunk(path.read_text(encoding="utf-8"))
vectors = list(embedder.embed([chunk.text for chunk in chunks]))
for chunk, vector in zip(chunks, vectors):
points.append(
PointStruct(
id=len(points),
vector=vector.tolist(),
payload={
"text": chunk.text,
"source": str(path),
"tokens": chunk.token_count,
},
)
)
client.upsert(collection_name=COLLECTION, points=points, wait=True)
print(f"{len(points)} chunks from {len(paths)} files")Then retrieve and answer. query_points is the current query entry point, and the hits live on .points:
import anthropic
question = "What is our refund window for annual plans?"
query_vector = list(embedder.embed([question]))[0].tolist()
hits = client.query_points(
collection_name=COLLECTION,
query=query_vector,
limit=5,
with_payload=True,
).points
for rank, hit in enumerate(hits, start=1):
print(f"{rank}. {hit.score:.3f} {hit.payload['source']}")
print(f" {hit.payload['text'][:120]}")
context = "\n\n".join(
f"[{rank}] source={hit.payload['source']}\n{hit.payload['text']}"
for rank, hit in enumerate(hits, start=1)
)
llm = anthropic.Anthropic()
message = llm.messages.create(
model="claude-opus-5",
max_tokens=4096,
system=(
"Answer using only the numbered context blocks. "
"Cite the block numbers you used. "
"If the blocks do not contain the answer, say that they do not."
),
messages=[{"role": "user", "content": f"{context}\n\nQuestion: {question}"}],
)
for block in message.content:
if block.type == "text":
print(block.text)That is the whole system. Roughly sixty lines, and it will answer a surprising number of questions correctly, which is exactly why people stop here and ship it.
Freeze the configuration
A baseline you cannot rebuild is a story you told yourself. Write the four decisions to disk next to the code, as data, not as comments:
{
"run": "baseline-01",
"chunking": {"strategy": "fixed_token", "size": 512, "overlap": 0, "tokenizer": "BAAI/bge-small-en-v1.5"},
"embedding": {"model": "BAAI/bge-small-en-v1.5", "dim": 384},
"index": {"engine": "qdrant-1.18", "collection": "baseline", "distance": "cosine"},
"retrieval": {"top_k": 5},
"generation": {"model": "claude-opus-5"}
}Every experiment for the rest of the course is a copy of this file with one field changed and a new collection name. When you cannot remember in October whether the good result came from the reranker or from the day you quietly bumped top_k to 20, this file is the answer.
Try it
Ingest at least twenty of your own documents, not a toy dataset. Then pick five questions you already know the answers to, and for each one run the retrieval step and save the top 5 hits to a file: question, then rank, score, source path, and the first 200 characters of each chunk.
You are done when three things are true. The ingest script prints a chunk count greater than the file count. the .points list returned by query_points holds five hits with non-empty payload["text"]. And you have baseline-01.json plus a saved file of twenty-five retrieved chunks.
One rule: while you read those twenty-five chunks you will see obvious problems, a chunk that starts mid-sentence, a table rendered as noise, the right document ranked fourth behind three near-duplicates. Write each one down in a list called suspicions.md. Fix none of them.
Common mistakes
- Tuning the baseline the moment it disappoints you. You will want to raise
top_k, add overlap, swap the model. Every one of those changes may well help, and you have no way to know, so all you have done is move the zero mark. Log the impulse insuspicions.mdand move on. - Chunking with a tokenizer the embedding model does not use. Chonkie's
TokenChunkerdefaults totokenizer="character", and plenty of examples pass a GPT-style tokenizer instead. Either way your "512-token" chunks are not 512 tokens tobge-small-en-v1.5, so the long ones overflow its 512-token window and the tail is truncated before embedding. The text is in your payload, indexed, and invisible to search. - Reading
message.content[0].text. Thinking is on by default onclaude-opus-5, so the first content block can be a thinking block whose text is empty. Loop overmessage.contentand take the blocks whereblock.type == "text". - Treating
embedder.embed()as a list. It returns a generator of numpy arrays. Iterate it once, wrap it inlist()if you need it twice, and call.tolist()before handing a vector to Qdrant. - Deleting the baseline once something better exists. Keep the collection and the config file for the whole course. The cost is a few hundred megabytes; the alternative is arguing about improvements you cannot demonstrate.
Where this goes next
You now have a system that produces answers and no way to say whether they are good ones. The next session, Building the Question Set, fixes the harder half of that: mining real user questions and labelling the passages that actually answer them, instead of asking a model to invent both sides and grading yourself on your own homework.