<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: ALI KHUSROO BIN SABIR</title>
    <description>The latest articles on DEV Community by ALI KHUSROO BIN SABIR (@cyberalieng).</description>
    <link>https://dev.to/cyberalieng</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4245071%2F27660152-6408-4c2d-ae86-98ed7085e859.png</url>
      <title>DEV Community: ALI KHUSROO BIN SABIR</title>
      <link>https://dev.to/cyberalieng</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/cyberalieng"/>
    <language>en</language>
    <item>
      <title>Multi-tenant RAG: stop one customer's documents leaking into another's answers</title>
      <dc:creator>ALI KHUSROO BIN SABIR</dc:creator>
      <pubDate>Sat, 14 Nov 2026 21:01:35 +0000</pubDate>
      <link>https://dev.to/cyberalieng/multi-tenant-rag-stop-one-customers-documents-leaking-into-anothers-answers-4i3h</link>
      <guid>https://dev.to/cyberalieng/multi-tenant-rag-stop-one-customers-documents-leaking-into-anothers-answers-4i3h</guid>
      <description>&lt;p&gt;Retrieval-augmented generation (RAG) is the standard way to let people "chat with their documents": split the documents into chunks, embed them, store them in a vector database, and at question time retrieve the closest chunks and hand them to an LLM as context.&lt;/p&gt;

&lt;p&gt;Nearly every tutorial stops there. That's fine for one person's notes. It's a problem the moment two customers share the same deployment, because &lt;strong&gt;the vector search has no idea whose documents it is searching&lt;/strong&gt;. Ask "what's our pricing?" and the most similar chunk might belong to someone else.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://github.com/CyberAli-eng/enterprise-rag-engine" rel="noopener noreferrer"&gt;Enterprise RAG Engine&lt;/a&gt;, an open-source FastAPI + LangChain service, to show what that boundary looks like in code. Here's how it works, a demo, and the bug I found in my own isolation logic while writing this post.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea: tag on write, filter on read
&lt;/h2&gt;

&lt;p&gt;Isolation has to happen &lt;strong&gt;in the retrieval step&lt;/strong&gt;, not after it. If you retrieve across everyone and filter afterwards, you've already handed other tenants' text to your process, and one forgotten filter later it's in an answer.&lt;/p&gt;

&lt;p&gt;So every chunk is stamped with its tenant when it's stored:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;add_documents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;documents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;store&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_vector_store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"tenant_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="s2"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_documents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;documents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And every search is restricted to one tenant by the vector store itself, so other tenants' chunks never come back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;similarity_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;store&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_vector_store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;filter_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"tenant_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="s2"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;similarity_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;filter_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Chroma and Pinecone both support metadata filters, so this works the same on either backend.&lt;/p&gt;

&lt;h2&gt;
  
  
  Grounded answers, with receipts
&lt;/h2&gt;

&lt;p&gt;Isolation keeps the wrong documents out. The prompt keeps the model honest about the right ones:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You are an enterprise knowledge assistant. Answer the user's question using
ONLY the provided context. If the context does not contain the answer, say you
don't have enough information — never invent facts. Be concise and cite which
source(s) you drew from by their number, e.g. [1], [2].
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each response returns the source snippets next to the answer, so a user (or an auditor) can check the claim instead of trusting it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The demo
&lt;/h2&gt;

&lt;p&gt;Two companies upload one document each: Acme a leave policy, Globex a pricing guide. Then both ask the same question: &lt;em&gt;"How many days of paid annual leave do I get?"&lt;/em&gt; These are the real responses from the engine running locally on Llama 3.2:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Acme&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;According to [1], full-time employees receive 24 days of paid annual leave per calendar year.&lt;/p&gt;

&lt;p&gt;sources: &lt;code&gt;acme-leave-policy.md&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Globex&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I don't have enough information to answer your question. The provided context only discusses pricing plans for Globex Ltd's services, but does not mention anything about paid annual leave or employee benefits. [1]&lt;/p&gt;

&lt;p&gt;sources: &lt;code&gt;globex-pricing.md&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Globex never sees Acme's policy. It searches only its own pricing guide, finds nothing relevant, and says so rather than guessing.&lt;/p&gt;

&lt;p&gt;▶ &lt;a href="https://cyberali-eng.github.io/enterprise-rag-engine/#demo" rel="noopener noreferrer"&gt;Watch the 2-minute demo&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug I found in my own code
&lt;/h2&gt;

&lt;p&gt;While writing this post, I wrote a test for the obvious edge case: what happens when a request &lt;strong&gt;doesn't send a tenant at all&lt;/strong&gt;?&lt;/p&gt;

&lt;p&gt;The search function originally looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;filter_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"tenant_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No tenant meant &lt;strong&gt;no filter&lt;/strong&gt;, which meant searching every tenant's documents. Ingest already treated a missing tenant as &lt;code&gt;"default"&lt;/code&gt;; search didn't. The test failed and returned Acme's, Globex's and the default documents together.&lt;/p&gt;

&lt;p&gt;The fix is the one-liner you saw above, &lt;code&gt;tenant_id or "default"&lt;/code&gt;, plus a test so it can't come back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_missing_tenant_only_sees_the_default_tenant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;docs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;vector_store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;similarity_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"leave days"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"default.md"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lesson generalises: &lt;strong&gt;a missing scope should fall back to the narrowest scope, never the widest.&lt;/strong&gt; Optional parameters are where that breaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rest of the guard rails
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;API-key auth&lt;/strong&gt; on every request (&lt;code&gt;X-API-Key&lt;/code&gt;), with keys read only from environment variables.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limiting&lt;/strong&gt; per key (an in-memory sliding window; swap in Redis when you run more than one instance).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keys bound to tenants:&lt;/strong&gt; &lt;code&gt;API_KEY_TENANTS&lt;/code&gt; maps each API key to the tenants it may use. Asking for any other tenant returns 403 before a search runs, and a key bound to one tenant doesn't need to send &lt;code&gt;tenant_id&lt;/code&gt; at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upload validation:&lt;/strong&gt; only &lt;code&gt;.txt&lt;/code&gt; and &lt;code&gt;.md&lt;/code&gt;, 5 MB maximum, valid UTF-8.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No secrets in git&lt;/strong&gt;, enforced by secret scanning in CI.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What it doesn't do yet
&lt;/h2&gt;

&lt;p&gt;Honesty matters more than a feature list, so:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No per-user auth.&lt;/strong&gt; Keys can be bound to tenants, but there's no JWT layer for individual end users yet; that's on the roadmap.&lt;/li&gt;
&lt;li&gt;The rate limiter is single-instance.&lt;/li&gt;
&lt;li&gt;Ingestion is text and Markdown only for now.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Run it yourself, free
&lt;/h2&gt;

&lt;p&gt;No paid LLM API key needed. It runs a local model through Ollama:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/CyberAli-eng/enterprise-rag-engine.git
&lt;span class="nb"&gt;cd&lt;/span&gt; enterprise-rag-engine
cp .env.example .env
&lt;span class="c"&gt;# in .env: API_KEYS=&amp;lt;your secret&amp;gt;, LLM_PROVIDER=ollama,&lt;/span&gt;
&lt;span class="c"&gt;#          LLM_MODEL=llama3.2:3b, OLLAMA_BASE_URL=http://ollama:11434/v1&lt;/span&gt;
docker compose --profile &lt;span class="kd"&gt;local&lt;/span&gt; up --build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prefer a hosted model? Set &lt;code&gt;LLM_PROVIDER&lt;/code&gt; to &lt;code&gt;openai&lt;/code&gt;, &lt;code&gt;groq&lt;/code&gt; or &lt;code&gt;together&lt;/code&gt; instead. Chroma or Pinecone is one more variable.&lt;/p&gt;

&lt;p&gt;The code is MIT-licensed: &lt;strong&gt;&lt;a href="https://github.com/CyberAli-eng/enterprise-rag-engine" rel="noopener noreferrer"&gt;github.com/CyberAli-eng/enterprise-rag-engine&lt;/a&gt;&lt;/strong&gt;. Issues and PRs are welcome, especially for the Redis rate limiter and PDF ingestion.&lt;/p&gt;

</description>
      <category>rag</category>
      <category>python</category>
      <category>security</category>
      <category>llm</category>
    </item>
  </channel>
</rss>
