
Memo's memory system uses Retrieval-Augmented Generation (RAG) to automatically recall relevant context for every conversation. This page explains the technical pipeline from message to memory retrieval.
User message
│
▼
┌─────────────────────┐
│ 1. Embedding │ → Convert text to vector (nomic-embed-text-v1.5)
└─────────────────────┘
│
▼
┌─────────────────────┐
│ 2. Semantic Search │ → Vector similarity (cosine) + FTS5 keyword search
└─────────────────────┘
│
▼
┌─────────────────────┐
│ 3. RRF Merge │ → Reciprocal Rank Fusion — combine vector + FTS results
└─────────────────────┘
│
▼
┌─────────────────────┐
│ 4. Context Packing │ → Inject top N memories into the system prompt
└─────────────────────┘
│
▼
┌─────────────────────┐
│ 5. LLM Response │ → Model generates with memory context
└─────────────────────┘
│
▼
┌─────────────────────┐
│ 6. Persist │ → Store the new interaction for future retrieval
└─────────────────────┘
Every message is converted into a dense vector (embedding) using nomic-embed-text-v1.5, a 137M parameter model specialized for semantic similarity. This model runs locally via the bundled llama.cpp server.
Long messages are split into overlapping 300-word chunks with a 50-word overlap window:
// internal/memory/chunker.go
type Chunk struct {
Text string
Index int
WordLen int
}
func ChunkMessage(text string) []Chunk {
// Splits on word boundaries, 300 words per chunk, 50-word overlap
}
Each chunk is embedded and stored independently. Previously, long messages were stored as one block — information buried in the middle was often missed.
When storing, the embedding is computed from both the user message and the assistant reply concatenated. This means queries like "what did you suggest about pricing?" find the right memory even when the user message alone doesn't contain the answer.
Memory retrieval uses two search strategies simultaneously:
Uses sqlite-vec with the vec0 virtual table — an approximate nearest neighbor (ANN) index:
SELECT rowid, distance
FROM memory_vec
WHERE embedding MATCH ?
ORDER BY distance ASC
LIMIT ?
Distance is computed using cosine similarity. Results are semantic matches — conceptually related content even when exact words differ.
Uses SQLite FTS5 with the unicode61 tokenizer:
SELECT rowid, rank
FROM memory_fts
WHERE content MATCH ?
ORDER BY rank
LIMIT ?
Each word is matched independently (not as a phrase). This catches exact terms, names, dates, and technical jargon.
The retrieval candidate pool is dynamically sized:
candidatePool := min(max(topK * 5, 20), 100)
Previously fixed at 15 — far too small for large memory bases beyond a few thousand entries.
The last 3 user messages are appended as additional context. Short follow-ups like "remember what we said about that?" now find relevant memories instead of returning nothing.
For queries longer than 7 words, a second embedding is computed from the first 5 words as a topic anchor. Both embeddings are searched separately and merged via RRF, improving recall when the core topic is buried mid-sentence.
Results from vector search and FTS are merged using Reciprocal Rank Fusion:
RRF_score(d) = Σ 1 / (k + rank_i(d))
Where k = 60 (a damping constant that smooths rank differences). Memories appearing in both result sets are promoted to the top. A quality filter discards low-confidence matches below a threshold.
Top N matching memories (typically 5–10, configurable) are formatted and injected into the system prompt:
[Relevant Memories]
- 2024-06-15: User decided on usage-based pricing with a free tier.
(match: hybrid, importance: 5)
- 2024-06-10: User flagged that seat pricing feels wrong for a local-first tool.
(match: vector, importance: 4)
Each memory includes its timestamp, match type ("vector", "fts", or "hybrid"), and importance score.
The LLM receives the full prompt — system prompt + relevant memories + conversation history + user message — and generates a response informed by past context.
After the conversation turn, the exchange (user message + assistant reply) is:
data/memory/memory.db in three tables:memories — raw text, metadata, importance scorememory_vec — vector embeddings (vec0 virtual table)memory_fts — full-text search index (FTS5 virtual table)All three tables are written atomically in a single transaction.
-- Text storage
CREATE TABLE memories (
id TEXT PRIMARY KEY,
content TEXT NOT NULL,
timestamp TEXT NOT NULL,
importance INTEGER DEFAULT 1,
retrieval_count INTEGER DEFAULT 0,
pinned INTEGER DEFAULT 0,
soft_deleted INTEGER DEFAULT 0,
deleted_at TEXT
);
-- Vector index (sqlite-vec vec0)
CREATE VIRTUAL TABLE memory_vec USING vec0(
embedding FLOAT[768]
);
-- Full-text index (FTS5)
CREATE VIRTUAL TABLE memory_fts USING fts5(
content,
tokenize='unicode61'
);
Every memory has an importance score (1–5):
/remember) → importance 5, never decayedThe decay cycle runs as a daily background job.
A daily background process prevents near-duplicate accumulation:
This runs after the decay cycle and never blocks chat responses.
Settings → Memory → Debug Search: Type any query to see the top retrieved memories with scores, timestamps, and match type badges.
Memory Analytics Panel shows:
| Command | Action |
|---|---|
/remember <text> |
Save a pinned memory with max importance |
/forget <pattern> |
Delete memories matching the pattern |
| Operation | Typical Latency |
|---|---|
| Embedding (300-word chunk) | ~50–100ms |
| Vector search (10K memories) | ~5–15ms |
| FTS5 search (10K memories) | ~1–3ms |
| RRF merge | ~1ms |
| Full pipeline (query → results) | ~60–120ms |
Performance scales logarithmically with the number of stored memories due to the vec0 ANN index. A memory base of 100K entries adds roughly 2× the search latency of 10K.