REJCOB
← All work

Multi-tenant RAG with hybrid search and an eval harness

A retrieval stack where a tenant leak is impossible by construction and a quality regression fails the build.

Role
Architecture & design
Period
2026
Company
Personal project

RLS

every query scoped to one tenant by a Postgres policy

Hybrid

full-text + vector results fused, then reranked

CI-gated

golden question set scored on every change

#Context

Most SaaS products that add a 'chat with your docs' feature face the same two risks: one customer's content surfacing in another customer's answers, and retrieval quality silently degrading when someone tweaks chunking or prompts. This design treats both as engineering problems with hard guarantees instead of things to check by hand.

#Problem & constraints

  • Tenant filtering done in application code is one forgotten WHERE clause away from a data leak.
  • Pure vector search misses exact terms — product codes, error strings, names — that keyword search finds trivially.
  • Without a repeatable evaluation, every change to chunking, embeddings or prompts is a guess.

#Architecture

Questiontenant set per requestFull-texttsvectorVectorpgvectorPostgreSQLRLS per tenantFusion + rerankRRFLLM answerwith citationsEval harnessrecall@k · CI gate
Two retrievers over one tenant-scoped Postgres table, fused and reranked; an eval harness gates changes in CI.

Documents are parsed, chunked by heading structure and stored in a single PostgreSQL table with a pgvector embedding column and a tsvector column. Every row carries a tenant ID, and a Row Level Security policy restricts all reads and writes to the tenant set on the current database session — the API sets it once per request, so no query can reach another tenant's rows. At query time the question runs through two retrievers in parallel: full-text search and vector similarity. Their rankings are merged with reciprocal rank fusion and the top candidates go through a reranker before the LLM composes an answer with citations back to the source chunks. A separate eval harness holds a golden set of questions with expected source passages; it reports recall@k and answer faithfulness, and runs in CI so a change that lowers either score fails the pipeline.

#Key decisions & tradeoffs

01Row Level Security over a collection per tenant

Decision
Keep all tenants in one table and let a Postgres policy enforce isolation on every statement.
Alternatives
A separate vector collection or database per tenant, or tenant filters written by hand in each query.
Why
Isolation becomes a property of the database rather than of developer discipline, onboarding a tenant is an insert instead of provisioning, and relational data and vectors share one transaction.
Cost of being wrong
One large index serves every tenant, so a very large tenant affects the others; past a certain size that tenant needs partitioning or its own instance.

02Hybrid search with rank fusion

Decision
Run full-text and vector retrieval side by side, merge with reciprocal rank fusion, then rerank the shortlist.
Alternatives
Vector search alone, or generating several rewrites of the question and retrieving for each.
Why
Keyword and vector search fail on different questions, so combining them covers both without an extra LLM call per query. Rank fusion needs no score calibration between the two.
Cost of being wrong
Two indexes to maintain, and the reranker adds latency to every request.

03Evaluation as a CI gate

Decision
Version a golden question set next to the code and fail the build when recall@k or faithfulness drops below a threshold.
Alternatives
Manual spot checks in a chat window after each change.
Why
Retrieval changes interact in ways nobody predicts; a number that moves on every commit turns tuning from opinion into measurement.
Cost of being wrong
The golden set has to be written and kept current by hand, and LLM-judged faithfulness costs tokens on every run.

#Results

0

cross-tenant rows reachable — blocked by policy, by design

1 DB

relational data, full-text and vectors in a single Postgres

recall@k

and faithfulness tracked per commit

#What I'd do differently

The part I would pressure-test first is the shared index: RLS filters after the vector index returns candidates, so a small tenant inside a large table can get fewer results than requested. The next step is measuring that on a skewed dataset and comparing partial indexes per tenant against partitioning.

#Stack

PostgreSQLpgvectorRow Level SecurityOpenAINode.jsTypeScriptRerankerRAG