How to build "ask your docs": a RAG pipeline, end to end
A minimal "ask your docs" demo is easy: chunk pages, embed them, retrieve the nearest few, put them in a prompt. The version that holds up on a real corpus needs more, because the common failure modes are all in the pipeline, not the model:
- the answer misses the one page that had it (retrieval),
- a table gets split across chunks and only half is visible (chunking),
- a question about Project A returns facts from Project B (no provenance),
- an embedder outage silently makes the index stale (no self-heal),
- a mobile answer is lost on a tab switch (connection-bound generation).
This is the full pipeline behind Ask in tela, an open-source team wiki, documented step by step. It has three stages: preparing documents (index-time), retrieving for a question (query-time), and grounding the generation. The whole thing on one diagram:
flowchart TD
subgraph PREP["Stage 1 · Prepare documents (index-time, on every save)"]
direction TB
SRC["Page body · attachment · sheet"]
NORM["Normalize to text<br/>sheet to prose · extract PDF/text · strip drawings"]
CHUNK["Chunk on structure<br/>heading-aware, ~1500-1700 chars"]
CTX["Contextualize<br/>prepend page title + heading breadcrumb"]
EMB["Embed<br/>cache by hash(model + text) · stamp model"]
STORE[("Store in pgvector<br/>beside content · no ACL on the chunk")]
SRC --> NORM --> CHUNK --> CTX --> EMB --> STORE
HEAL["Self-heal loop<br/>debounce · retry-backoff · stale sweep"]
HEAL -.re-index.-> NORM
end
Q(["User question"])
subgraph RET["Stage 2 · Retrieve (query-time)"]
direction TB
KW["Keyword search<br/>Postgres FTS · OR-recall"]
VEC["Vector search<br/>embed query to cosine distance"]
RRF["Fuse · Reciprocal Rank Fusion (k=60)"]
RERANK["Rerank — optional<br/>cross-encoder on top ~50 · best-effort, timed out"]
EXPAND["Expand to parent page<br/>dedup to pages · full body of hubs · budgeted"]
KW --> RRF
VEC --> RRF
RRF --> RERANK --> EXPAND
end
subgraph GEN["Stage 3 · Ground and generate"]
direction TB
GROUND["Build the LLM input<br/>numbered cited excerpts [n]<br/>+ Space › path location labels<br/>+ known cross-doc conflicts<br/>+ answer-only / be-exhaustive brief"]
LLM(["LLM · streamed, detached job"])
OUT(["Answer + cited sources<br/>+ low-confidence flag + follow-ups"])
GROUND --> LLM --> OUT
end
STORE -. access-scoped join .-> KW
STORE -. access-scoped join .-> VEC
Q --> KW
Q --> VEC
EXPAND --> GROUND
The rest of this post walks each stage and says what every step is for.
Stage 1: prepare documents
Runs in the background when a document is saved.
Step 0 — normalize to text
Not all content is prose. Embedding a spreadsheet's raw markup buries the data under syntax; embedding only page bodies misses attachments. Normalize each source to plain text first:
- Spreadsheet/table → project to self-describing prose (
"Revenue — Q1: 40k, Q2: 55k") so cell values are embedded, not markup. - Attached PDF or text file → extract its text and index it alongside the page it hangs off. Non-text bytes (images, scanned PDFs) are skipped.
- Drawings and other non-text blocks → stripped.
Step 1 — chunk on structure
Whole pages are too long to embed well; embeddings blur as text grows, and retrieval should return the relevant section. Split on the document's own headings rather than fixed-size windows: each chunk is a coherent section (~1500–1700 characters) that never cuts mid-idea.
Step 2 — contextualize each chunk
A section ripped out of its page loses meaning: a chunk titled "Limits" under a "Rate limiting" page matches any query about limits and answers none. Before embedding, prepend each chunk with its page title and heading breadcrumb (Rate limiting › Limits › <section>). The embedded text is self-contained; the original text is kept for display. This is a lightweight form of contextual retrieval.
Step 3 — embed and cache
Run each contextualized chunk through an embedding model (tela uses a 1024-dim model). Two practices matter from the start:
- Cache by
hash(model + text). On edit, only changed chunks get new text, so only they are re-embedded; the rest reuse stored vectors. - Stamp each vector with its model. Changing embedding models invalidates every vector (different vector space). The stamp identifies stale rows and enables model migration by backfilling the new model alongside the old, then switching.
Step 4 — store beside the source; authorize through the source
Store vectors in pgvector. A dedicated vector database is unnecessary when the app already runs Postgres: vectors sit next to content and permissions, and the lexical and vector indexes share one query engine.
Treat the chunk table as a disposable, derived cache carrying no permissions of its own. At query time, authorize through the live source row in the same SQL: join chunks → pages → access. Never copy an ACL onto the chunk. A chunk that can only be seen by joining to its live parent can never out-live or out-scope that parent — so moving a page to a private space cannot leak its old chunks.
Step 5 — keep it fresh automatically
Three mechanisms keep the index from drifting:
- Debounce — a burst of saves collapses to one re-index.
- Retry with backoff — a failed embedding is re-queued with exponential backoff, not dropped.
- Stale sweep — an independent loop re-queues anything whose index is missing or older than its source, recovering after an outage or a process restart that lost the in-memory queue.
Stage 2: retrieve
Step 1 — hybrid retrieval
Vector search matches by meaning ("how do I publish a space" finds the right page without the word "publish") but misses exact terms — error strings, identifiers, product names. Keyword search (Postgres full-text) matches those and misses paraphrase. They fail in opposite directions, so run both.
On the keyword side, use OR-recall rather than AND: a conversational question carries filler words a relevant document lacks, and requiring all terms zeroes out good documents. Match any term for candidacy; ranking sorts the rest.
Step 2 — fuse with RRF
Combine the two ranked lists with Reciprocal Rank Fusion: each list contributes 1 / (k + rank) (k = 60), so chunks ranked highly by both rise to the top. RRF needs no score calibration between the incomparable cosine and text-rank scales.
Step 3 — rerank (optional)
RRF gives good recall; the top few results may still be similar-looking rather than answer-bearing, because neither keyword overlap nor embedding distance reads the query and passage together. A cross-encoder reranker re-scores the top ~50 candidates by jointly reading query and passage — the largest precision lever above hybrid+RRF. Make it best-effort (fall back to the fused order on failure) and time-bounded (a slow reranker must not stall the request).
Step 4 — expand to the parent document
Chunk retrieval finds the right neighbourhood, but some answers span a whole page (a "services using X" registry, a split table) and cannot be reconstructed from one fragment. After retrieval, dedup chunks back to their source pages and expand the pages that matter — the top few by rank, plus any page the query drew many chunks from (a dense hub, often an overview page) — to their full body before grounding. Bound it with a per-page and a total character budget; the long tail degrades to chunk text.
Stage 3: ground and generate
Step 1 — build the LLM input
Assemble the retrieved material into a numbered, cited context block with a strict brief:
- Answer only from the excerpts; if they don't cover it, say so — don't invent.
- Cite sources by
[n]number so every claim is traceable. - Label each excerpt with its location (
Space › path › Title) and keep projects distinct. This is what prevents cross-project bleed. - If contradictions between documents are tracked, pass them in and surface the conflict instead of picking one value.
- Append a self-scoping "be exhaustive if this is a list/table" instruction — a no-op for ordinary questions, and language-agnostic (it fires regardless of phrasing rather than gating on English keywords).
Step 2 — production concerns
- Stream the answer. Generation takes 10–60s, much of it silent prompt-processing before the first token.
- Detach generation from the request. A stream tied to the HTTP connection dies with it, and backgrounded mobile browsers tear the connection down on a tab switch. Run generation on its own bounded context that writes to a replayable log; let the client reconnect and replay.
- Rate-limit the shared model. On a single box the embedder/LLM is the scarcest resource; a per-account limit prevents saturation.
- Flag low confidence. If even the best reranked hit scores below a calibrated threshold, still answer but mark it best-effort.
- Offer follow-ups. Suggested next questions turn a lookup into a thread.
Measure retrieval and generation separately
There are two distinct things to measure, and they fail independently:
- Retrieval quality — did the right page reach the candidate set? Score
recall@k,MRR, andnDCGagainst a golden set of(question → expected page)pairs. - Answer quality — the right pages were retrieved and the model still dropped an item. A retrieval eval scores that case 100% and hides the bug. A second eval runs the real answer pipeline and checks the output contains every expected item, splitting each miss into "retrieved but dropped" (generation) versus "never retrieved" (retrieval), which need different fixes.
What to skip until measured
At wiki scale (tens of thousands of chunks) an exact vector scan is sub-millisecond with perfect recall, so an ANN index (HNSW) only trades recall for unneeded speed. GraphRAG and multi-step agentic retrieval add fragility at this size. Add machinery when the eval set shows the simpler approach is the bottleneck — not before.
Summary
- Normalize everything to text (sheets, PDFs, not just prose).
- Chunk on structure, then contextualize each chunk before embedding.
- Cache embeddings by content+model hash; stamp the model.
- Store beside the content; authorize through the live source row.
- Self-heal the index (debounce · retry · stale sweep).
- Retrieve hybrid, fuse with RRF, rerank for precision.
- Expand chunks to their parent page for whole-page answers.
- Ground strictly — cite, label locations, surface conflicts, don't invent.
- Stream and detach generation; rate-limit; flag low confidence.
- Measure retrieval and generation separately.
The whole pipeline is open source in tela — a self-hostable, markdown-native team wiki with this Ask pipeline built in (pgvector, hybrid + RRF + optional cross-encoder rerank). Code: github.com/zcag/tela. Try Ask on the free cloud at telawiki.com.