Short answer: put the tenant boundary in the retrieval query and in the index layout, then treat chunk freshness as a security property rather than a tuning detail. A candidate search result is acceptable only when every returned record belongs to the requesting employer and reflects the latest permitted profile version.
I carry a pager, so I have a low opinion of dashboards that turn green while the wrong customer is seeing the right-looking answer. The failure mode here is quiet: an embedding is valid, the similarity score is high, and the generated explanation sounds grounded. The page fires only after a recruiter notices a name from another tenant.
This is an incident lesson, not a vendor tour. Recruiting search needs semantic matching across resumes, interview notes, and job descriptions, while a media platform may use the same machinery to detect near-duplicate stories. The data differs, but the invariant is identical: retrieval must never cross an ownership boundary, and stale chunks must not survive a profile change.
What does tenant isolation require in recruiting candidate search?
Start with an authorization decision before any vector distance is calculated. The request carries a tenant identifier from an authenticated session, not from a query string supplied by the browser. That identifier is attached to every candidate chunk at ingestion, copied into the vector store metadata, and required as a filter on every search. A post-filter in application code is too late: the model has already seen the forbidden text if the vector store returned it.
Use two independent checks. The first is a storage-level predicate (tenant_id = ?). The second verifies that each result's tenant matches the request context before the result enters a prompt. Fail closed when either value is absent. Logging the rejected request is useful; returning a partial result is not.
No exceptions.
The same rule applies to deletion and updates. A candidate can change employers, consent, or visibility. Keep a monotonically increasing source_version beside each chunk and reject an upsert that would move that version backwards. During a delete, mark the source as withdrawn before removing vectors, so a concurrent search cannot rehydrate an old chunk from a queue retry.
Here is the small piece of Go I keep near the retrieval boundary. It is intentionally boring; the hard part is making it impossible for callers to omit the filter.
package retrieval
import (
"context"
"errors"
)
var ErrTenantMismatch = errors.New("retrieval result crossed tenant boundary")
type Chunk struct {
ID string
TenantID string
SourceID string
SourceVersion int64
Text string
}
type Store interface {
Search(ctx context.Context, embedding []float32, tenantID string, limit int) ([]Chunk, error)
}
func SearchCandidates(ctx context.Context, store Store, tenantID string, embedding []float32, limit int) ([]Chunk, error) {
if tenantID == "" {
return nil, errors.New("missing tenant context")
}
rows, err := store.Search(ctx, embedding, tenantID, limit)
if err != nil {
return nil, err
}
for _, row := range rows {
if row.TenantID != tenantID {
return nil, ErrTenantMismatch
}
}
return rows, nil
}
The second check is not redundant. It catches a misconfigured adapter, an accidental collection-wide query, and a test double that ignores metadata. I want that failure to be loud at the boundary, where the pager can tell me what page fired.
How should chunking and freshness shape the index?
Chunking is where semantic quality and isolation meet. A resume split into arbitrary 1,000-token windows can put a candidate's name in one chunk and the employment restriction in another. The retriever then finds a persuasive fragment without the condition that makes it safe to show. For candidate records, chunk around stable fields: role history, skills, location, and consent scope. Store the source ID and version on every chunk, even when the text is duplicated for context.
Near-duplicate detection in media exposes the same trap. Two articles can share a lead paragraph but differ in a correction or embargo. If the index keeps only the old chunk, a similarity hit can label the corrected story as a duplicate. Freshness therefore needs an explicit policy: a short queue delay for routine edits, immediate tombstoning for withdrawals, and a bounded reconciliation job that compares the source database with indexed versions.
I do not pretend there is one correct freshness window. Your mileage may vary with editorial review time and recruiter workflow. Measure the interval from source commit to searchable version, and separately measure how long withdrawn text remains retrievable. Those are different SLOs.
Freshness is access control.
A practical index record looks like this:
| Field | Purpose | Failure caught |
|---|---|---|
tenant_id |
Mandatory filter key | Cross-employer leakage |
source_id |
Stable candidate or story identity | Duplicate lineage |
source_version |
Monotonic update number | Stale chunks winning a search |
visibility |
Consent, role, or embargo state | Unauthorized context |
chunk_kind |
Skills, history, lead, correction | Incomplete semantic context |
updated_at |
Reconciliation and audit timing | Silent indexing lag |
Do not use a single global freshness score to hide policy differences. A candidate withdrawal is an access event; a typo fix is a quality event. They deserve separate queues and alerts.
The incident drill: prove the boundary before tuning recall
I start a drill with a synthetic pair of tenants, identical resumes, and deliberately overlapping job descriptions. Then I rotate through the operations that usually expose a hole: a normal search, an empty tenant claim, a retry after an update, and a deletion racing a query. The expected result for the empty claim is an error, not zero hits that look harmless.
For every returned chunk, record tenant, source version, and the authorization decision in a trace. Do not log raw resume text. A useful alert is “result version behind source by N minutes” or “tenant predicate missing,” not “average cosine score changed.” Similarity is a diagnostic signal; it is not an access control.
The test matrix should include adversarial text: a resume that literally says another company's name, a media article with a revised headline, and a chunk whose metadata is missing. Property-based tests can generate tenant IDs and ensure that swapping the request context never changes the set of permissible source IDs. Replay a captured incident through the same code path after every index migration.
That replay is the part teams skip.
This architecture has a cost. Per-tenant partitions or namespaces can multiply operational objects, and strict version checks can delay recall while an index catches up. A shared index with metadata filters is simpler to operate, but its blast radius is larger if one adapter forgets a predicate. Choose isolation that matches your threat model, then make the unsafe path mechanically hard to call.
When is this design the wrong fit?
It is not suitable when you need anonymous, cross-tenant discovery or a public corpus where ownership is intentionally absent; a tenant-bound retriever will correctly refuse that use case. It is also a poor fit for workloads that require sub-second visibility after every keystroke unless you can support the indexing and reconciliation budget. In those cases, keep a transactional keyword path for fresh fields and use semantic retrieval as a slower, explicitly labeled supplement.
Stick with a relational query plus full-text index when the candidate set is small, filters are highly structured, or auditability matters more than fuzzy recall. A vector layer earns its place when synonyms and paraphrases dominate, but it should remain downstream of authorization and source-version checks. I am not sure any generic benchmark can predict your leakage risk; replaying your own tenant and deletion cases will resolve that uncertainty faster.
The conclusion I want in the postmortem is precise: no result crossed a tenant boundary, no withdrawn source was returned, and every explanation can point to a current source version. Everything else is optimization.
Top comments (0)