Skip to main content
ANVISoftware Solutions
Lesson 12 of 22Intermediate16 min

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.
One record, as it would arrive at the store
JSON
{
  "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 ..."]
}
PostgreSQL with the pgvector extension: table, index, and a filtered top-k query
SQL
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.
The query side, end to end
Python
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 extensionDedicated vector database
Comfortable rangeThousands to a few million chunks, which covers most internal corporaTens of millions upward, or very high query rates
Operational costOne more column and index on a database you already back up and monitorAnother service to deploy, secure, back up, upgrade and pay for
Filtering alongside similarityOrdinary SQL, joined to your real tablesA filter syntax per product, with its own limits on what can be combined
Transactions with your other dataYes. Document and chunks committed togetherNo. Two stores to keep in step, and a reconciliation job when they drift
Tuning availableIndex parameters and the query plannerMore levers: index types, sharding, replication, quantisation
Sensible trigger to moveStart hereMeasured 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.