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:

ORDER BY bm25(memories_fts) / (strength × (1 + importance)) ASC

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:

  • personal memories — only if user_id matches the sender
  • room memories — visible to anyone in the channel
  • global memories — 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:

score(d) = 1/(k + rankfts) + 1/(k + rankvec)

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

ScenarioBehavior
Ollama up, embeddings existFull hybrid (FTS5 + vector + RRF)
Ollama up, no embeddings yetVector returns empty, FTS5 results used
Ollama down / timeoutFTS5-only (Phase 1 behavior)
No FTS5 keywords matchVector-only results returned
No ollama_url configuredFTS5-only permanently