What retrieval is for
Three questions, same as ever: what it is, how it works, when not to bother.
The model has never seen your data
It knows a great deal about the world in general and nothing whatsoever about your refund policy, your customers, or what happened in yesterday's standup. There is no way to teach it by asking. The only way it can know something is if the text is in front of it when it answers.
So put the text in front of it. That's RAG. The R is the hard part.
The obvious move is to paste in everything, and for a small handbook that is genuinely the right answer — see when not to bother below. It stops being right when "everything" is ten thousand documents, because you would be paying to send all of them on every question, and because a model handed a haystack answers worse than one handed the needle.
The whole pipeline, once
Ahead of time: cut your documents into pieces, turn each piece into a vector, store them. At question time: turn the question into a vector, find the closest pieces, paste them into the prompt, ask.
documents→chunks→vectors→store
question→vector→search→top few→prompt
Everything before the question is preparation you do once and repeat when documents change. Everything after it happens in the couple of hundred milliseconds a user is waiting. Knowing which side of that line a piece of work sits on decides most of your architecture.
Chunking is string handling. Storing is a database. Searching is search. Only the embedding and the final answer involve a model at all, and the embedding is a much smaller and cheaper one than the model you are asking. Debug this like the search system it is, not like a mysterious AI problem.
What a vector actually is
No maths background needed. It is a list of numbers with one useful property.
A position, not a summary
An embedding model reads a piece of text and gives back a fixed list of numbers — a thousand-odd of them, typically. Those numbers are coordinates. The model has been trained so that texts meaning similar things land near each other.
That's it. You cannot read a vector, or turn it back into the text. All you can do is measure how far one is from another, and that turns out to be enough.
Distance is normally measured by the angle between two vectors rather than the gap between their tips, which is what cosine similarity means. 1.0 is pointing the same way, 0 is unrelated. The practical upshot: a long passage and a short question can still score highly, because length pushes the tip further out without changing the direction much.
Vectors from different models are not comparable. Not slightly — not at all. If you change embedding model, or even its version, every vector you have stored becomes meaningless against new ones. See re-embedding, because this is a migration, not a config change.
Similar is not the same as relevant
This is the sentence to remember. Embeddings measure aboutness. A passage saying "we do not offer refunds on sale items" and one saying "refunds are issued within 30 days" are both extremely about refunds, and will score almost identically against "can I get a refund".
Nothing in the vector knows which one answers the question. That is what reranking is for.
The same blindness explains the classic embarrassment: ask "which customers have not paid?" and get back passages about customers who have paid. Negation barely moves a vector. If your questions turn on words like not, except, before or without, pure vector search will quietly let you down.
Chunking
This matters more than which database you choose. It is also the part people spend the least time on.
Why you cut things up at all
Two reasons. A whole document has one vector, and a hundred-page manual is "about" so many things that its vector ends up about nothing in particular. And you have to paste the result into a prompt, so it needs to be small enough to afford.
The tension is the whole game. Small chunks are precise and score sharply, but a chunk that has been cut adrift from its heading may no longer say what it is about — "this is not covered" is useless when the sentence naming what "this" is landed in the previous chunk. Large chunks keep context and dilute meaning.
Cut on structure, not on a number
Splitting every 500 characters is the default in every tutorial and it is the worst option available. Documents already have joints — headings, sections, paragraphs, list items, code blocks. Cut there, and only fall back to a character count when a section is too big.
Overlap is the cheap insurance: repeat the last sentence or two of each chunk at the start of the next, so a fact that straddles a boundary survives in one piece somewhere. It costs storage and nothing else. Ten to twenty per cent is normal.
Put the document title and the section heading at the top of every chunk. It costs a few tokens and repairs most of the orphaned-fragment problem at a stroke, because now the chunk says what it is about even when read alone.
And keep byte offsets on every chunk. When someone asks why the answer was wrong you want to open the source document at the exact place, not go hunting.
Where the vectors live
Two families, and the answer is more often the boring one than the internet suggests.
In the database you already have
Postgres with pgvector gives you a vector column next to your ordinary
columns. You search it with SQL. It joins to your real tables. It is in your transactions
and your backups.
For most applications this is the right answer and it is not close.
The advantage is not performance, it is coherence. Filtering to one tenant, one date
range and one document type is a WHERE clause rather than a second query
against a second system whose copy of that metadata may be out of date.
A dedicated vector service
Pinecone, Qdrant, Weaviate, Milvus and friends do one job and do it at scale — hundreds of millions of vectors, sharding, replication, and search features you would otherwise build.
The cost is that you now run two systems that must agree with each other.
That disagreement is not hypothetical: it is the deleted record still being returned in search results a week later. Reach for one of these when you have measured a real problem with the boring option, not in anticipation of one.
The index, and the honest trade in it
Comparing a question against every stored vector is exact and, past a few tens of thousands of rows, too slow. So you build an index that finds almost the nearest ones, very fast. The word approximate is doing real work in that sentence: you are trading away some recall for speed, deliberately.
HNSW is the usual choice — fast queries and good accuracy, at the price of memory and a slow build. IVFFlat sorts vectors into buckets and searches only the nearest few; it builds quickly and uses less memory, and it is less accurate. Both have a knob that trades recall against latency, and both need the data present before you build them.
Run a few hundred queries against an exact search and against your index, and compare the result sets. If the index is returning 92% of what exact search found, you now know your ceiling, and you can stop blaming the model for the other 8%. Almost nobody does this and it takes an hour.
The dual store
Ordinary records in one place, vectors in another, joined by a key. This is what most real systems look like, and the sync is where they go wrong.
The rule that keeps you out of trouble
Rows are the truth. Vectors are an index. An index is something you can delete and rebuild from the truth at any time. If you ever cannot — if something exists only as a vector and its metadata — you have not chosen an architecture, you have made a filing error.
Test yourself with one question: if the vector store were wiped right now, could you rebuild it entirely from your database? If yes, everything below is a chore. If no, you have a data-loss problem wearing a search problem's clothes.
What the schema looks like
-- the truth
CREATE TABLE documents (
id bigserial PRIMARY KEY,
tenant_id bigint NOT NULL REFERENCES tenants(id),
title text NOT NULL,
body text NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- the index, derived and disposable
CREATE TABLE document_chunks (
id bigserial PRIMARY KEY,
document_id bigint NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
tenant_id bigint NOT NULL, -- denormalised so we can filter first
chunk_index int NOT NULL,
content text NOT NULL,
start_offset int NOT NULL, -- so we can point at the source
embedding vector(1536),
embed_model text NOT NULL, -- which model made this
source_hash text NOT NULL -- hash of the text it was made from
);
CREATE INDEX ON document_chunks
USING hnsw (embedding vector_cosine_ops);
Four columns there are doing unglamorous work: tenant_id so filtering happens
before the search, start_offset so you can cite, embed_model so you
know what is comparable with what, and source_hash so you can tell whether a
vector is stale without re-embedding to find out.
ON DELETE CASCADE is the single most valuable line in that schema. It means
the most common sync bug in the world — deleted record, orphaned vector, ghost search
result — is structurally impossible. If your vectors live in a separate service you have
to write that behaviour, remember to call it, and handle it failing.
SELECT c.content, d.title, c.start_offset,
1 - (c.embedding <=> $1) AS score
FROM document_chunks c
JOIN documents d ON d.id = c.document_id
WHERE c.tenant_id = $2 -- filter FIRST
AND c.embed_model = $3
AND d.updated_at >= $4
ORDER BY c.embedding <=> $1
LIMIT 20;
One statement, one round trip, transactionally consistent with the rest of your data, and the tenant filter applied by the database rather than hoped for afterwards. That is the argument for keeping them together, and it is a strong one.
Filter before you search, not after
Say you want the top 10 results for one customer. Search all vectors, take the top 10, then throw away the ones belonging to other customers — and you may be left with two. Or none. You asked for ten and the ranking silently gave you the wrong thing.
Post-filtering does not reduce quality gently. It destroys recall, and it does so most badly exactly when the filter is most selective.
Pre-filtering means the constraint is applied as part of the search, so the ten you get back are the best ten that qualify. Every serious vector store supports this; the trap is that the naive API in the quickstart usually does not, and it works fine on your test data where every row belongs to you.
Keeping the two in step
Four events, four answers. Insert: chunk, embed, write. Update: re-chunk, re-embed, replace the old chunks. Delete: remove the chunks. Backfill: everything that existed before you added any of this.
Update is the one that bites, because a row can change without anybody thinking about search.
Do not embed inline in the request that saved the row — the embedding call can be slow and it can fail, and neither should stop a user saving their work. Write the row, enqueue the job, let it retry. Store the hash of the text you embedded so the job can decide whether there is anything to do, and so a nightly sweep can find rows whose hash no longer matches.
Queues drop things. Deploys interrupt workers. Somebody will run a bulk UPDATE
in a console. A job that walks the table comparing source_hash against the
current text — and reports how many disagreed — turns "our search is stale and nobody knows
why" into a number on a dashboard.
Changing embedding model is a migration
A new embedding model comes out, it scores better, you switch. Every vector you have is now incomparable with every vector you make from this moment on. Search does not error. It just quietly returns nonsense for anything old.
This is why embed_model is a column and not a constant in your code.
The safe shape: write both old and new vectors for a while, keep serving from the old one, backfill the new one in the background, compare the two on a set of real queries, then cut over and drop the old column. Dimensions usually differ between models, so this often means a second column rather than an overwrite — which is a feature, because it makes rollback possible.
Hybrid search, and reranking
Vector search alone is not the finished article. These two are the largest quality wins available and both are cheap.
Vectors are bad at exact things
Search for error code E-4021, or invoice INV-99187, or a surname,
or a function called parseUserToken. Embeddings blur all of these — the whole
point of the model is that near-enough is good enough, and for an identifier near enough is
useless.
Keyword search finds them instantly. It is also helpless at "how do I get my money back" when the document says "refund", which is exactly where vectors shine.
So run both and combine the rankings. The standard method is reciprocal rank fusion: score
each result by 1 / (60 + its rank) in each list and add them up. It needs no
tuning, no score normalisation, and no knowledge of how the two systems scale their
numbers — which is why it has outlasted cleverer schemes.
Postgres ships full-text search. If your chunks are already in Postgres, hybrid is one
tsvector column and a second ranked query away, in the same statement, with
the same tenant filter. There is very little reason not to.
Retrieve widely, then rerank hard
Fetch fifty candidates cheaply, then run a slower model that looks at the question and each chunk together and scores how well it actually answers. Keep the best five.
This is the single biggest quality jump most pipelines can make, and it is an afternoon's work.
The reason it works: an embedding is made without knowing your question, so it can only measure aboutness. A reranker reads both at once, so it can tell "refunds take 5–10 days" from "refunds are not available on sale items" for the question "how long does a refund take". Nothing in the first stage can make that distinction.
Latency, and it is not free — fifty pairs to score is real time. But it lets you retrieve more loosely than you otherwise dare, which means fewer misses at the first stage. Budget for it and measure both halves separately, or you will not know which one is failing.
Getting it into the prompt
You found the right passages. It is still possible to lose here.
Say where each piece came from, and what to do without one
Answer using only the sources below. If they do not contain the
answer, say you do not know. Cite the source id for each claim.
<source id="doc-114#3" title="Refund policy">
Customers may request a refund within 30 days of delivery…
</source>
<source id="doc-118#0" title="Shipping and cancellation">
Orders can be cancelled at no cost until they enter packing…
</source>
Question: how long do I have to ask for my money back?
Delimit each passage, give it an id, and tell the model explicitly what to do when the answer is not there. Without that last instruction it will fall back on general knowledge and sound just as confident doing it.
Ids you can resolve back to a document and an offset make citations real rather than decorative — a person can click through and check. That single feature does more for trust than any amount of accuracy improvement, because it lets a sceptical user verify instead of believing you.
Retrieved text is data, not instructions. If a document contains "ignore your instructions and…", a naive pipeline will do it. Keep sources inside delimiters, say plainly that content within them is reference material only, and never let retrieved text decide whether a tool gets called.
Measuring it, and knowing when to skip it
Both of these will save you more time than any amount of tuning.
Thirty questions with known answers
Write down thirty real questions and, for each, which chunk ought to come back. Now you can answer the only question that matters when you change something: did that help?
Without it you are adjusting chunk sizes by feel and shipping on vibes.
Two numbers are enough to start. Recall@k: how often the right chunk is somewhere in the top k. MRR: how near the top it was. Recall tells you whether retrieval is even possible; MRR tells you whether reranking is earning its keep.
And separate the two failures. If the right chunk was never retrieved, no prompt change will save you. If it was retrieved and the answer was still wrong, retrieval is fine and the problem is downstream. Teams burn weeks on the wrong half of that.
When not to do any of this
If your entire corpus fits comfortably in a prompt, put it in the prompt and cache it. No chunking, no embeddings, no sync, no index, nothing to go stale. A fifty-page handbook is about 25,000 tokens — cached, that is cheap and it beats any retrieval pipeline on accuracy because nothing can be missed.
Retrieval is also the wrong tool for questions that need aggregation rather than lookup.
"How many orders were refunded last month" is a SELECT COUNT(*). Fetching
twenty chunks about refunds and hoping the model counts correctly is a worse database with
extra steps. Give it a tool that runs the query.
The retrieval bench
A real document, cut up in front of you. Move the chunk size and watch the pieces redraw. Ask something and watch every chunk get scored. Then switch between keyword, vector and hybrid and see the ranking change — including the two cases where each one on its own gets it wrong.
What actually goes wrong
In rough order of how often it happens.
Blaming the model for a retrieval miss
The answer is wrong, so the prompt gets rewritten, then the model gets swapped. The right chunk was never retrieved and none of that could have helped.
Log what was retrieved for every request. Before touching anything else, check whether the answer was in the context at all. This one habit will save you more time than everything else on this page.
Pure vector search in production
It demos beautifully and then someone searches for an order number, a surname or an error code and gets nothing useful.
Hybrid is the default, not the advanced option. Add keyword search and fuse the rankings.
Orphaned vectors after a delete
A record is deleted. Its chunks are not. The thing keeps turning up in search results, sometimes for people who should never have seen it.
In the same database this is ON DELETE CASCADE. Across two systems it is code you must write, call and monitor — plus a sweep that catches what the code missed.
Post-filtering by tenant
Top 20 across everyone, then discard other tenants' rows, then wonder why a customer with few documents gets almost no results.
Filter inside the search. It is also the difference between a quality bug and a data-leak bug, depending on whether the discard ever gets skipped.
Swapping embedding model without a migration
Nothing errors. Search quality collapses for old content and stays fine for anything indexed since, which makes it maddening to diagnose.
Record the model on every row. Dual-write, backfill, compare, cut over.
Chunking on a character count
Sentences cut mid-clause, tables sliced in half, headings separated from the thing they head.
Split on the document's own structure and fall back to a size limit. Add overlap. Put the heading in the chunk.