Hybrid Retrieval
FTS5 keyword search + vector semantic search, blended via Reciprocal Rank Fusion
The Problem
Keyword search (FTS5 BM25) is fast and precise but fails on semantic similarity. "What GPU does the server have?" won't match "the GPU server has 4x RTX 3090" because there's no keyword overlap. Vector search catches meaning but can miss exact matches. The solution: use both.
How It Works
Step 1: FTS5 Path
The user's message is tokenized, stop words removed, and queried against memories_fts.
Results are ranked by a composite score:
This means strong, important memories rank higher for the same keyword relevance.
Archived memories are excluded (WHERE archived_at IS NULL).
Results are scoped by the sender's user ID and memory scope:
personalmemories — only ifuser_idmatches the senderroommemories — visible to anyone in the channelglobalmemories — visible to everyone everywhere
Returns up to limit × 3 candidates.
Step 2: Vector Path
The user's message is embedded via Ollama nomic-embed-text (768-dim, ~10ms).
The embedding is queried against vec_memories using sqlite-vec KNN:
SELECT memory_id, distance
FROM vec_memories
WHERE embedding MATCH ?1 AND k = ?2
Returns up to limit × 3 candidates ranked by L2 distance.
If Ollama is unreachable (timeout, error), this step is skipped entirely.
Step 3: Reciprocal Rank Fusion
Results from both paths are blended using RRF:
Where k = 60 (standard constant). A memory ranked #1 by both gets score
1/61 + 1/61 = 0.033. A memory ranked #1 by FTS5 but #10 by vector gets
1/61 + 1/70 = 0.031.
Top N results by blended score are returned. This naturally handles cases where only one path returns results (the other contributes 0).
Step 4: Graph Augmentation
After RRF selects the top memories, the memory link graph is walked 1-hop to find related memories that didn't match the query directly.
Up to 3 linked memories are added to the context. Links include
co_extracted (from the same conversation), consolidated (merged from),
and semantic (high cosine similarity). Only active (non-archived) memories are included.
This means asking "what language does Project X use?" can also surface "Project X deploys to ARM64" if the two facts were extracted together or are semantically linked.
Step 5: Touch
All retrieved memories (primary + graph-augmented) get their access_count bumped
and last_accessed updated. This resets the Ebbinghaus decay clock — frequently
recalled memories stay strong.
Context Assembly
The final ContextBundle sent to the LLM contains:
[system] You are Muninn...
## Relevant Memories
- Oz prefers dark mode
- the GPU server has 4x RTX 3090
- (up to 5 injected memories)
[user] (Sat, Apr 5 at 9:14 PM) message 1
[assistant] reply 1
... (last 10 messages)
[user] (Sun, Apr 6 at 10:30 AM) current message
Total context: ~2-4K tokens. Well under any limit.
Fallback Behavior
| Scenario | Behavior |
|---|---|
| Ollama up, embeddings exist | Full hybrid (FTS5 + vector + RRF) |
| Ollama up, no embeddings yet | Vector returns empty, FTS5 results used |
| Ollama down / timeout | FTS5-only (Phase 1 behavior) |
| No FTS5 keywords match | Vector-only results returned |
| No ollama_url configured | FTS5-only permanently |