DocsMind
Answers drawn only from your own documents, with citations and no invented sources.
Built from scratch to demonstrate chat with your company docs

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.
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.
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.
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.
- A workspace uploads a document. It is stored, marked as processing, and a queued job is dispatched.
- The job extracts the text and splits it into roughly 500 token chunks with overlap.
- Each chunk is embedded with Gemini text-embedding-004 and written with the owning tenant id.
- A user asks a question, which is embedded with the same model.
- Cosine similarity matches it against that workspace's chunks, scoped by all three isolation mechanisms and limited to documents that finished processing.
- Chunks scoring below 0.45 are filtered out, and the top 6 survive.
- If nothing survives, the model is never called. The user is told plainly that the answer is not in their documents.
- 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.
- If the generation model is rate limited or unavailable, the top passages are quoted directly with their document titles rather than failing the request.
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.


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