Filtering vector search by tenant without leaking rows
A missing filter returns another customer's documents with a high similarity score. Where to enforce the boundary, and why one collection per tenant is the stronger option.
A vector search with no filter returns the most similar vectors in the collection — including the ones belonging to other customers. The failure isn’t an error; it’s a confident answer citing a document the asker should never have seen. POST /v1/vector/query on Infrai takes a filter, and the only question is whether your code can ever forget to pass it.
Two designs prevent that. One is a filter you can’t omit; the other is a collection per tenant.
The filter
curl -sS -X POST "https://api.infrai.cc/v1/vector/query" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"collection": "docs",
"embedding": [0.0119, -0.0461, 0.0802],
"top_k": 5,
"filter": {"tenant": "t_northwind"},
"include_metadata": true
}'
{
"ok": true,
"data": {
"items": [
{"id": "guide-gateway#0", "score": 0.91,
"metadata": {"url": "https://docs.example.com/gateway", "title": "Gateway basics",
"text": "An API gateway routes requests to services.", "tenant": "t_northwind"}}
],
"next_cursor": null
}
}
The filter matches on metadata you wrote at upsert time, so it only works if every vector carries the tenant.
A batch indexed before you added that field is a batch with no tenant on it, and its behaviour under a filtered query depends on how a missing key is treated — which is not something to leave to chance in a boundary this consequential. Backfill or re-index those vectors before you rely on the filter, and assert the field’s presence in whatever writes to the collection.
Make the filter impossible to omit
import os
import requests
API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
COLLECTION = os.environ.get("RAG_COLLECTION", "docs")
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
class TenantScopedIndex:
"""The tenant is a constructor argument, not a query parameter. There is no
method on this class that can search without it, which is the only reliable way
to stop one from appearing in a hurry six months from now."""
def __init__(self, tenant: str) -> None:
if not tenant:
raise ValueError("tenant is required")
self._tenant = tenant
def upsert(self, vectors: list[dict]) -> int:
stamped = [
{**vector, "metadata": {**(vector.get("metadata") or {}), "tenant": self._tenant}}
for vector in vectors
]
resp = SESSION.post(f"{API}/v1/vector/upsert",
json={"collection": COLLECTION, "vectors": stamped}, timeout=180)
resp.raise_for_status()
return len(stamped)
def query(self, embedding: list[float], top_k: int = 5,
extra_filter: dict | None = None) -> list[dict]:
# The tenant is merged LAST so a caller-supplied filter can never override
# it — passing {"tenant": "someone_else"} has no effect.
merged = {**(extra_filter or {}), "tenant": self._tenant}
resp = SESSION.post(
f"{API}/v1/vector/query",
json={"collection": COLLECTION, "embedding": embedding, "top_k": top_k,
"filter": merged, "include_metadata": True},
timeout=60,
)
resp.raise_for_status()
items = resp.json()["data"].get("items", [])
# Belt and braces: verify what came back belongs to this tenant. If the
# filter ever misbehaves, this turns a silent leak into a loud failure.
for item in items:
if (item.get("metadata") or {}).get("tenant") != self._tenant:
raise RuntimeError("cross-tenant row in result set — refusing to return")
return items
def index_for_session(session_id: str) -> TenantScopedIndex:
"""Derive the tenant from the verified session, never from the request body."""
verified = SESSION.get(f"{API}/v1/auth/session/verify/{session_id}", timeout=20)
verified.raise_for_status()
user_id = verified.json()["data"]["user_id"]
profile = SESSION.get(f"{API}/v1/auth/user/get/{user_id}", timeout=20)
profile.raise_for_status()
tenant = (profile.json()["data"].get("metadata") or {}).get("tenant_id")
if not tenant:
raise PermissionError("user has no tenant")
return TenantScopedIndex(tenant)
if __name__ == "__main__":
index = index_for_session(os.environ["SESSION_ID"])
print(len(index.query([0.0119, -0.0461, 0.0802])))
The post-query assertion is the part I’d insist on in review. It costs nothing, it catches a filter that stopped working, and it converts the worst failure mode — a quiet leak — into an exception someone sees.
One collection per tenant is stronger
The filter approach shares one index and relies on every query being correct. Per-tenant collections make a leak structurally impossible: a query against docs_t_northwind cannot return another tenant’s vectors, because they aren’t in it.
curl -sS -X POST "https://api.infrai.cc/v1/vector/collection/create" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"collection": "docs_t_northwind", "dimension": 1536, "metric": "cosine", "metadata": {"tenant": "t_northwind"}}'
| Concern | Shared + filter | Collection per tenant |
|---|---|---|
| Leak possible from a code bug | yes | no |
| Deleting one tenant’s data | filtered delete by ids | delete the collection |
| Number of collections to manage | one | one per customer |
| Cross-tenant analytics | possible | not possible |
| Standing cost | one collection | rent per collection |
The trade is operational. Per-tenant collections mean provisioning on signup, cleanup on churn, and standing rent per collection — real work. But for anything where a leak is a reportable incident rather than a bug, it’s the design to choose, and GET /v1/vector/collection/list keeps the inventory readable.
Deleting a tenant’s vectors
curl -sS -X DELETE "https://api.infrai.cc/v1/vector/delete" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"collection": "docs", "ids": ["guide-gateway#0", "guide-gateway#1"]}'
Delete takes explicit ids, not a filter — which is the strongest argument for deterministic ids that encode their source, because “remove everything this tenant indexed” then means listing ids you can reconstruct rather than searching for them. With per-tenant collections it’s DELETE /v1/vector/collection/delete and one call.
Limitations
Filtering is metadata equality, so there’s no range query, no negation and no nested-field matching — a filter like “documents newer than August” isn’t expressible and has to become a metadata field you set at index time. Deletion by filter isn’t available either.
Qdrant and Weaviate both offer considerably richer filtering, including range and geo conditions, and Pinecone’s namespaces give you per-tenant isolation inside one index without per-collection overhead — a genuinely better fit if strict multi-tenant retrieval at scale is your core problem.
What’s here on one credential is the identity that decides the tenant: GET /v1/auth/session/verify/{session_id} and the user record behind it are the same key as the index and the model, so the boundary is derived from the verified caller rather than from a mapping table you maintain separately. Query and upsert bill per call at rates live in GET /v1/discovery/vector.query (verified 2026-09-21), with collections accruing standing rent while they exist, and platform rates drifting downward as vendor contracts improve.