When I first built an internal document search tool with Claude API, I did what most people do: threw everything into a vector database and called it done.
Then during the demo, our HR lead asked: "Wait — can everyone see the compensation data?"
That was the moment I realized a RAG system without proper access control isn't a product, it's a liability.
This article walks through the system I rebuilt over the following three weeks — a production-grade internal document search agent with department-level access control, hybrid search, and full audit logging. I'll share the actual implementation decisions, including the ones that required painful rewrites.
System Architecture Overview
Here's the final system structure:
- Ingest layer: PDF / Markdown / Notion exports → text extraction → chunking → embedding generation → stored in pgvector
- Search layer: Hybrid search (pgvector cosine distance + PostgreSQL full-text BM25) → merged via Reciprocal Rank Fusion → RBAC filtering
- Answer layer: Filtered chunks → Claude API (claude-sonnet-4-6) → answer with source citations
- Audit layer: All queries, answers, and referenced documents → written to audit table
The most important architectural decision: implement RBAC at the database layer using PostgreSQL Row Level Security, not in application code. Application-layer filtering creates a failure mode where any bug can become a data leak. With RLS, it's structurally impossible — the database simply won't return rows the user isn't allowed to see, regardless of what the application code does.
Document Ingest Pipeline
import anthropic
import psycopg2
from psycopg2.extras import execute_values
import hashlib
from pathlib import Path
from typing import Optional
import pypdf
import tiktoken
client = anthropic.Anthropic()
enc = tiktoken.encoding_for_model("gpt-4o") # Approximate token counting
def extract_text_from_pdf(pdf_path: str) -> str:
"""Extract text content from a PDF file."""
text_parts = []
with open(pdf_path, "rb") as f:
reader = pypdf.PdfReader(f)
for page in reader.pages:
text_parts.append(page.extract_text())
return "\n".join(text_parts)
def chunk_document(
text: str,
chunk_size: int = 512,
overlap: int = 64
) -> list[dict]:
"""
Split document into overlapping chunks.
- chunk_size: target size in tokens
- overlap: tokens of context shared between adjacent chunks
Why 512/64? This lets us fit 8–10 chunks into a Claude context window
while preserving enough overlap to avoid losing meaning at boundaries.
"""
tokens = enc.encode(text)
chunks = []
start = 0
while start < len(tokens):
end = min(start + chunk_size, len(tokens))
chunk_text = enc.decode(tokens[start:end])
# Skip fragments shorter than 50 tokens (end of document)
if len(enc.encode(chunk_text)) < 50:
break
chunks.append({
"text": chunk_text,
"token_start": start,
"token_end": end
})
start += chunk_size - overlap
return chunks
def ingest_document(
file_path: str,
department: str, # "hr", "engineering", "finance", "all"
doc_title: str,
db_conn: psycopg2.extensions.connection
) -> int:
"""
Ingest a document into the vector store.
Returns the number of chunks created (0 if already ingested).
"""
path = Path(file_path)
if path.suffix == ".pdf":
text = extract_text_from_pdf(file_path)
elif path.suffix in [".md", ".txt"]:
text = path.read_text(encoding="utf-8")
else:
raise ValueError(f"Unsupported file type: {path.suffix}")
# Deduplication via content hash
doc_hash = hashlib.sha256(text.encode()).hexdigest()
with db_conn.cursor() as cur:
cur.execute(
"SELECT id FROM documents WHERE content_hash = %s",
(doc_hash,)
)
if cur.fetchone():
print(f"Skipping {doc_title} — already ingested")
return 0
chunks = chunk_document(text)
with db_conn.cursor() as cur:
cur.execute(
"""
INSERT INTO documents (title, department, content_hash, file_path)
VALUES (%s, %s, %s, %s) RETURNING id
""",
(doc_title, department, doc_hash, file_path)
)
doc_id = cur.fetchone()[0]
chunk_data = [
(doc_id, i, chunk["text"], department)
for i, chunk in enumerate(chunks)
]
with db_conn.cursor() as cur:
execute_values(
cur,
"""
INSERT INTO document_chunks (doc_id, chunk_index, content, department)
VALUES %s
""",
chunk_data
)
db_conn.commit()
print(f"✅ Ingested: {doc_title} ({len(chunks)} chunks, dept: {department})")
return len(chunks)A note on embeddings: as of May 2026, Anthropic doesn't provide a dedicated embedding model, so you'll want to use voyage-3-large from Voyage AI or a similar provider. The code above intentionally omits the embedding generation to avoid confusion — add it after the chunk text extraction step.
Hybrid Search with pgvector and BM25
Pure vector search struggles with exact-match queries like "specification MA-2024-001" or specific error codes. BM25 (keyword search) handles these cases well. Combining both with Reciprocal Rank Fusion gives you the best of both worlds.
def hybrid_search(
query: str,
user_department: str,
query_embedding: list[float],
db_conn: psycopg2.extensions.connection,
top_k: int = 10,
vector_weight: float = 0.6,
bm25_weight: float = 0.4
) -> list[dict]:
"""
Hybrid search using RRF (Reciprocal Rank Fusion).
Critical: This function must be called with a DB connection that has
RLS configured for user_department. The function itself does NOT
apply department filtering — the database handles that automatically.
Never add application-layer department filtering on top of this.
"""
rrf_k = 60 # Standard RRF parameter from literature
with db_conn.cursor() as cur:
# Vector search — RLS automatically filters by department
cur.execute(
"""
SELECT id, content, doc_id, chunk_index,
1 - (embedding <=> %s::vector) AS score
FROM document_chunks
ORDER BY embedding <=> %s::vector
LIMIT %s
""",
(query_embedding, query_embedding, top_k * 2)
)
vector_results = {row[0]: (i, row) for i, row in enumerate(cur.fetchall())}
# BM25 full-text search — also subject to RLS
cur.execute(
"""
SELECT id, content, doc_id, chunk_index,
ts_rank(to_tsvector('english', content),
plainto_tsquery('english', %s)) AS score
FROM document_chunks
WHERE to_tsvector('english', content) @@ plainto_tsquery('english', %s)
ORDER BY score DESC
LIMIT %s
""",
(query, query, top_k * 2)
)
bm25_results = {row[0]: (i, row) for i, row in enumerate(cur.fetchall())}
# Merge with RRF
all_ids = set(vector_results.keys()) | set(bm25_results.keys())
scored_results = []
for chunk_id in all_ids:
rrf_score = 0.0
row_data = None
if chunk_id in vector_results:
rank, row = vector_results[chunk_id]
rrf_score += vector_weight / (rrf_k + rank + 1)
row_data = row
if chunk_id in bm25_results:
rank, row = bm25_results[chunk_id]
rrf_score += bm25_weight / (rrf_k + rank + 1)
if row_data is None:
row_data = row
scored_results.append({
"id": chunk_id,
"content": row_data[1],
"doc_id": row_data[2],
"chunk_index": row_data[3],
"rrf_score": rrf_score
})
scored_results.sort(key=lambda x: x["rrf_score"], reverse=True)
return scored_results[:top_k]Implementing RBAC with PostgreSQL Row Level Security
This is the architectural centerpiece of the system.
-- Table definition
CREATE TABLE document_chunks (
id BIGSERIAL PRIMARY KEY,
doc_id BIGINT NOT NULL REFERENCES documents(id),
chunk_index INT NOT NULL,
content TEXT NOT NULL,
department TEXT NOT NULL, -- "hr", "engineering", "finance", "all"
embedding vector(1024) -- voyage-3-large dimensionality
);
-- Enable RLS (FORCE applies it to table owners too)
ALTER TABLE document_chunks ENABLE ROW LEVEL SECURITY;
ALTER TABLE document_chunks FORCE ROW LEVEL SECURITY;
-- Standard user policy: see your own department + "all"
CREATE POLICY dept_access ON document_chunks
USING (
department = current_setting('app.user_department')
OR department = 'all'
);
-- Admin bypass (separate role)
CREATE POLICY admin_access ON document_chunks
TO admin_role
USING (true);
-- App role for regular users
CREATE ROLE app_user;
GRANT SELECT ON document_chunks TO app_user;from contextlib import contextmanager
@contextmanager
def get_db_connection_for_user(user_department: str):
"""
Return a DB connection scoped to user_department via RLS.
The third argument to set_config is crucial: 'true' means the setting
is only valid for the current transaction. 'false' would make it
session-scoped — dangerous with connection pooling because the setting
could persist to a different user's request.
"""
conn = psycopg2.connect(
dsn=os.environ["DATABASE_URL"],
options="-c role=app_user"
)
try:
with conn.cursor() as cur:
cur.execute(
"SELECT set_config('app.user_department', %s, true)",
(user_department,)
)
yield conn
finally:
conn.close()Claude API Answer Generation
SYSTEM_PROMPT = """You are an internal document search assistant.
Rules:
1. Answer only based on the document chunks provided
2. If information is not in the chunks, say explicitly: "This information is not in the provided documents"
3. Always cite which document chunks support your answer
4. Flag if your answer might involve sensitive or confidential information
"""
def answer_query(
query: str,
chunks: list[dict],
user_info: dict
) -> dict:
"""
Generate an answer from filtered search results using Claude.
Note on chunk count: claude-sonnet-4-6 has a 200K token window,
but we limit to 8 chunks to keep costs reasonable. Each 512-token
chunk + context overhead costs roughly 0.015 USD per query with sonnet.
"""
if not chunks:
return {
"answer": "No matching documents found. Try different keywords "
"or contact your admin to check access permissions.",
"cited_chunks": [],
"model": "none",
"input_tokens": 0,
"output_tokens": 0
}
# Limit chunks to control cost
display_chunks = chunks[:8]
context_parts = []
for i, chunk in enumerate(display_chunks):
context_parts.append(
f"[Chunk {i+1}] (Doc ID: {chunk['doc_id']}, "
f"Position: {chunk['chunk_index']})\n{chunk['content']}"
)
context = "\n\n---\n\n".join(context_parts)
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=2048,
system=SYSTEM_PROMPT,
messages=[
{
"role": "user",
"content": (
f"Document chunks:\n\n{context}\n\n"
f"Question: {query}"
)
}
]
)
return {
"answer": response.content[0].text,
"cited_chunks": [c["id"] for c in display_chunks],
"model": response.model,
"input_tokens": response.usage.input_tokens,
"output_tokens": response.usage.output_tokens
}Audit Logging for Compliance
import uuid
from datetime import datetime, timezone
def log_search_event(
db_conn: psycopg2.extensions.connection,
user_id: str,
user_department: str,
query: str,
cited_chunk_ids: list[int],
answer_summary: str,
model: str,
input_tokens: int,
output_tokens: int,
session_id: Optional[str] = None
) -> str:
"""
Write a search event to the audit log.
Design note: store only the first 200 characters of the answer.
Storing full responses significantly increases storage costs and
may create compliance issues if responses contain PII.
Returns the event UUID for downstream correlation.
"""
event_id = str(uuid.uuid4())
with db_conn.cursor() as cur:
cur.execute(
"""
INSERT INTO search_audit_log (
event_id, user_id, user_department, query_text,
cited_chunk_ids, answer_summary, model_used,
input_tokens, output_tokens, session_id, created_at
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
event_id, user_id, user_department, query,
cited_chunk_ids, answer_summary[:200], model,
input_tokens, output_tokens, session_id,
datetime.now(timezone.utc)
)
)
db_conn.commit()
return event_id
# Monthly cost report query
MONTHLY_COST_QUERY = """
SELECT
DATE_TRUNC('day', created_at) AS day,
user_department,
COUNT(*) AS query_count,
SUM(input_tokens) AS total_input_tokens,
SUM(output_tokens) AS total_output_tokens,
-- claude-sonnet-4-6: ~$3/M input, ~$15/M output (approx. May 2026)
ROUND(
(SUM(input_tokens) * 3.0 + SUM(output_tokens) * 15.0) / 1000000,
4
) AS estimated_cost_usd
FROM search_audit_log
WHERE created_at >= DATE_TRUNC('month', CURRENT_DATE)
GROUP BY 1, 2
ORDER BY 1, 2;
"""Keep the audit table in a separate schema (audit) with INSERT-only permissions for the app user. Restricting SELECT to an auditor role ensures the logs can't be tampered with by the application.
Three Pitfalls That Hit Us in Production
Pitfall 1: Chunk size too large
My first instinct was "more context is better," so I went with 2048-token chunks. What actually happened: we could only fit 2–3 chunks into Claude's context budget per query, and it kept missing relevant information scattered across other documents.
Switching to 512 tokens let us send 8–10 chunks per query, and answer quality improved noticeably.
# Don't do this
chunk_size = 2048 # Reduces chunk count per query → misses information
# This works better in practice
chunk_size = 512 # Fits 8–10 chunks comfortably, preserves context
overlap = 64 # ~12.5% overlap prevents boundary artifactsPitfall 2: Missing FORCE ROW LEVEL SECURITY
By default, table owners bypass RLS. Without FORCE ROW LEVEL SECURITY, admin connections (which we used during development) see everything — so our integration tests all passed. The access control failure only surfaced after we switched to the application role.
-- This is insufficient — table owner bypasses it
ALTER TABLE document_chunks ENABLE ROW LEVEL SECURITY;
-- This enforces it for everyone, including the table owner
ALTER TABLE document_chunks ENABLE ROW LEVEL SECURITY;
ALTER TABLE document_chunks FORCE ROW LEVEL SECURITY;Pitfall 3: Stale embeddings after document updates
When we updated a document, the process was: delete old record → insert new text. What we forgot was regenerating the embeddings. The BM25 search worked fine (it reads the text directly), but vector search returned results from the old content.
def update_document(doc_id: int, new_text: str, db_conn):
"""Always regenerate embeddings when updating document text."""
new_hash = hashlib.sha256(new_text.encode()).hexdigest()
chunks = chunk_document(new_text)
with db_conn.cursor() as cur:
# Remove all old chunks
cur.execute("DELETE FROM document_chunks WHERE doc_id = %s", (doc_id,))
# Re-insert with fresh embeddings
for i, chunk in enumerate(chunks):
# embedding = generate_embedding(chunk["text"]) # Required
cur.execute(
"""
INSERT INTO document_chunks (doc_id, chunk_index, content, department)
VALUES (%s, %s, %s, (SELECT department FROM documents WHERE id = %s))
""",
(doc_id, i, chunk["text"], doc_id)
)
cur.execute(
"UPDATE documents SET content_hash = %s, updated_at = NOW() WHERE id = %s",
(new_hash, doc_id)
)
db_conn.commit()Pitfall 4: Connection pooling and set_config scope
This one is subtle and only shows up under load. When using a connection pool (PgBouncer, asyncpg, etc.), set_config with is_local = false persists beyond the transaction. If the pooler reuses that connection for a different user, they'll see the previous user's department setting.
Always pass true as the third argument to set_config. And if you're using session-mode pooling (not transaction-mode), verify your pooler correctly resets session state between uses.
Testing Your RBAC Implementation
Access control bugs are among the most dangerous class of failures in this type of system. Before going to production, write a dedicated test suite that specifically verifies cross-department isolation.
def test_department_isolation():
# Engineering user should not see HR documents
with get_db_connection_for_user("engineering") as conn:
with conn.cursor() as cur:
cur.execute("SELECT content FROM document_chunks WHERE department = 'hr'")
rows = cur.fetchall()
assert len(rows) == 0, "Engineering user must not see HR documents"
# 'all' department documents should be visible to everyone
with get_db_connection_for_user("finance") as conn:
with conn.cursor() as cur:
cur.execute("SELECT content FROM document_chunks WHERE department = 'all'")
rows = cur.fetchall()
assert len(rows) > 0, "All users should see 'all' department documents"
def test_rls_not_bypassable_with_explicit_filter():
# Even with explicit WHERE department = 'hr', RLS blocks access for non-HR users
with get_db_connection_for_user("engineering") as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT COUNT(*) FROM document_chunks WHERE department = 'hr'"
)
count = cur.fetchone()[0]
# RLS enforces zero rows regardless of what the WHERE clause says
assert count == 0, "RLS must block access regardless of explicit filters"I run these as blocking pre-deploy checks in CI. A PostgreSQL upgrade that accidentally disables RLS gets caught immediately, not by a user in production.
Performance Optimization
The system runs fine for a few hundred documents without tuning. Here are the changes that made the biggest difference when we scaled past 5,000 documents.
Approximate vector indexes. The default pgvector index scans every row. At scale, switch to IVFFlat or HNSW:
-- IVFFlat: faster queries, slight recall trade-off
-- Set 'lists' to roughly sqrt(row count)
CREATE INDEX idx_chunks_embedding_ivfflat
ON document_chunks
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
-- HNSW: better recall than IVFFlat, higher memory usage
CREATE INDEX idx_chunks_embedding_hnsw
ON document_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);Between the two, I lean toward HNSW for internal document search. The recall difference matters more when documents are similar to each other (which they often are within a company's corpus), and memory is rarely a constraint in this use case.
Cache embeddings for repeated queries. About 30% of queries in our system are near-duplicates — people searching for the same procedures with slightly different wording. Caching embedding vectors in Redis with a 1-hour TTL reduced our embedding API costs by roughly 25%.
import redis
import json
import hashlib
redis_client = redis.Redis.from_url(os.environ["REDIS_URL"])
def get_or_generate_embedding(text: str) -> list[float]:
cache_key = f"emb:{hashlib.sha256(text.encode()).hexdigest()}"
cached = redis_client.get(cache_key)
if cached:
return json.loads(cached)
embedding = generate_embedding(text) # Your embedding API call
# Embeddings are deterministic — a long TTL (1-24h) is safe
redis_client.setex(cache_key, 3600, json.dumps(embedding))
return embeddingParallelize document ingest. With a thread pool, ingest time for 500 documents dropped from 45 minutes to 12 minutes on a 4-core machine:
import asyncio
from concurrent.futures import ThreadPoolExecutor
async def ingest_document_batch(
file_paths: list[tuple[str, str, str]],
db_conn,
max_workers: int = 4
) -> dict[str, int]:
"""Ingest multiple documents in parallel. Returns {title: chunk_count}."""
with ThreadPoolExecutor(max_workers=max_workers) as executor:
loop = asyncio.get_event_loop()
tasks = [
loop.run_in_executor(
executor,
lambda p=path, d=dept, t=title: ingest_document(p, d, t, db_conn)
)
for path, dept, title in file_paths
]
chunk_counts = await asyncio.gather(*tasks)
return {title: count for (_, _, title), count in zip(file_paths, chunk_counts)}Production Deployment Checklist
Before going live with real users, verify each of these:
Database security:
- RLS enabled with
FORCE ROW LEVEL SECURITYon all document tables - App role has
SELECTonly on document tables (no INSERT/UPDATE/DELETE) - App role has
INSERTonly on audit table — no SELECT (prevents log tampering) - Cross-department isolation confirmed by the pytest suite above
Application layer:
set_configusestrue(transaction-local) everywhere in the codebase- Department values come from your identity provider (JWT claims), never from user input or URL parameters
- Database credentials are in environment variables, not source code
- No application-layer department filter anywhere — RLS handles it, and redundant filters create a false sense of security
Observability:
- Audit log is writing on every query — verify manually after a test search
- Monthly cost report query is scheduled and returning expected values
- PostgreSQL slow query logging enabled for queries over 1,000ms
- Alerts configured for Claude API error rates
Search quality baseline:
- 20+ representative queries run by hand against your actual document set
- Hybrid search verified to find both semantic matches and exact product/error codes
- Claude returns explicit "not in documents" rather than hallucinating for out-of-corpus topics
- Verified that "all" department documents are accessible to all users
Where to Go from Here
This system works well up to a few thousand documents. Once you exceed that, you'll start to notice two things: BM25 performance degrades without proper index tuning, and the single pgvector table becomes a bottleneck.
The next step I'd take is adding a re-ranking layer. Using a cross-encoder model (or Cohere Rerank) as a second pass after hybrid search improves precision significantly, especially when the query is ambiguous. The RRF merged results are good, but a trained cross-encoder is better at judging semantic relevance than a simple score fusion formula.
Start with 10–100 internal documents, verify your RLS policies block cross-department access as expected, and instrument your audit logs from day one. The compliance story is much easier when you have a complete query history from the start.
Beyond re-ranking, three areas are worth investing in once the baseline system is proven:
Hierarchical chunking. Instead of uniform 512-token chunks, implement a two-level structure: large "parent" chunks (~2,000 tokens) that preserve complete sections, and small "child" chunks (~200 tokens) for retrieval. Use small chunks to find relevant sections, then send the parent chunk to Claude for context-rich answers. This is sometimes called "parent document retrieval" and is particularly effective for technical documentation where context spans multiple paragraphs.
User feedback integration. Add a thumbs-up/thumbs-down mechanism to the search UI and log ratings alongside audit events. After a few weeks, you'll have ground truth data to evaluate whether your current hybrid search weights (0.6 vector / 0.4 BM25) are actually optimal for your specific corpus. Adjust weights based on which queries the system consistently gets wrong.
Incremental document sync. If your documents live in Notion, Confluence, or SharePoint, build a sync pipeline that ingests only changed documents rather than re-ingesting everything on each run. Track a last_modified timestamp per document and compare it against your documents table before triggering the ingest pipeline. Combined with content-hash deduplication (already implemented above), this makes your pipeline idempotent — safe to re-run at any time without creating duplicate chunks.
The access control and audit foundation you built here carries forward through all of these improvements. The hardest part — getting the security model right from the start — is already done. Everything after this point is about improving recall and user experience, which is much more forgiving territory to iterate in.
Choosing Your Embedding Model
One decision I left as a placeholder in the ingest code above deserves its own explanation. As of May 2026, Anthropic does not provide a dedicated embedding model. You need a third-party provider, and the choice affects both retrieval quality and cost significantly.
Voyage AI (voyage-3-large) is my current recommendation. Anthropic officially recommends it for Claude-based RAG applications, and the model is trained to complement Claude's semantic space. The 1,024-dimension output is strong for technical documentation retrieval.
import voyageai
import os
voyage_client = voyageai.Client(api_key=os.environ["VOYAGE_API_KEY"])
def generate_document_embedding(text: str) -> list[float]:
result = voyage_client.embed(
texts=[text],
model="voyage-3-large",
input_type="document" # Use this for ingestion
)
return result.embeddings[0]
def generate_query_embedding(query: str) -> list[float]:
result = voyage_client.embed(
texts=[query],
model="voyage-3-large",
input_type="query" # Use this for search queries — different from "document"
)
return result.embeddings[0]The distinction between input_type="document" and input_type="query" matters more than it looks. The model applies different internal processing for each type. Using "document" for both ingest and queries degrades retrieval quality in my testing by roughly 5-8% on English technical content. For Japanese-heavy corpora, voyage-3-large tends to outperform OpenAI's embedding models due to stronger multilingual training data.
OpenAI text-embedding-3-large is a solid alternative if your team already runs OpenAI infrastructure. The 3,072-dimension output is larger than voyage-3-large's 1,024 dimensions — stronger semantic coverage, but about 6x the storage cost per chunk. For most internal document search applications, that storage overhead isn't justified by the marginal quality gain.
Multilingual content note. If your documents mix English and Japanese, both Voyage and OpenAI 3-large handle this reasonably. The critical rule is consistency: use the same model and the same input_type for every document in your corpus and every query. Switching models mid-deployment creates incompatible embedding spaces and silently degrades search quality in ways that are hard to diagnose.
The practical setup I use: voyage-3-large for ingest and queries, generate embeddings in batches of 128 texts per API call (Voyage supports batching), and cache the embeddings after generation so re-ingestion after schema changes doesn't require re-calling the API.
What Makes This System Actually Get Used
After building several internal search tools, I've noticed a pattern: technically correct systems still get abandoned if they fail on a few critical usability points. Here's what turned ours from "a demo people tried once" to something the team uses daily.
Response latency under 3 seconds. Users treat search as an instant-feedback interaction. Above 3 seconds, they start questioning whether the system is working. Profile your pipeline to find where time is spent: embedding generation, hybrid search, or Claude response generation. For most setups, the Claude API call dominates. Switching to claude-haiku-4-5-20251001 for simple queries and routing only complex questions to claude-sonnet-4-6 cuts average latency significantly while maintaining quality on most searches.
Honest "no results" messages. When Claude can't find relevant content — either because the documents don't cover the topic or because the user doesn't have access — the message matters. "I don't see information about this in the documents you have access to" is far better than a vague "no results found" because it tells the user whether to search differently or contact their admin. We added this distinction by checking whether the hybrid search returned zero results (no documents matched) versus returned results that Claude deemed insufficient (topic genuinely not covered).
Citation links that open the source. Every answer Claude generates should link back to the source documents, and those links should open the actual document at the relevant section. Users verify claims before acting on them. If you make verification difficult, trust in the system decays quickly. We store the source URL alongside each document chunk in the ingest pipeline and include it in the formatted context sent to Claude, which then echoes it back in citations.
Building for the user experience is just as important as getting the architecture right. The RBAC and audit logging keep the system safe to deploy. The latency, messaging, and citations keep people coming back.