Field Guide

Retrieval, vectors, and RAG.
Mostly it is search.

A model knows nothing about your data. RAG is the unglamorous business of finding the handful of paragraphs that answer a question and pasting them into the prompt before you ask it. That is the whole idea. Almost everything that goes wrong goes wrong in the finding — which is why most of this page is about search, and only a little of it is about AI.

Includes the dual store pattern
Depth
01

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.

documentschunksvectorsstore
questionvectorsearchtop fewprompt

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.

Nothing here is AI except the last step

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.

02

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.

The one that catches everyone

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.

03

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.

Two things worth doing

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.

04

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.

Measure the recall you gave away

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.

05

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.

The query this schema makes easy
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.

The sweep is not optional

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.

06

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.

You already have half of it

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.

What it costs you

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.

07

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.

Everything in here is untrusted

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.

08

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.

09

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.

Three things to try. Ask "how do I get my money back?" on Keyword only — the words "money back" appear nowhere in the document, so it flails. Now ask "what is E-4021?" on Vector only and watch it lose the exact code among passages that are merely about payments. Hybrid gets both. Then drag the chunk size down to 120 with no overlap and ask about refund timing — the answer gets cut in half and neither piece can answer it any more.
And one that does not work. Ask for your money back and turn reranking on: the order barely moves. Look at the winning chunk and you will see why — at this size the refund rule and the list of exclusions are in the same chunk, so there is nothing for a reranker to choose between. No amount of clever scoring separates two ideas you have already glued together. That is a chunking problem, and it is the most common shape of a retrieval problem that looks like a ranking problem.
10

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.