A vector search does not know who is asking. It knows which vectors are close.
Point one at a store holding two teams' documents and it will hand team A a paragraph from team B's contract, with a similarity score attached to make the result look considered. Nothing has malfunctioned. You asked for the nearest neighbours and it gave you the nearest neighbours; whose they were was never part of the question.
This guide measures three ways of dealing with that, on the stack from the vector database guide and the agent search guide. The first leaks. The second looks correct and is quietly useless. The third works, and still needs a step that is rarely mentioned.
The setup
The corpus is 205 real documents, 4,160 chunks, each tagged with the team that owns it — for this test, the document's real category, so 27 documents belong to finance. Substitute your tenant id, customer id or group; the mechanism is identical.
Our user is on the finance team. They ask twenty questions. We count what comes back.
Regime one: no filter
The default. Embed the question, fetch the ten nearest chunks, hand them to the model.
161 of 200 results (80.5%) belonged to another team
62 distinct documents the user had no business seeing
Four out of five results were somebody else's. And they were not junk that a model would obviously discard — they were the best answers available, which is exactly why they ranked:
asked: is 220 minus my age the right way to work out my zones
got: "Heart-Rate Zones: What '220 Minus Age' Gets Wrong"
team: health similarity: 0.4876
asked: can someone read what is inside my token without the key
got: "Decoding a JWT Is Not Verifying It"
team: developer similarity: 0.5642
In a demo this looks like the system working beautifully. That is the trap: an access-control failure in retrieval presents as unusually good results. Nobody files a bug when the answers are excellent.
Regime two: filter afterwards, in your own code
The obvious fix. Fetch the ten nearest, then drop anything the user is not allowed to see before it reaches the model. It is three lines and needs no support from the database. From the outside it looks airtight, because no foreign document ever reaches the caller.
It is also the version most likely to be in production somewhere right now. Here is what it did:
17 of 20 questions returned fewer than the 10 results asked for
16 of 20 questions returned NOTHING AT ALL
Sixteen questions out of twenty answered with silence. Not because the user lacked permission to see an answer — the finance team holds 27 documents, and the next regime finds ten permitted results for every single one of these questions. The permitted matches were below the cut. They existed, they were relevant, and the query never reached them, because the top ten had already been spent on documents that were then thrown away.
This is the worst of the three outcomes because it is the hardest to detect. There is no leak, no error and no exception — just a search box that says "no results" to a user whose own team wrote the answer, and a log line recording a successful query.
Regime three: filter inside the query
Push the constraint into the search itself, so the store never considers a forbidden chunk in the first place:
POST /collections/docs/points/search
{
"vector": [...],
"limit": 10,
"filter": { "must": [ { "key": "team", "match": { "value": "finance" } } ] }
}
0 of 20 questions returned fewer than 10 results
0 results from the wrong team
Correct and complete. The ranking now happens within what the user may see, which is what you wanted from the beginning.
The filter value must come from the server-side session, never from the request or the model. This is a security decision, not a measurement result. If the tenant id is a parameter your agent fills in, then it is a parameter your agent can be talked into filling in differently, and you have built an access-control system whose enforcement point is a language model's judgement. Read the identity where you already trust it, and attach the filter after that.
The step nobody mentions: permitted is not relevant
A filtered search always returns ten permitted results. It does not return ten useful ones. With nothing relevant in the tenant, it returns the least irrelevant thing it holds — and attaches a score:
asked: how do I run a vector database on my own machine
got: "The 4% Rule: Where Retirement's Most Famous Number..."
score: 0.0931
asked: which AI coding assistant should I be using in my editor
got: "Should I Pay Off My Loan Early? The Date Matters..."
score: 0.1313
Hand that to a model with "answer from these documents" and it will do its best with retirement withdrawal rates. You have replaced a leak with a fabrication.
The fix is to set a similarity floor and return nothing below it. That floor is a property of your corpus and model, so it has to be measured. On sixteen on-topic and sixteen off-topic questions:
floor real answers kept nonsense refused
0.25 16/16 11/16
0.30 16/16 14/16
0.35 16/16 15/16
0.40 15/16 15/16
0.45 13/16 16/16
0.35 keeps every real answer and refuses fifteen of sixteen. Push to 0.45 and you catch the last one at the cost of three real answers.
The lowest-scoring real answer came in at 0.3920. The highest-scoring nonsense came in at 0.4161. The distributions overlap. On this corpus, with this model, no threshold keeps every good answer and refuses every bad one. Expect the same on yours.
A floor is therefore not a solution but a choice about which error you would rather make: occasionally refusing a real answer, or occasionally serving a confident irrelevance. Either is defensible. Making the choice without noticing you made it is the problem.
How this was measured, and what it does not cover
Ollama 0.32.15, Qdrant 1.19.0, all-minilm at 384 dimensions, over 205 real documents chunked to 4,160 points. Every figure above is a count from a scripted run, not an estimate.
The tenancy is a stand-in. We used each document's real category as its team, so no labels were invented — but categories are not secrets, and a real deployment has consequences ours did not. The measurements show how the search behaves, and the mechanism is the same whether the payload field holds a category or a customer id.
The floor rests on thirty-two questions, sixteen a side. That is enough to demonstrate the overlap, which is the finding; it is not enough to pin 0.35 as the number for anything but this corpus. Run the same table on yours — it is about forty lines and one afternoon.
We tested the search, not an agent. Nothing here measures whether a model can be persuaded to ask for a different tenant, which is why the filter belongs on the server side of that boundary rather than in the tool arguments. That claim is reasoning about where trust sits, and we are marking it as such rather than dressing it up as a result.