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.
| Approach | What happens | Result |
|---|---|---|
| Post-filter | Search the whole index for top k, then discard non-matching rows in your code | Fewer than k results, often zero. Silent. |
| Pre-filter | Restrict to matching rows first, then search among them | Correct count, correct rows |
| Filtered search | Push the filter into the index so it is applied during traversal | Correct, 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.
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.
Push the constraint into the query itself. The store combines it with the index, so it can return the count you asked for.
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.
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.
- Enforced by the store - only permitted chunks come back
- Left to a prompt instruction - the chunk comes back regardless
| Where the check lives | Is it a control? |
|---|---|
WHERE clause or store filter in the retrieval query | Yes |
| Postgres row-level security on the chunks table | Yes, and it survives a forgotten WHERE (not if the app connects as the table owner) |
| A separate index or collection per tenant | Yes, and the strongest isolation available |
| An instruction in the system prompt | No |
| Checking the model's answer afterwards | No - 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.
| Field | Why | Recoverable later? |
|---|---|---|
tenant_id, allowed_group_ids | Security boundary | Sometimes, painfully |
source_url, doc_id, doc_title | Citations, and users trust answers they can check | No, once the source moves |
doc_version, text_hash, meta_hash | Detecting what changed; the two hashes drive incremental re-indexing | No |
ingested_at, updated_at | Recency ranking, staleness alerts | No |
doc_type, lang, section_path | Everyday filtering, and debugging | Only by reprocessing |
chunk_index, parent_id | Small-to-big retrieval, neighbour expansion | Only by re-chunking |
embedding_model | Knowing which vectors are stale during a model migration | No |
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
- Filter before or during the search, never on the results afterwards.
- Expect post-filtering to return fewer than k, sometimes zero.
- Enforce permissions in the query, never in the prompt.
- Store tenant, source, date, type and permissions at index time.
- Blend recency as a score adjustment, not as a hard date cutoff.
RECALL NO SCROLLING BACK
You run a top-10 vector search and then drop everything not matching tenant_id in your application code. The failure mode is...
Permission filtering belongs...
The main risk of a very selective pre-filter over an HNSW index is...
Which metadata is impossible to recover later if you do not capture it at index time?
A good way to favour recent documents is...