Retrieval Architecture for Legal Contract Review Workspaces Explained (Tenant Isolation)
Short answer: enforce tenant authorization before candidate generation, carry the authorized tenant identity into every index lookup and cache key, and choose a shared, partitioned, or dedicated index only after measurin
Short answer: enforce tenant authorization before candidate generation, carry the authorized tenant identity into every index lookup and cache key, and choose a shared, partitioned, or dedicated index only after measuring corpus skew and rebuild cost. For an edtech company's contract-review workspace, that means a near-duplicate clause from another customer must never become a candidate, even if its embedding is the closest match.
This ordering matters more than the vector database brand. Retrieval-augmented generation joins a retriever to a generator, so the retriever defines the material the model is allowed to see. A prompt that says βuse only this tenant's contractsβ is not an access-control boundary. It is text sent after retrieval.
I've learned the same lesson from OTP delivery: an ignored HTTP 429 can look like a messaging problem several steps later. In retrieval, a missing authorization filter can look like a relevance problem until a foreign clause appears in a reviewerβs evidence panel. Diagnose the earliest enforceable boundary β not the last visible symptom.
How should tenant isolation shape retrieval architecture for a legal contract review workspace?
Start with one invariant: no retrieval operation may search outside the principal's authorized tenant set. The application authenticates the user, resolves membership and role from server-side state, and creates an authorization context. The client doesn't get to select an arbitrary tenant_id in a JSON body. Every later operation receives the resolved context, including lexical search, vector search, reranking, document hydration, cache reads, exports, and audit events.
That sounds strict because it is. Legal documents contain party names, negotiated rates, renewal dates, indemnity language, and signatures. Encryption protects storage and transport, but it does not stop an authenticated query from reading the wrong logical partition. Tenant isolation is an authorization property.
For semantic near-duplicate detection, filter before similarity scoring whenever the engine can do so. A global top-k search followed by application-side tenant filtering is unsafe and inaccurate: it admits unauthorized candidates into the retrieval stage, and the permitted results left after filtering may not be the tenant's true top k. The safe query asks for nearest neighbors inside the authorized tenant partition. Exact duplicate hashes can run through the same boundary, but they don't replace semantic search; a punctuation change or reordered clause can preserve meaning while changing the hash.
Keep the scope narrow. If a reviewer is assigned only to acquisition contracts in one workspace, tenant membership alone may still be too broad. The retrieval predicate can include matter, document status, or an access-control-list version, provided those attributes come from trusted policy state rather than free-form client input. The response should retain document and clause identifiers so the UI can show provenance instead of presenting generated prose as an unsupported answer.
Make authorization part of the query contract
An interface is useful here because it makes the boundary reviewable. The caller supplies a principal and query; the service resolves scope, and only then can it invoke the index. The index adapter accepts an authorization scope, not a nullable filter assembled by a controller.
from dataclasses import dataclass
from typing import Protocol, Sequence
@dataclass(frozen=True)
class SearchRequest:
principal_id: str
workspace_id: str
text: str
limit: int = 10
@dataclass(frozen=True)
class AuthorizedScope:
tenant_id: str
workspace_id: str
policy_version: str
class PolicyEngine(Protocol):
def authorize_search(self, request: SearchRequest) -> AuthorizedScope: ...
class VectorIndex(Protocol):
def search(
self,
*,
query_text: str,
scope: AuthorizedScope,
limit: int,
) -> Sequence[dict]: ...
def find_near_duplicates(
request: SearchRequest,
policy: PolicyEngine,
index: VectorIndex,
) -> Sequence[dict]:
if not 1 <= request.limit <= 50:
raise ValueError("limit must be between 1 and 50")
scope = policy.authorize_search(request)
return index.search(
query_text=request.text,
scope=scope,
limit=request.limit,
)
Notice what is absent: there is no fallback to an unfiltered search, and there is no user-provided tenant identifier passed through as truth. Denial should stop the request before embedding or retrieval. That's useful for security and for cost: rejected work doesn't consume embedding, search, reranking, or generation capacity.
The same scope belongs in cache identity. A cache key based only on normalized query text can return one tenant's result to another tenant that submits the same clause. Include the tenant, workspace, policy version, retrieval configuration, and corpus version. Short expirations don't repair a missing boundary. They only shorten the exposure window.
Observability needs restraint. Record the principal, authorized scope, policy decision, corpus version, candidate count, latency, and a trace identifier, but don't put raw contract clauses or embeddings into routine logs. Compliance teams need enough evidence to reconstruct a decision without creating a second, loosely governed document store. I've seen delivery investigations become harder when identifiers disappear between rate-limit handling and provider callbacks; retrieval traces have the same correlation problem, so carry one opaque trace ID across authorization, search, reranking, and hydration.
Fail closed.
What index topology keeps tenant isolation affordable at scale?
The primary cost question is not βwhich engine has the lowest unit price?β It is how much searchable state the chosen isolation boundary duplicates. Estimate vector payload, graph or other index overhead, metadata, replicas, snapshots, and temporary headroom for rebuilds. Then apply the estimate to the actual tenant-size distribution. Average contracts per tenant can hide a few large customers that dominate memory and rebuild time.
A shared index stores every clause once and attaches trusted tenant metadata. It usually offers the best packing efficiency for many small tenants, but the query path must make the tenant predicate mandatory. A dedicated index per tenant creates a clearer physical boundary and independent lifecycle controls, yet thousands of tiny indexes can add fixed metadata, shard, connection, and operational overhead. A partitioned or hybrid design places most tenants in shared infrastructure while moving unusually large or regulated tenants into dedicated partitions. That adds routing and migration work, but it prevents one topology from being forced onto every customer.
Use a worksheet rather than a slogan:
from dataclasses import dataclass
@dataclass(frozen=True)
class IndexCapacity:
vector_bytes: int
index_overhead_bytes: int
metadata_bytes: int
replica_count: int
rebuild_headroom_ratio: float
def planned_bytes(self) -> int:
primary = (
self.vector_bytes
+ self.index_overhead_bytes
+ self.metadata_bytes
)
replicated = primary * self.replica_count
return int(replicated * (1 + self.rebuild_headroom_ratio))
Populate those fields from a representative build, not a guessed universal ratio. I'm not sure which topology will be cheapest for a given corpus without the tenant histogram, embedding dimensions, update rate, retention rules, and measured index overhead. Those inputs resolve the uncertainty. Also model re-embedding: a model change may require old and new representations to coexist while quality and access-control checks run.
Cost pressure can tempt teams to pool everything and filter after retrieval. Don't. A cheaper index that weakens the security invariant is the wrong design; reduce dimensions after evaluation, deduplicate immutable clause content within an authorized boundary, adjust replicas to recovery requirements, or tier inactive corpora instead.
Compare implementations only after fixing the invariant
The table below is an implementation comparison, not a ranking. All three choices can participate in a sound design, and none removes the need to test the complete authorization path.
| Implementation | Natural isolation mechanism | Operational trade-off to verify |
|---|---|---|
| PostgreSQL with pgvector | Database rows can carry tenant keys, while PostgreSQL row security policies restrict which rows a command can access | Row-level security policy ownership and bypass behavior need careful role design; vector index performance must be measured on the real filtered distribution |
| Elasticsearch | Document-level security can restrict role access to documents matching a query | Security configuration and filtered vector-query behavior must be validated for the deployed version and license |
| OpenSearch | Fine-grained access control includes index-, document-, and field-level controls | Tenant, role, and document rules add policy administration; search recall and latency still need corpus-specific testing |
Stick with a relational design when contract metadata, transactions, and policy joins dominate and the measured vector workload fits its operating envelope. A search engine is a better candidate when combined lexical and vector retrieval, search-specific operations, and independent scaling justify another system. A dedicated index is not suitable when tiny tenants make fixed operational overhead dominate; a shared index is not suitable when policy or contractual requirements demand a separate physical boundary. The catch is that a hybrid topology needs a trustworthy routing catalog and a tested tenant-migration procedure.
Thresholds deserve the same skepticism. One cosine-similarity cutoff is unlikely to represent every clause type equally: boilerplate notices, addresses, and negotiated liability language have different consequences when flagged as duplicates. Build a labeled set of positive and hard-negative pairs from authorized test data, report precision and recall by clause category and tenant-size band, and have legal reviewers define the cost of misses versus false alarms. Your mileage may vary across embedding models and document extraction pipelines.
Roll out with evidence, denial tests, and reversible moves
Begin with a shadow index populated from synthetic or explicitly approved test contracts. Test attempts to cross tenant, workspace, matter, role, and policy-version boundaries; assert both zero returned documents and an auditable denial. Add cache tests using identical query text under different tenants. Then exercise deletion, retention expiry, key rotation, index rebuild, embedding migration, and backup restoration. A retrieval path isn't ready merely because its happy-path relevance looks good.
For production rollout, route a small authorized cohort to the new retrieval path, compare quality and latency without exposing cross-scope candidates, and keep the previous authorized path available for rollback. Migration units should be tenant or workspace scoped. After each move, reconcile source document counts, indexed clause counts, corpus versions, and access-policy versions before switching reads.
The decision rule is compact: shared indexes for efficient packing when mandatory server-side filters are enforceable and tested; dedicated indexes when physical separation is required or a tenant's scale warrants independent operations; hybrid routing when the corpus is materially skewed. In every case, authorization precedes retrieval, provenance survives generation, and index cost includes the rebuild you will eventually need.
References
- https://arxiv.org/abs/2005.11401
- https://www.postgresql.org/docs/current/ddl-rowsecurity.html
- https://github.com/pgvector/pgvector
- https://www.elastic.co/guide/en/elasticsearch/reference/current/field-and-document-access-control.html
- https://docs.opensearch.org/latest/security/access-control/index/
- https://www.rfc-editor.org/rfc/rfc6585.html#section-4
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.