Version: 1.0 Updated: 2026-03-12
This document covers both general RAG concepts (Section 1--2) and the Niles-specific implementation (Section 3--6). It serves as a reference for understanding why specific design choices were made and how the pipeline works end-to-end.
- Introduction
- RAG Pipeline -- Fundamentals
- Niles Implementation
- Design Decisions & Trade-offs
- Configuration Reference
- Troubleshooting
Retrieval-Augmented Generation combines a retrieval system (finding relevant documents) with a generative model (producing answers). Instead of relying solely on the LLM's training data, RAG fetches real-time context from a knowledge base and injects it into the prompt.
User Query
|
v
[Retrieval] --> Find relevant documents from knowledge base
|
v
[Augmentation] --> Inject retrieved context into LLM prompt
|
v
[Generation] --> LLM produces grounded answer with sources
Three main approaches exist for giving an LLM domain knowledge:
| Approach | Pros | Cons | Best For |
|---|---|---|---|
| Prompting | Zero cost, instant | Limited context window, no updates | Static, small knowledge |
| Fine-tuning | Deep knowledge integration | Expensive, needs retraining, hallucination risk | Style/behavior changes |
| RAG | Always current, verifiable sources, no retraining | Retrieval quality matters, added complexity | Dynamic knowledge bases |
RAG is the right choice when:
- Knowledge changes frequently (wiki pages, notes, docs)
- Users need verifiable sources (links back to originals)
- Privacy requires local processing (no data sent to cloud for training)
- The knowledge base is too large for the context window
Every RAG system follows the same high-level pipeline:
Data Source --> Ingest --> Chunk --> Embed --> Store --> Retrieve --> Inject --> Generate
Each step has multiple strategies with different trade-offs. The following sections cover each step in detail.
Data ingestion connects the knowledge source to the RAG pipeline. The key decisions are what to ingest and when to re-ingest.
Source types:
| Source | Method | Example |
|---|---|---|
| APIs | Poll or webhook | Notion API, Confluence API |
| Files | File watcher or scan | Markdown files, PDFs |
| Databases | Change data capture | PostgreSQL logical replication |
| Web | Crawl | Sitemap-based crawling |
Change detection strategies:
| Strategy | Freshness | Complexity | Best For |
|---|---|---|---|
| Polling + hash | Minutes | Low | APIs without webhooks |
| Webhooks | Real-time | Medium | Services that support them |
| Last-modified timestamp | Minutes | Low | File systems, APIs with timestamps |
| Full re-sync | On-demand | Lowest | Small datasets, initial loads |
Chunking splits documents into pieces small enough to embed meaningfully. The chunking strategy has the single largest impact on retrieval quality.
Fixed-size (character/token based)
Split text at every N characters or tokens, optionally with overlap.
[chunk 1: chars 0-500] [chunk 2: chars 400-900] [chunk 3: chars 800-1300]
↑ overlap
- Pros: Simple, predictable chunk sizes, uniform embedding dimensions
- Cons: Splits mid-sentence, ignores document structure
- Best for: Unstructured text, log files, transcripts
Recursive splitting
Try splitting by paragraph first, then sentence, then character. Popular in LangChain.
- Pros: Respects natural text boundaries better than fixed-size
- Cons: Variable chunk sizes, still structure-unaware
- Best for: Prose documents, articles, emails
Semantic splitting
Use embeddings to detect meaning shifts. Split where consecutive sentences have low similarity.
- Pros: Semantically coherent chunks
- Cons: Expensive (requires embedding every sentence), unpredictable sizes
- Best for: Long documents with topic changes, research papers
Document-structure-aware
Use the document's own structure (headings, sections, HTML tags) as split boundaries.
# Setup --> Section 1 (with heading context)
Install deps...
## Configuration --> Section 2 (inherits # Setup context)
Edit config...
# Usage --> Section 3 (new top-level context)
Run the app...
- Pros: Natural boundaries, heading context for retrieval, preserves author's structure
- Cons: Requires structured input (Markdown, HTML), variable chunk sizes
- Best for: Wiki pages, documentation, Notion content, structured notes
Overlap copies the last N characters of chunk K into the beginning of chunk K+1. This prevents information loss at chunk boundaries.
- Too little overlap (0): Hard cuts lose context
- Too much overlap (>30%): Redundant embeddings, wasted storage
- Sweet spot: 15--20% of chunk size
Instead of one chunk level, create two:
- Level 0 (summary): One LLM-generated summary per page (broad context)
- Level 1 (detail): Fine-grained chunks (specific information)
At retrieval time, both levels compete. When a detail chunk matches, its page's summary chunk gets a small boost -- providing broader context alongside the specific match.
| Data Type | Recommended Strategy | Chunk Size | Overlap |
|---|---|---|---|
| Wiki / Notion pages | Document-structure-aware | 400--800 chars | 100 chars |
| Plain text / transcripts | Recursive or fixed-size | 500--1000 chars | 100--200 chars |
| Code files | AST-based or function-level | Per function | None |
| PDFs (unstructured) | Fixed-size with sentence boundaries | 500--800 chars | 100 chars |
| Chat logs | Message-based (per turn or group) | Per conversation | None |
| Research papers | Section-based (abstract, methods, results) | Per section | 100 chars |
Embeddings convert text into dense numerical vectors where semantically similar texts are close together in vector space.
"How to install Python" --> [0.12, -0.34, 0.56, ..., 0.78] (768 dimensions)
"Python setup guide" --> [0.11, -0.33, 0.55, ..., 0.77] (similar vector)
"Recipe for pizza" --> [0.89, 0.12, -0.67, ..., -0.23] (different vector)
Similarity is measured by cosine distance: 1.0 = identical, 0.0 = unrelated.
| Model | Dimensions | Local | Cost | Quality |
|---|---|---|---|---|
| nomic-embed-text | 768 | Yes (Ollama) | Free | Good for general text |
| all-MiniLM-L6-v2 | 384 | Yes (SBERT) | Free | Fast, lower quality |
| text-embedding-3-small | 1536 | No (OpenAI) | $0.02/1M tokens | High quality |
| text-embedding-3-large | 3072 | No (OpenAI) | $0.13/1M tokens | Highest quality |
| Cohere embed-v3 | 1024 | No (Cohere) | $0.10/1M tokens | Multi-language |
Key trade-offs:
- Dimensions: Higher = more nuanced but more storage. 768 is the sweet spot for local models.
- Local vs. cloud: Local is free and private but slower. Cloud is fast but costs money and sends data externally.
- Task prefixes: Some models (nomic-embed-text, E5) require prefixes like
"search_document: "for indexing and"search_query: "for queries. This tells the model whether the text is a document to be found or a question looking for answers. Omitting prefixes degrades quality significantly.
- Single: Embed one text at a time. Simpler, lower memory, higher latency per batch.
- Batch: Embed many texts in one request. Higher throughput, better GPU utilization.
- For indexing pipelines: batch is preferred (process hundreds of chunks).
- For query time: single is fine (one query at a time).
Vector databases store embeddings and enable fast similarity search.
| Type | Examples | Pros | Cons |
|---|---|---|---|
| Purpose-built | Pinecone, Weaviate, Qdrant, Milvus | Optimized for vectors, rich features | Extra infrastructure to manage |
| DB extensions | pgvector (PostgreSQL), sqlite-vss | Reuse existing DB, SQL joins | Fewer vector-specific features |
| In-memory | FAISS, Annoy | Fastest retrieval | No persistence, RAM-limited |
Decision: If you already run PostgreSQL, pgvector eliminates an entire service from your stack. For <100k vectors, the performance difference is negligible.
| Index | Build Time | Query Speed | Recall | Best For |
|---|---|---|---|---|
| Flat (no index) | None | Slow (exact) | 100% | <10k vectors, development |
| IVFFlat | Fast | Fast | 80--95% (depends on probes) |
10k--1M, when you can tune probes |
| HNSW | Slow | Fast | 95--99% | Any size, best default choice |
IVFFlat partitions vectors into lists clusters and only searches probes of them at query time. With default probes=1 and lists=100, only 1% of vectors are searched -- missing most results. HNSW (Hierarchical Navigable Small World) builds a multi-layer graph and provides consistently high recall without parameter tuning.
Recommendation: Use HNSW unless you have a specific reason not to. The slightly slower build time is a one-time cost, and the query-time recall is dramatically better than IVFFlat with default settings.
Retrieval finds the most relevant chunks for a user query.
Pure vector search (semantic)
Embed the query, find nearest neighbors by cosine similarity.
- Pros: Understands meaning, handles paraphrases
- Cons: Weak on exact names/titles, typo-sensitive
- Best for: Natural language questions, conceptual queries
Keyword search (lexical)
Traditional full-text search (BM25, PostgreSQL tsvector).
- Pros: Exact term matching, handles named entities well
- Cons: No semantic understanding, misses paraphrases
- Best for: Searching for specific terms, product names, code identifiers
Hybrid search
Combine vector and keyword scores. Two common fusion methods:
-
Reciprocal Rank Fusion (RRF): Merge ranked lists from both methods
-
Weighted sum:
score = α * vector_score + (1-α) * keyword_score -
Pros: Best of both worlds
-
Cons: More complex, needs score calibration
-
Best for: Production systems with mixed query types
Lightweight hybrid: post-retrieval keyword boost
A simpler alternative to full hybrid search. Run vector search first, then boost results whose metadata (title, headings) matches query keywords.
Vector search --> Top 15 candidates --> Keyword boost on metadata --> Re-rank --> Top 5
- Pros: No second index needed, minimal complexity, works well for structured metadata
- Cons: Only boosts results already in the candidate set
- Best for: Structured documents with meaningful titles, small-to-medium collections
Reranking
Use a cross-encoder model as a second-stage ranker. More accurate but slower.
- Pros: Highest accuracy, considers query-document interaction
- Cons: Slow (can't be pre-computed), requires additional model
- Best for: High-stakes search, when top-1 accuracy matters
| Query Type | Best Approach |
|---|---|
| "What is X?" (conceptual) | Pure vector |
| "Find the Mindset page" (named entity) | Keyword or hybrid |
| Mixed queries, production use | Hybrid or vector + keyword boost |
| High-stakes, accuracy-critical | Hybrid + reranking |
The final step injects retrieved chunks into the LLM prompt.
Relevance filtering: Don't inject chunks below a similarity threshold. Low-quality context confuses the LLM more than no context.
Ordering: Present chunks sorted by relevance (highest first). LLMs attend more to the beginning and end of context.
Source attribution: Include source URLs/titles so the LLM can cite them. This makes answers verifiable.
Prompt design:
| Style | Example | Trade-off |
|---|---|---|
| Strict | "Answer ONLY from the provided context." | Fewer hallucinations, may refuse valid questions |
| Soft | "Base your answer on the provided context." | More helpful, slightly higher hallucination risk |
| Guided | "Use the context. If nothing is relevant, say so." | Best balance for most use cases |
Notion API
|
v
[Sync] src/niles/sync/notion.py
MD5 change detection
Page hierarchy crawl
|
v
notion_pages table PostgreSQL
|
v
[Chunk] src/niles/sync/notion_embeddings.py
Markdown-aware splitting
Heading hierarchy tracking
Breadcrumb prefixing
Code block masking
|
v
[Embed] src/niles/sync/ollama_embedder.py
Ollama + nomic-embed-text-v2-moe
768-dim vectors
Task prefixes
|
v
notion_embeddings table PostgreSQL + pgvector
HNSW index
|
v
[Retrieve] src/niles/actions/notion.py
Cosine similarity search
Auto-merge scoring
Keyword boost
Per-page deduplication
|
v
[Inject] src/niles/sources/web/_chat.py
Context formatting
Source attribution
|
v
[Generate] Ollama LLM (llama3.1:8b)
Grounded answer
Markdown source links
Module: src/niles/sync/notion.py -- NotionSync
The sync module connects to Notion via Internal Integration Token and crawls the page hierarchy recursively.
Change detection: MD5 hash of page content. Each synced page gets a content_md5 in the notion_pages table. On re-sync, the hash is compared -- unchanged pages skip re-embedding.
Storage: notion_pages table with columns:
id(Notion page UUID),title,parent_id(for hierarchy)content_text(extracted plain text with markdown structure)content_md5(for change detection)synced_at,embedded_at(for tracking pipeline state)url(for source attribution in search results)
Scheduling: Auto-sync runs on a configurable interval via APScheduler. Manual sync can be triggered from the Settings UI.
Module: src/niles/sync/notion_embeddings.py -- NotionEmbeddingPipeline
Niles uses document-structure-aware chunking because Notion pages are inherently structured with headings.
Pipeline:
-
_split_by_headings(text)-- Split on# / ## / ###lines into sections. Track heading hierarchy so## Subunder# Maingets context"# Main > ## Sub". Code blocks are masked (_mask_code_blocks) so bash comments like# install depsdon't trigger false splits. -
_split_section(body)-- Within each section, split by paragraph boundaries respectingchunk_sizewithchunk_overlap. These are configurable via thenotion_chunk_size(default: 600 chars) andnotion_chunk_overlap(default: 100 chars) settings; theNotionEmbeddingPipeline.__init__only supplies the fallback defaults. No overlap across section boundaries. -
Prefix each chunk with breadcrumb context:
[Wiki > Setup > # Installation] actual content here -
Filter junk --
_is_useful_chunk()drops chunks that are mostly ASCII art or box-drawing characters (alphanumeric ratio < 40%).
Output: ChunkInfo(text, page_title, heading_context) -- structured metadata carried through the pipeline.
Breadcrumbs: _build_breadcrumbs() walks up to 2 ancestors via parent_id to build page hierarchy context. Example: page "Installation" under "Migration Guide" under "ThemisEcho" gets breadcrumb "ThemisEcho > Migration Guide > Installation".
Hierarchical levels:
- Level 0 (summary): If a
NotionSummarizeris configured, pages longer than 100 chars get an LLM-generated 2--4 sentence summary. This provides broad page context for retrieval. - Level 1 (detail): Fine-grained chunks as described above. Always generated.
Module: src/niles/sync/ollama_embedder.py -- OllamaEmbedder
- Model:
nomic-embed-text-v2-moerunning locally via Ollama - Dimensions: 768
- Connection: Persistent
httpx.AsyncClientfor connection pooling. The embedder reuses thellm_base_urlsetting (there is no separateOLLAMA_BASE_URL) and calls the/api/embedendpoint, e.g.http://host.docker.internal:11434/api/embed
Task prefixes (required by nomic-embed-text-v2-moe for optimal retrieval):
"search_document: "-- prepended when indexing chunks"search_query: "-- prepended when embedding user queries
Metadata columns: Each embedding row stores page_title and heading_context alongside the vector. This enables keyword-based scoring at retrieval time without a second index.
Database: PostgreSQL 15 with pgvector extension
Table: notion_embeddings
| Column | Type | Purpose |
|---|---|---|
id |
SERIAL | Surrogate primary key |
page_id |
TEXT | FK to notion_pages |
chunk_level |
SMALLINT | 0 = summary, 1 = detail |
chunk_index |
INT | Position within page |
chunk_text |
TEXT | Full chunk with prefix |
embedding |
VECTOR(768) | nomic-embed-text-v2-moe vector |
page_title |
TEXT | Breadcrumb for keyword boost |
heading_context |
TEXT | Heading hierarchy for keyword boost |
created_at |
TIMESTAMPTZ | For freshness tracking |
Primary key: id SERIAL PRIMARY KEY. The tuple (page_id, chunk_level, chunk_index) is a UNIQUE constraint (upsert-safe), not the primary key.
Index: HNSW with m=16, ef_construction=64 using cosine distance operator (vector_cosine_ops).
Why HNSW over IVFFlat: During development, the original IVFFlat index with lists=100 and default probes=1 caused the retriever to miss 99% of results. HNSW provides >95% recall without parameter tuning.
Module: src/niles/actions/notion.py -- NotionRetriever
The retriever uses a multi-stage scoring pipeline combining vector similarity with structural metadata boosts.
Step 1 -- Vector search:
SELECT ... , 1 - (embedding <=> $1::vector) AS similarity
FROM notion_embeddings
WHERE 1 - (embedding <=> $1::vector) > $threshold
ORDER BY embedding <=> $1::vector
LIMIT $internal_limit- Threshold: 0.30 (configurable)
- Internal limit:
max_results * 3(over-fetches for scoring headroom)
Step 2 -- Keyword extraction:
_extract_keywords(query) strips German and English stop words, punctuation, and short tokens (<2 chars). Example: "was steht unter Mindset?" → ["mindset"].
Step 3 -- Composite scoring:
Four additive boosts, applied on top of the base cosine similarity:
| Boost | Value | Trigger |
|---|---|---|
| Multi-hit | +0.05 | 2+ detail chunks from same page in results |
| Summary | +0.03 | Summary chunk has detail hits from same page |
| Title keyword | +0.15 | Query keyword found in page_title |
| Heading keyword | +0.08 | Query keyword found in heading_context |
Boosts stack additively and are capped at 1.0.
Example: Query "was steht unter Mindset?" with keyword ["mindset"] hitting a page titled "Mindset":
- Base vector similarity: 0.31
- Title keyword boost: +0.15
- Final score: 0.46 (now ranks above an unrelated page at 0.45)
Step 4 -- Per-page deduplication:
Maximum per page: 1 summary + 2 detail chunks. Prevents one long page from dominating all result slots.
Step 5 -- Return top N results with chunk_text, page_title, page_url, similarity.
Module: src/niles/sources/web/_chat.py
When Notion search returns results, the chat handler:
- Strips the
[breadcrumb > heading]prefix from chunk text (presented separately) - Formats each result with source link, section heading, and relevance score
- Wraps everything in a
[Notion-Kontext]block - Uses a guided prompt style (see
src/niles/agent/prompts.py):- "Base your answer on the provided Notion sections"
- "If no section is relevant, say so"
- "Cite sources as Markdown links"
Niles already runs PostgreSQL for all other data. Adding Pinecone or Qdrant would mean another service to manage, another failure point, and another backup to maintain. For <100k vectors, pgvector with HNSW performs well enough. The trade-off is fewer vector-specific features (no built-in filtering, no automatic sharding).
Privacy is a core principle. Notion pages contain personal notes, work documents, and private information. Sending this to OpenAI or Cohere for embedding would violate the "100% local" guarantee. The quality difference between nomic-embed-text-v2-moe (768d, local) and text-embedding-3-small (1536d, cloud) exists but is acceptable for a personal knowledge base.
Full hybrid would require a second index (tsvector + GIN) and score fusion logic. The keyword boost approach is simpler: it only runs on the ~15 candidates already returned by vector search, checking if query keywords appear in the structured page_title and heading_context columns. This solves the specific problem (named entity queries like "Mindset") without the complexity of maintaining a parallel retrieval path.
Upgrade path: If the knowledge base grows significantly, the existing page_title and heading_context TEXT columns can be replaced with a tsvector GENERATED ALWAYS AS (...) column + GIN index for SQL-side full-text search.
Notion content is inherently structured with headings. Using fixed-size chunking would discard this structure, splitting mid-section and losing the heading context that makes chunks retrievable. Markdown-aware splitting preserves the author's organization and embeds heading hierarchy into each chunk's context prefix.
IVFFlat with default settings (probes=1) only searches 1 out of N clusters. During development, this caused the retriever to miss the correct result entirely (Mindset page not found despite 0.55 similarity). HNSW provides consistently high recall (>95%) without requiring probes tuning. The trade-off is slower index builds, but for <20k vectors this is negligible.
| Setting | Default | Description |
|---|---|---|
NOTION_TOKEN |
-- | Internal Integration Token (required) |
llm_base_url |
http://host.docker.internal:11434 |
Ollama API endpoint (shared with the LLM; the embedder appends /api/embed) |
notion_embedding_model |
nomic-embed-text-v2-moe |
Embedding model name |
notion_similarity_threshold |
0.30 |
Minimum cosine similarity for results |
notion_sync_interval |
30 |
Minutes between automatic syncs; 0 disables the scheduler |
notion_chunk_size |
600 |
Characters per chunk (default supplied by NotionEmbeddingPipeline.__init__) |
notion_chunk_overlap |
100 |
Overlap between chunks (default supplied by NotionEmbeddingPipeline.__init__) |
For setup instructions, see Deployment.md Section 12.
- Check embeddings exist:
SELECT COUNT(*) FROM notion_embeddings; - Check index type:
SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'notion_embeddings';-- should show HNSW, not IVFFlat. - Check embedding dimensions:
SELECT vector_dims(embedding) FROM notion_embeddings LIMIT 1;-- should return 768. - Test direct similarity:
SELECT chunk_text, 1 - (embedding <=> (SELECT embedding FROM notion_embeddings LIMIT 1)) AS sim FROM notion_embeddings ORDER BY sim DESC LIMIT 5;
- Typo sensitivity: Embedding models treat "Mindest" (German: minimum) and "Mindset" (English: attitude) as completely different words. Check spelling.
- Keyword boost not firing: Verify
page_titleandheading_contextare populated:SELECT page_title, heading_context FROM notion_embeddings WHERE page_title != '' LIMIT 5; - Threshold too high: Lower
notion_similarity_threshold(default: 0.30).
- Check
embedded_at < synced_atfor pending pages:SELECT COUNT(*) FROM notion_pages WHERE content_text != '' AND (embedded_at IS NULL OR embedded_at < synced_at); - Force re-embed: Call
force_reembed()from the Settings UI or API.
For more troubleshooting, see Deployment.md -- Troubleshooting.