RAGStoring vectors

Metadata and filtering

The half of retrieval that is not semantic, including the half that is a security boundary.

WHY THIS, FOR YOU

You asked for the top 10 chunks for this tenant and got 3, or none, and the query looked fine. Or worse - a user saw a paragraph from a document they were never allowed to open, because the only thing standing between them and it was a sentence in the system prompt. Both come from the same misunderstanding: treating filters as a refinement of similarity search rather than as a constraint on it. This lesson shows you where the filter has to run, what to store so you can filter at all, and which filters must be enforced by the database and never by the model.

Similarity gets the attention, but it is about half of what a retrieval query does. The other half is structured and boring: this tenant, this language, this document type, published after this date, visible to this user. None of it is fuzzy, all of it has to be exactly right, and one item is a security boundary.

1.Where the filter runs decides whether it works

Three places a filter can happen, and they are not interchangeable.

ApproachWhat happensResult
Post-filterSearch the whole index for top k, then discard non-matching rows in your codeFewer than k results, often zero. Silent.
Pre-filterRestrict to matching rows first, then search among themCorrect count, correct rows
Filtered searchPush the filter into the index so it is applied during traversalCorrect, and fast at scale

Post-filtering is the trap, because it is the natural way to write the code. Ask for 10, filter, and now you have 2. Nothing errored. The user sees a thin answer. Take 50 tenants of similar size, with results spread evenly across them (an invented, simple case). A top 10 then holds 10 / 50 = 0.2 of your tenant's chunks on average, so most queries come back empty.

The fix is to put the filter in the query. In SQL that is a WHERE clause next to the ORDER BY on distance; in a dedicated store it is the filter argument in the search call. Either way the store, not your application, decides how to combine the constraint with the index.

NaivePost-filter: search, then discard

Ask the store for the top k, then drop rows that fail the constraint in your own code. The store picked its k without knowing the constraint, so whatever the filter removes is gone.

What to doPre-filter or filtered search: constrain first

Push the constraint into the query itself. The store combines it with the index, so it can return the count you asked for.

Same constraint, two different places to apply it. Only the right-hand version lets the store return the k you asked for; post-filtering fails silently, with no error when the count comes back short.

Combining the two is hard, and a WHERE clause does not make that go away. An HNSW graph was built over every vector, so a narrow filter means the walk keeps reaching nodes it must reject. Stores differ: some keep filter-aware structures (as of October 2026, Weaviate's docs name ACORN as its default filter strategy since v1.34), and some fall back to an exact scan when few rows match.

Postgres with pgvector looks safe and is not. As of October 2026, its README says the filter runs after the index scan: with the default hnsw.ef_search of 40, a condition matching 10% of rows leaves about 4 rows on average, not 10. Since version 0.8.0 you can turn on iterative index scans, which keep scanning until enough rows pass. The query below never returns a row it should not; whether it returns 10 depends on those settings. Count the rows on real data.

-- Constraints and similarity in one query. Returns up to 10; with an HNSW
-- index it can return fewer unless iterative scans are on (see above).
SELECT id, doc_id, body,
       1 - (embedding <=> $1) AS similarity
FROM chunks
WHERE tenant_id  = $2                             -- hard boundary
  AND allowed_group_ids && $3                     -- hard boundary: any of the user's groups
  AND lang       = $4
  AND doc_type   = ANY($5)
  AND published_at <= now()
ORDER BY embedding <=> $1
LIMIT 10;

-- Recency as a preference, not a cutoff: blend similarity with age decay.
-- Sorting by a computed score cannot use the HNSW index: it scores every matching row.
SELECT id, body,
       (1 - (embedding <=> $1))
         - 0.15 * (1 - exp(-EXTRACT(epoch FROM now() - updated_at) / (86400 * 180)))
       AS score
FROM chunks
WHERE tenant_id = $2 AND allowed_group_ids && $3
ORDER BY score DESC
LIMIT 10;

The second query is a starting point. The 180-day time constant and the 0.15 weight are yours to set, by trying a couple of values against real queries.

0 days
FreshNo decay penalty yet - similarity alone decides the ranking.
The decay term from the query above, felt as a knob. The bands are illustrative - the 180-day time constant and the 0.15 weight are yours to tune against real queries, not fixed here.

2.Permissions are not a preference

Everything above is about quality. This part is about not having an incident.

If a chunk is retrieved, it is exposed. The model reads it, and the answer can quote or leak it. Even if the answer is clean, retrieved chunks are usually logged, traced and shown in a citations panel. No later step makes an unauthorised chunk safe.

So: the permission filter is part of the retrieval query, enforced by the store. Not a line in the system prompt, not a check on the generated answer. A prompt instruction is a suggestion to a probabilistic system; a WHERE clause is a constraint.

Retrieval query runsAgainst the store, with whatever constraints it was given
The permission check
  1. Enforced by the store - only permitted chunks come back
  2. Left to a prompt instruction - the chunk comes back regardless
Whatever comes back is exposedIn the context window, usually logged, and often shown in a citations panel
What happens after a chunk is retrieved. Nothing downstream can undo it - the enforcement branch has to be decided before the query runs, not after.
Where the check livesIs it a control?
WHERE clause or store filter in the retrieval queryYes
Postgres row-level security on the chunks tableYes, and it survives a forgotten WHERE (not if the app connects as the table owner)
A separate index or collection per tenantYes, and the strongest isolation available
An instruction in the system promptNo
Checking the model's answer afterwardsNo - the data was already read

A permission check run on retrieved chunks before the model sees them is still a post-filter, with the same fewer-than-k failure, so fetch more candidates than you need; Permissions that travel with the data covers where such a check fits. Two practical notes. First, permissions change and vectors do not: if access is derived from groups that change often, store the group ids on the chunk and resolve the user's groups at query time, rather than baking a user list into the row. Second, decide what an empty result means. A user who may see nothing should get "I could not find anything", and your logs should record that the filter emptied the candidate set, or you cannot tell a permissions bug from a retrieval bug.

A filter that protects data is a database constraint. If it lives in a prompt, it is not a control, it is a wish.

3.What to store, and store it now

Metadata you did not capture at index time is usually gone. The source has moved or been edited, and no reprocessing will tell you what it said when you ingested it. A metadata column costs little next to a 1024-dimension vector, so store more than you think you need.

FieldWhyRecoverable later?
tenant_id, allowed_group_idsSecurity boundarySometimes, painfully
source_url, doc_id, doc_titleCitations, and users trust answers they can checkNo, once the source moves
doc_version, text_hash, meta_hashDetecting what changed; the two hashes drive incremental re-indexingNo
ingested_at, updated_atRecency ranking, staleness alertsNo
doc_type, lang, section_pathEveryday filtering, and debuggingOnly by reprocessing
chunk_index, parent_idSmall-to-big retrieval, neighbour expansionOnly by re-chunking
embedding_modelKnowing which vectors are stale during a model migrationNo

People leave out the last row and regret it: without it, a half-finished re-embed leaves a table you cannot reason about.

Filter columns need an index like any other: a tenant_id with no B-tree index makes filtered search slow in a way that looks like the vector index's fault (Qdrant's docs, as of October 2026, say the same of payload indexes). A filter matching a handful of rows is best answered by an exact scan; one matching nearly every row may as well not exist. Do not build the full permission machinery for a single-tenant corpus with no access rules, and keep a hard date cutoff for real rules, such as a policy that took effect on a set day.

WHAT YOU TAKE AWAY

  1. Filter before or during the search, never on the results afterwards.
  2. Expect post-filtering to return fewer than k, sometimes zero.
  3. Enforce permissions in the query, never in the prompt.
  4. Store tenant, source, date, type and permissions at index time.
  5. Blend recency as a score adjustment, not as a hard date cutoff.

RECALL NO SCROLLING BACK

  1. You run a top-10 vector search and then drop everything not matching tenant_id in your application code. The failure mode is...

  2. Permission filtering belongs...

  3. The main risk of a very selective pre-filter over an HNSW index is...

  4. Which metadata is impossible to recover later if you do not capture it at index time?

  5. A good way to favour recent documents is...

GO DEEPER