← Back to articles
AI-generated image

Running internal-document Q&A in production: permissions, updates, evaluation and operations

Turn document Q&A (RAG) into a production system many people use. Incremental ingestion, permission filtering, better retrieval, evaluation and operations.

Written by AI · 14 min · For readers of the basic RAG article
Local LLMRAGOperations
What this article covers

In production, the system around the model decides quality more than the model's cleverness. Design four things first: ingestion that keeps documents current, permissions before search, evaluation after every change, and logging with monitoring.

Prototype vs. production

The document Q&A system was built for one person to try. Turn it into an internal system many people use, and problems beyond the model appear. For production, decide these requirements first.

RequirementWhat to decideIf you do not
UsersNumber of people, how many at oncePeople wait at busy times (see memory sizing)
DocumentsVolume, formats (PDF, Office, wiki, etc.), where they liveSome documents cannot be ingested
UpdatesHow often they change, how fast the change must show upAnswers use an outdated rule
PermissionsWho may read which documentContent a person must not see appears in answers
QualityThe bar for “good enough”, and how to measure itYou cannot tell if a change helped
OperationsFailure handling, logs, an ownerNobody can fix it when it stops

This article does not recommend specific products. Product features and settings change, so it focuses on ideas and design points. The code is the smallest form that shows the mechanism.

Overall architecture

Think of a production setup as two paths: the path that answers questions and the path that ingests documents. The search index connects them.

Production architecture for internal-document Q&AINSIDE THE COMPANY NETWORKUsersAuth and API gatewaySSO establishes who is askingQuestion servicesearch → filter → answer → verifyInference serverSearch (filtered by permission)Index storefull-text + vector index + groups allowed to readINGESTION (whenever documents change)DocumentsConvertSplitEmbedupdate only the diffQuestion log, evaluation, monitoringQuestions, sources, answers, feedback; measured regularly
Figure 1 The path that answers questions and the path that ingests documents meet at the search index. Permissions filter on the index data, and logs and evaluation watch the whole.
  • Question path: authentication establishes who is asking; the question service searches, filters by permission, has the model answer, verifies the answer, and returns it
  • Ingestion path: detect changes in the document store, convert, split and embed, then update the index
  • Logs and evaluation: both paths keep records, and an evaluation set measures quality regularly

Ingestion: keeping documents current

The prototype loaded every document at startup. In production, rebuild only the documents that changed.

  • Store a hash of each document's content, and reprocess only what changed
  • When a document disappears, remove it from the index too (otherwise answers use content you deleted)
  • Keep with each document its source (path or URL), update date and the groups allowed to read it
  • Convert PDF and Office files to text first. Tables and figures break easily, so check the result by eye
  • Keep heading structure when splitting. Putting the heading at the start of each chunk makes it easier to find
ingest.py (index tables)
import hashlib
import re
import sqlite3

from rag import split  # the split function from the previous article

db = sqlite3.connect("index.db")
db.executescript("""
CREATE TABLE IF NOT EXISTS docs (path TEXT PRIMARY KEY, hash TEXT, groups TEXT);
CREATE TABLE IF NOT EXISTS chunks (id INTEGER PRIMARY KEY, path TEXT, ord INTEGER, text TEXT);
""")
ingest.py (incremental ingestion)
# Ingest: rebuild only changed documents, and remove vanished ones from the index
def sync(files):  # files: {path: (text, set of groups allowed to read)}
    changed = removed = 0
    for path, (text, groups) in files.items():
        digest = hashlib.sha256(text.encode()).hexdigest()
        row = db.execute("SELECT hash FROM docs WHERE path = ?", (path,)).fetchone()
        if row and row[0] == digest:
            continue  # unchanged
        db.execute("DELETE FROM chunks WHERE path = ?", (path,))
        for order, chunk in enumerate(split(text)):
            db.execute("INSERT INTO chunks (path, ord, text) VALUES (?, ?, ?)", (path, order, chunk))
        db.execute("INSERT OR REPLACE INTO docs VALUES (?, ?, ?)", (path, digest, ",".join(sorted(groups))))
        changed += 1  # recompute this document's chunk embeddings here too
    for (path,) in db.execute("SELECT path FROM docs").fetchall():
        if path not in files:
            db.execute("DELETE FROM chunks WHERE path = ?", (path,))
            db.execute("DELETE FROM docs WHERE path = ?", (path,))
            removed += 1
    db.commit()
    return changed, removed

If you swap the embedding model, the existing vectors become unusable, and you rebuild the vectors for every document. Record which model made the vectors in the index so the swap goes smoothly.

Permissions: filter before searching

Internal documents mix what everyone may read with what only certain departments may read. The key point: do not leave the permission check to the model. Writing “do not show this to this person” in the prompt gives no guarantee the model obeys.

Where to apply permissionsNot good: hide after searchingSearch allevery documentSend to modelall resultsHide answerat outputThe model has already read documents it should notGood: filter before searchingGroup filtercheck permissionSearchvisible docs onlySend to modelfiltered resultsDocuments a person must not see never reach the model
Figure 2 Apply permissions when deciding what to search. Asking the model “do not show this” is not a safeguard.

Filter before the search (or together with it). Documents a person must not see are never candidates in the first place.

ingest.py (filter by permission)
# Permissions: before searching, keep only the chunks this person may see
def visible_chunks(user_groups):
    rows = db.execute("SELECT chunks.id, chunks.path, chunks.text, docs.groups FROM chunks JOIN docs USING (path)").fetchall()
    return [(i, path, text) for i, path, text, groups in rows if set(groups.split(",")) & set(user_groups)]
  • Leave authentication to your existing company system (SSO, etc.) and get the user's groups from it
  • Build a path so that permission changes (leaving, transfers) take effect quickly. If you copy groups into the index, a sync delay becomes a hole
  • If even the existence of a document must stay hidden, base answers like “no source found” on the permission-filtered results

Improving retrieval

Answer quality is mostly decided by retrieval. If the sources handed to the model are wrong, no model can answer correctly.

  • Hybrid search: use both keyword search (strong on proper nouns and part numbers) and vector search (strong on paraphrases)
  • Merging results: the two scores are not comparable, so merging by rank only (RRF) is common
  • Reranking: reorder the top few dozen with a more precise method and keep a handful
  • Metadata filters: narrow the search by department, document type, update date and so on
ingest.py (merge by rank)
# Merging results: use ranks only (RRF). Keyword and vector scores are not comparable, so merge by rank
def rrf(rankings, k=60):
    score = {}
    for ranking in rankings:
        for rank, chunk_id in enumerate(ranking, start=1):
            score[chunk_id] = score.get(chunk_id, 0) + 1 / (k + rank)
    return sorted(score, key=score.get, reverse=True)

Generate the answer, then verify it

The prototype returned the model's answer as it was. In production, check it before returning.

  • Citation check: confirm the source numbers in the answer are numbers of sources you actually passed. With no valid number, treat the answer as unsupported
  • Choose not to answer: when search finds nothing, or only weak matches, return “do not know” without calling the model
  • Show the source: show a link to the original document with the answer so a person can check the original
  • Prepare for prompt injection: do not let instructions inside documents be followed. Be especially wary of documents outsiders can write (inquiries, wiki edits)
ingest.py (citation check)
# Citation check: are the numbers in the answer really numbers of the sources we passed?
def check_citations(answer, found_count):
    cited = {int(n) for n in re.findall(r"\[(\d+)\]", answer)}
    return bool(cited) and cited <= set(range(1, found_count + 1))

Correct citation numbers do not guarantee correct content. The number check is the minimum; checking content is the job of the evaluation below.

Evaluation: measure after every change

Changing the chunk size, the search method, the model or the prompt can raise or lower quality. Do not judge by feel; measure with the same set of questions and compare.

  1. Collect 50–100 real questions (ask people on the job, or use inquiry history)
  2. Attach to each the part of the document that is the correct basis
  3. After every change, measure retrieval (the share of questions with the correct part in the top k)
  4. Check answers by sampling with people. You can have a model grade answers, but the model's grading is also wrong at times, so combine it with human checks
ingest.py (retrieval evaluation)
# Evaluation: the share of questions whose correct chunk is in the top k
def recall_at_k(cases, search, k=5):  # cases: [(question, set of correct chunk ids)]
    hits = sum(1 for question, expected in cases if expected & set(search(question)[:k]))
    return hits / len(cases)
  • Include “questions the documents do not cover” in the set, to measure whether it correctly says “do not know”
  • Grow the set from real use. Add questions from the “was not helpful” records
  • Keep the ability to roll back the last change when the numbers drop

Operations: keeping it running

ItemWhat to watchResponse
Response timeSearch and model time, queue lengthIf concurrent use grows, revisit hardware or model
Share of “do not know”A sudden rise means a document or search problemCheck for failed ingestion or missing documents
Feedback“Helpful / not helpful”Add the unhelpful questions to the evaluation set
IngestionFailed documents, time of last updateNotify on failure. Do not keep serving a stale index
LogsQuestions, sources found, answersDecide retention and who can read them
  • Handling logs: questions and answers contain confidential or personal data. Protect logs as strongly as the documents
  • Failures: if the model stops, still returning the search results (a list of related documents) keeps the service from going completely dark
  • Updates: when changing the model, embedding model or search settings, measure on the evaluation set before switching
  • Backups: the index can be rebuilt from documents, but it takes time. Know how long a rebuild takes

Checklist

  • Have you decided the number of users, document volume, update frequency and unit of permission?
  • Do document changes and deletions reach the index within the time you decided?
  • Do you filter by permission before searching, rather than relying on the prompt?
  • Have you tested that documents a person must not see never appear in answers?
  • Is there an evaluation set that you run after every change, including questions the documents do not cover?
  • Do you verify citations and show source links?
  • Have you decided log retention and who can read the logs?
  • Have you decided what happens when the model or search stops?
  • Have you decided who runs it and who fixes problems?

Official references

Ollama — Documentation ↗vLLM — Documentation ↗Claude Docs — Reduce hallucinations ↗
PAIBOT / DEVELOPMENT JOURNALBack to articles →

Find what you need

Searches titles, tags and body text esc to close