Vector Databases
By the end of this lesson
Store embeddings and query them by similarity.
The embeddings lesson produced a vector for a piece of text and measured the distance between two of them. That works for three sentences held in memory. A handbook, a set of finance procedures and two years of internal notes come to tens of thousands of chunks, and comparing a question against every one of them on every request is not a plan.
What you need is a store that holds vectors, finds the closest few to a query vector quickly, and lets you restrict the search to rows the caller is allowed to see. That is the whole job description. "Vector database" makes it sound like a new category of infrastructure; for most teams it is a column and an index on a database they already run.
What one stored record holds
The vector alone is useless. A similarity search that returns five vectors gives you five lists of numbers and no answer. Every record needs three things beyond the vector:
- The vector
- The embedding of this chunk. Fixed length, set by the embedding model. Every vector in the collection must have the same length, which is one reason a model change is a full re-index.
- The text that was embedded
- Store it, do not recompute it. This exact string is what goes into the prompt, so it has to be the string the vector was built from. Reconstructing chunks later and hoping they match is a subtle way to end up citing text the search never actually ranked.
- Metadata you will filter on
- Document type, audience, department, effective date, language, whether the document is current. Decide these before you index. Adding a filter you did not store means re-indexing to backfill it.
- A source reference a human can follow
- Document id, title, heading path, page or anchor, and a URL. This is what turns a retrieved chunk into a citation someone can open and check. Without it your assistant makes claims nobody can verify.
- The embedding model name and version
- On every row, not in a comment. It is how you detect a half-migrated index, and how a query can refuse to compare vectors from two different spaces.
{
"chunk_id": "handbook-expenses-2025-03#s4-p2",
"document_id": "handbook-expenses",
"document_title": "Expenses and Travel Handbook",
"heading_path": "Travel > Rail and air > Booking notice",
"chunk_text": "Flights must be booked at least fourteen days before departure unless a director has approved shorter notice in writing. Standard-class rail travel booked seven or more days in advance is reimbursable without further approval.",
"source_url": "https://intranet.example.com/handbook/expenses#booking-notice",
"page": 11,
"audience": "all-employees",
"effective_from": "2025-03-01",
"superseded": false,
"embedding_model": "text-embedding-3-small",
"embedding_dimensions": 1536,
"embedding": [0.0142, -0.0387, 0.0091, "... 1533 more numbers ..."]
}CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE document_chunk (
chunk_id text PRIMARY KEY,
document_id text NOT NULL,
document_title text NOT NULL,
heading_path text NOT NULL,
chunk_text text NOT NULL,
source_url text NOT NULL,
page integer,
audience text NOT NULL,
effective_from date NOT NULL,
superseded boolean NOT NULL DEFAULT false,
embedding_model text NOT NULL,
embedding vector(1536) NOT NULL
);
CREATE INDEX document_chunk_embedding_idx
ON document_chunk USING hnsw (embedding vector_cosine_ops);
CREATE INDEX document_chunk_audience_idx ON document_chunk (audience, superseded);
-- The closest current chunks the caller is allowed to read
SELECT chunk_id,
document_title,
heading_path,
chunk_text,
source_url,
1 - (embedding <=> $1) AS similarity
FROM document_chunk
WHERE audience = ANY($2)
AND superseded = false
AND effective_from <= current_date
AND embedding_model = $3
ORDER BY embedding <=> $1
LIMIT $4;- vector(1536) fixes the dimension count in the column type, so a vector from the wrong model is rejected by the database rather than quietly stored. The number has to match your embedding model.
- The <=> operator is cosine distance in pgvector. Smaller means closer, so ORDER BY on it ascending puts the nearest chunk first. Subtracting from 1 converts distance into the similarity score the embeddings lesson used, which is the friendlier number to log.
- HNSW is an approximate index. It trades a small amount of recall for a very large speed gain, and approximate is the right trade here — you are retrieving candidate passages, not settling a payroll figure. Without any index the query still works, by comparing against every row, which is fine while you have a few thousand.
- The WHERE clause is doing security work, not tidying. audience and superseded are checked inside the query, so a chunk the caller may not read cannot reach the prompt. Filter in the search, never in the code that formats the results.
- Passing the embedding model as a parameter and matching on it means a query using the wrong model returns nothing instead of returning confident nonsense. A loud empty result is much easier to diagnose than a silently wrong ranking.
- One honest caveat about approximate indexes plus filters: the index searches for near neighbours first and your conditions are applied around that, so a very restrictive filter can return fewer rows than you asked for even when more exist. Check what your version does, and test with your narrowest realistic filter rather than an unfiltered query.
def search(question: str, caller: User, top_k: int = 5) -> list[Passage]:
query_vector = embed(question) # same model that built the index
rows = db.fetch(
SEARCH_SQL,
query_vector,
caller.audience_tags, # what this person may read
EMBEDDING_MODEL,
top_k,
)
passages = [Passage.from_row(row) for row in rows]
log.info(
"retrieval",
extra={
"question_chars": len(question),
"returned": len(passages),
"top_similarity": passages[0].similarity if passages else None,
"chunk_ids": [p.chunk_id for p in passages],
},
)
return [p for p in passages if p.similarity >= MIN_SIMILARITY]- embed uses the same model constant the index was built with, read from one place. A literal model name at each call site is how half an index ends up in a different coordinate space.
- caller.audience_tags goes into the query, so permissions are applied by the database. The model never sees a passage the person could not have opened themselves.
- The log line is the one thing people leave out and later wish they had. When an answer is wrong, the first question is always which chunks were supplied, and this line answers it without a reproduction.
- The relevance floor is applied after ranking, and it is what lets the pipeline say it does not know. top-k always returns k rows if k rows exist, however unrelated they are — a question about parking scores something against an expenses corpus. Filtering by score is what turns "the five least bad rows" into "nothing relevant here".
- Pick MIN_SIMILARITY from evidence, not from an article. Run fifty real questions, including some your corpus genuinely does not cover, and look at where the scores fall. The number is specific to your model and your documents.
Whether you need dedicated infrastructure is a scale question, and it is worth answering honestly before you add a service to your deployment:
| Relational database with a vector extension | Dedicated vector database | |
|---|---|---|
| Comfortable range | Thousands to a few million chunks, which covers most internal corpora | Tens of millions upward, or very high query rates |
| Operational cost | One more column and index on a database you already back up and monitor | Another service to deploy, secure, back up, upgrade and pay for |
| Filtering alongside similarity | Ordinary SQL, joined to your real tables | A filter syntax per product, with its own limits on what can be combined |
| Transactions with your other data | Yes. Document and chunks committed together | No. Two stores to keep in step, and a reconciliation job when they drift |
| Tuning available | Index parameters and the query planner | More levers: index types, sharding, replication, quantisation |
| Sensible trigger to move | Start here | Measured latency at your real corpus size, not a projection |
Summary
- A useful record holds the vector, the exact text embedded, metadata you filter on, a source reference a human can open, and the embedding model name
- Similarity search returns the top k nearest vectors; k is a count and says nothing about whether anything was relevant
- Apply permission and freshness filters inside the query so an ineligible passage never reaches the prompt
- A relational database with a vector extension carries most internal corpora; a dedicated vector database is a scale decision backed by measurement
- Keep the embedding model name on every row, because a mixed index degrades results without raising an error
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Design the record, then break it
Write down the fields you would store for chunks of three document types: the expenses handbook, minutes of a finance meeting, and a client contract. Include everything you would filter on and everything you would cite.
Now pick three questions a user might ask and check your design answers each: "what is the flight booking notice?", "what did we decide about receipt thresholds in February?", and "what are the payment terms for Kestrel Foods?"
Show solution
The fields common to all three are the ones from the record above: chunk text, document id and title, heading path, source link, audience, effective date, superseded flag, embedding model.
The differences are where the design earns its keep. Meeting minutes need a meeting date, and the February question is a date filter plus similarity, not similarity alone. Contracts need a client id and almost certainly a tighter audience tag than the handbook — the same store now holds material with three different sensitivity levels.
The contract question also exposes the embeddings blind spot from module 1. "Kestrel Foods" is a name, and names embed weakly. A metadata filter on client id does this properly; similarity search on the name alone will disappoint.
If you found yourself wanting a field you had not listed, that is the point of the exercise. Backfilling a filter across an indexed corpus means re-reading every source document, so an hour on the record shape saves a re-index later.
Think about it
Do you need a vector database?
Your corpus is 9,000 chunks. It grows by perhaps 200 a month. Around 40 employees use the assistant, with a busy hour of roughly 100 questions. Someone proposes adding a managed vector database to the deployment.
Make the case either way, and say what evidence would change your mind.
Show solution
At 9,000 chunks and roughly two questions a minute at peak, a vector column on your existing PostgreSQL will not be the slow part of the request. Generation takes seconds; the search takes milliseconds. Adding a service gains you nothing measurable and costs you another thing to secure, back up and upgrade.
The stronger argument against the extra service is consistency. Two stores means a document and its chunks can be committed in one and not the other, and you will eventually write a reconciliation job that nobody wants to own.
The evidence that would change the decision is measured, not projected: search latency at your real corpus size on your real hardware, at peak concurrency. Also a genuine need for something your database cannot do — a specific index type, or sharding across nodes.
Growth of 200 chunks a month is worth putting in perspective. That is 2,400 a year. You are not approaching a scale problem, and designing for one you will not reach is its own cost.
Saved in this browser only.