Game studios rarely have one clean candidate corpus. A recruiting search may span a studio's employees, an agency feed, and a confidential executive pipeline, while a single recruiter expects results in a few hundred milliseconds. Mixing those datasets and trying to repair the boundary after ranking is a security incident waiting to happen.
Short answer: use staged retrieval with a tenant-scoped collection, an access predicate applied before vector similarity, and source identifiers carried through reranking; measure relevance and p95 latency separately, because a fast cross-tenant answer is still a failed answer.
The constraint is isolation before relevance
Treat tenant_id as part of the retrieval contract, not as a UI filter. In a game hiring system, a tenant can be a studio, and a sub-tenant can be a department or an agency relationship. The authorization service should produce an immutable scope such as (studio_42, recruiting_eu, recruiter_17) before the search service touches an index. The query layer then intersects that scope with document-level attributes: role, region, consent state, and the timestamp at which the profile was last approved.
There are three useful gates:
- Collection gate: route the request to a collection dedicated to the studio or to a partition whose policy is mechanically enforced.
- Metadata gate: require every candidate chunk to carry tenant, source, and access attributes; reject records with missing metadata during ingestion.
- Result gate: re-check authorization on the returned identifiers before a reranker or an LLM sees the text.
The third gate looks repetitive. It is. Indexes get rebuilt, caches get warmed with old policies, and a race between a recruiter being removed and a search response being assembled is entirely plausible.
I once assumed a namespace key was enough. It was not: a bulk import copied a profile's embedding without its studio_id, so the nearest-neighbor stage could not prove ownership. The incident was subtle because the source record still had a valid agency identifier, and relevance scores for the affected query improved, which made the dashboard look healthy. During review we traced one result through the embedding job, the cache key, and the reranker payload; the tenant claim disappeared between the second and third step. We stopped the import, quarantined every item with incomplete metadata, rebuilt the affected partition, and added a test that fails when an embedding cannot be joined to an authorized source row. The safe response is to quarantine that item, not to guess from the source filename. A missing claim is an access denial.
Ship slowly.
How should recruiting candidate search enforce tenant isolation?
Start by writing the answer that a recruiter is allowed to see, then derive the retrieval fields from it. A result should include a stable candidate identifier, the evidence snippets used for scoring, the source document identifier, and a policy version. Do not return raw vector rows and hope the caller remembers to filter them.
The following Python sketch keeps the boundary explicit. The vector service is intentionally represented by a small interface so the same contract can sit over a self-hosted index or a hosted one.
from dataclasses import dataclass
from typing import Iterable
@dataclass(frozen=True)
class Scope:
studio_id: str
recruiter_id: str
policy_version: int
@dataclass(frozen=True)
class CandidateHit:
candidate_id: str
score: float
source_id: str
tenant_id: str
policy_version: int
def retrieve(query: str, scope: Scope, vector_index) -> list[CandidateHit]:
metadata_filter = {
"studio_id": scope.studio_id,
"consent": "active",
"policy_version": {"$lte": scope.policy_version},
}
hits: Iterable[CandidateHit] = vector_index.query(
text=query,
limit=40,
filter=metadata_filter,
)
allowed = [
hit for hit in hits
if hit.tenant_id == scope.studio_id
and hit.policy_version <= scope.policy_version
]
return allowed[:20]
The filter is not a substitute for authorization. It is a way to reduce the candidate set and latency before the final check. The final check should use the same policy service that protects profile pages, and it should log the policy version alongside the result. That makes a later audit answerable: which rule admitted this profile, which source supported the ranking, which recruiter identity was in scope, and whether the policy changed while the request was running? Keep those fields in an append-only event record with a correlation ID, because reconstructing a decision from application logs alone is slow and often impossible after a retention window expires.
Partitioning choices and their failure modes
Per-tenant collections give the clearest blast-radius story. Deleting a studio is a collection operation, and an accidental query against another studio's collection can be denied at the client boundary. The cost is operational fan-out: hundreds of small collections complicate migrations, index tuning, and capacity planning.
A shared collection with a mandatory studio_id predicate is easier to operate and can improve hardware utilization. Its failure mode is predicate omission, especially in maintenance jobs and fallback code. Make the predicate part of the index client's type-level request, and reject an empty scope; a default of “all studios” is indefensible.
Physical database row-level security can add a second enforcement layer for metadata and source records. It does not automatically protect an external vector index, nor does it tell a reranker which text it may process. Keep the policy decision close to every data plane that handles profile content.
| Choice | Isolation strength | Latency shape | Operational cost | Poor fit when |
|---|---|---|---|---|
| Per-studio collection | High blast-radius control | Predictable for large studios | Many indexes and migrations | You have thousands of tiny tenants |
| Shared collection + filter | Depends on invariant enforcement | Good cache locality | Simpler capacity management | Teams cannot guarantee filter propagation |
| Separate index per trust zone | Strong boundary between sensitive pools | Extra network hop possible | Duplicate pipelines | Profiles must be searched across zones |
No option wins universally.
For a small number of large studios, separate collections are usually worth the isolation. For a marketplace with many agencies, a shared index plus a non-optional scope object may be more practical, provided the invariant is tested at every call site and the index client refuses requests whose scope object is absent or malformed.
Reranking without leaking evidence
Reranking improves relevance, but it expands the amount of candidate text handled by a model or a second service. Pass only the authorized top-N snippets, not the whole profile history. Keep source IDs attached to each snippet so the UI can show why a candidate matched “Unreal Engine networking” without exposing an unrelated note.
Latency budgets should be explicit. For example, reserve 80 ms for authorization, 120 ms for first-stage vector search, and 150 ms for reranking in a 400 ms p95 target. Those numbers are a starting hypothesis, not a benchmark; your mileage may vary until production traces confirm them. A timeout must return a bounded, clearly labeled partial result or no result, never an unfiltered fallback.
Record four measurements per request: tenant scope resolution time, retrieval p50/p95, reranker p50/p95, and the count of candidates removed by policy. A sudden drop in that last count can indicate an ingestion regression even when relevance scores look healthy.
Rollout, tests, and the catch
Build a labeled evaluation set with candidates from several studios, including near-duplicate resumes and revoked profiles. Assert that a query from studio A can never return a source ID owned by studio B. Run the assertion in unit tests, integration tests, and a scheduled canary that uses synthetic profiles. Also test policy changes during an in-flight query; the expected result is denial at the final gate when the policy version is stale.
Roll out in shadow mode first: compute the new scoped result beside the existing result, compare relevance labels, and inspect latency histograms. Then enable the result gate, followed by the metadata gate for newly indexed records. Keep a kill switch that disables reranking while preserving authorization.
The catch is that tenant isolation can reduce recall. A recruiter searching a shared talent pool may legitimately need cross-studio visibility, but that is a separate entitlement with an auditable scope, not a reason to weaken the default. Stick with a shared collection when operational simplicity matters and you can enforce a non-empty predicate everywhere; choose per-tenant or trust-zone indexes when legal boundaries and deletion guarantees dominate.
Top comments (0)