DocsMind

Answers drawn only from your own documents, with citations and no invented sources.

Built from scratch to demonstrate chat with your company docs

LaravelRAGGemini AIMulti-tenancyNext.js
DocsMind interface
The problem

What this solves

A general purpose chatbot cannot answer questions about your internal documents, because it has never seen them. Point it at a public model and it will answer anyway, confidently and wrongly.

The fix is retrieval: find the relevant passages first, then let the model answer using only those. That is straightforward for one customer and genuinely difficult for many, because now every retrieval is a chance to leak one company's documents into another company's answer.

I built DocsMind to demonstrate multi-tenant retrieval where isolation is a structural property rather than a filter somebody remembered to add.

Approach

How I approached it

  • Documents are uploaded per workspace, marked as processing, and handled on a database-backed queue rather than in the request. Parsing a large file is slow and can fail, and neither should hold up an HTTP response.
  • A queued job extracts the text, splits it into 500 token chunks with 75 tokens of overlap, embeds each chunk with Gemini text-embedding-004, and stores the vectors with the owning tenant id.
  • Retrieval is cosine similarity against that stored set, taking the top 6 chunks. Skipping an external vector service keeps the tenant boundary inside a database I already control, which is the entire point of the design.
  • Anything scoring below 0.45 similarity is dropped before the model is ever called, so weak matches cannot be dressed up as an answer.
  • The surviving passages go to gemini-2.0-flash under instructions to answer only from the numbered sources, never from general knowledge or earlier turns, and to cite inline. The response carries citations so a reader can check it against the source.
The hard part

Keeping one tenant's data out of another tenant's answers.

Why the obvious solution fails

The obvious approach is a where clause on tenant id in the retrieval query. It works right up until it does not. A single query written without the filter, a background job running outside the request and therefore outside whatever request-scoped context supplied the tenant, or a second retrieval path added later, and the leak is silent. Nothing errors. The wrong company's document simply appears in an answer, and a filter you have to remember to write is a filter that will eventually be forgotten.

What I did instead

  • Isolation is enforced in three separate mechanisms rather than trusting one query filter.
  • Model-level scoping: every tenant-bound model carries a tenant_id and a global scope that both filters reads and stamps writes, so the constraint applies whether or not any given query remembers it.
  • Authentication-first resolution: the tenant is resolved from the authenticated user before route-model binding runs, so bound lookups are scoped too, and an id guessed from another workspace does not resolve.
  • Explicit controller checks on top of both.
  • A single forgotten filter is therefore not enough to cause a leak. All three have to fail at once, and each is enforced at a different point in the request.
Architecture

How the data flows

A Laravel 11 API on MySQL handles upload, queued processing, embedding, retrieval, and chat, with Sanctum token auth and a database-backed queue. Vectors live in the application database, scoped by tenant. A Next.js frontend consumes it.

  1. A workspace uploads a document. It is stored, marked as processing, and a queued job is dispatched.
  2. The job extracts the text and splits it into roughly 500 token chunks with overlap.
  3. Each chunk is embedded with Gemini text-embedding-004 and written with the owning tenant id.
  4. A user asks a question, which is embedded with the same model.
  5. Cosine similarity matches it against that workspace's chunks, scoped by all three isolation mechanisms and limited to documents that finished processing.
  6. Chunks scoring below 0.45 are filtered out, and the top 6 survive.
  7. If nothing survives, the model is never called. The user is told plainly that the answer is not in their documents.
  8. Otherwise the passages go to gemini-2.0-flash under instructions to answer only from the numbered sources, and the answer comes back with inline citations.
  9. If the generation model is rate limited or unavailable, the top passages are quoted directly with their document titles rather than failing the request.
At production scale

What I'd do differently

  • Cosine similarity computed in the application does not stay fast as the corpus grows. Past a certain point this needs a real vector index, either pgvector with an approximate index or a dedicated service. Moving to a service would mean reproducing the isolation guarantees outside my own database, which is a deliberate tradeoff rather than a free upgrade.
  • Embedding cost scales with document volume and with re-processing. Content-hashing chunks so unchanged text is never re-embedded on re-upload would matter well before scale becomes a problem.
  • Fixed size chunking is a blunt instrument. A boundary through the middle of a table or a clause degrades retrieval in ways that are hard to notice, so long structured documents would want splitting that respects their structure.
Screens
DocsMind screen 2
DocsMind screen 3

Want something like this built?

I build payment systems and AI features inside Laravel apps. Tell me what you need and I will tell you how I would approach it.

Get in touch