One SQLite file (~/.rekal/memory.db), three tables. Keep this file in
sync with SCHEMA in rekal/adapters/sqlite_adapter.py.
Why this is small. Earlier versions carried conversations, a memory link graph, a scratch tier with TTLs, memory types, access counters, and a per-project config table. Benchmarks showed none of it earned its cost: the structure existed mostly to be maintained, and the instructions explaining it cost tokens every session. The current model is the minimal core that hybrid recall actually needs. Structure gets re-added only when evidence forces it.
| Table | Holds |
|---|---|
memories |
the atomic unit: content + scope + tags + timestamps |
memories_fts |
FTS5 keyword index, trigger-synced to memories |
memory_vec |
sqlite-vec 384-dim embedding, 1:1 with memories (synced in Python, no trigger) |
CREATE TABLE IF NOT EXISTS memories (
id TEXT PRIMARY KEY,
content TEXT NOT NULL,
project TEXT,
tags TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);| Column | Type | Default | Notes |
|---|---|---|---|
id |
TEXT | none | 16-hex from uuid4().hex[:16] |
content |
TEXT | none | Distilled fact. Caveman-compressed. 1-2 sentences. |
project |
TEXT | NULL | Free-form scope. NULL = global memory. |
tags |
TEXT | NULL | JSON-encoded list[str]. NULL = no tags. Decoded by parse_tags. |
created_at |
TEXT | datetime('now') |
ISO-8601 UTC, YYYY-MM-DD HH:MM:SS. Used by recency scoring. |
updated_at |
TEXT | datetime('now') |
Set on insert; kept for provenance. |
External-content FTS5 table over content, tags, project, kept in
sync by three triggers (memories_ai / memories_ad / memories_au).
vec0 virtual table (id TEXT PRIMARY KEY, embedding float[384]).
No trigger support on virtual tables, so every write path (store,
replace, delete, prune) maintains it by hand.
store(content, project?, tags?)→ insert + embed.replace(old_id, content, ...)→ store new, delete old. No link graph: one topic, one memory. Project/tags inherit from the old row unless overridden. This backs thememory_store(replaces=...)tool.delete(id)/prune(project?/before?)→ remove rows + their vec rows.
| Invariant | Enforced by |
|---|---|
Tags are JSON-encoded list[str] |
db.store JSON-encodes; parse_tags decodes. Bad JSON falls back to []. |
Vector dim matches memory_vec declaration |
FastEmbedder.dimensions passed to SqliteDatabase.create. Mismatch → vec0 raises at insert. |
Timestamps are 'YYYY-MM-DD HH:MM:SS' UTC strings |
now_utc(). Compared lexicographically, which works because the format is fixed-width. |
memory_vec stays 1:1 with memories |
Hand-written cascade in every write path (no triggers on virtual tables). |
migrate_to_minimal (sqlite_adapter.py) runs on every open and detects
the pre-minimal shape by column (memory_type still present). It rebuilds
memories with the minimal columns and then lets SCHEMA recreate FTS
(+ a full index rebuild). One-way; idempotent.
Carried over:
- durable, non-superseded rows (id, content, project, tags, timestamps)
- their embeddings in
memory_vec, without re-embedding anything
Dropped, deliberately:
- superseded rows: their exclusion lived in
memory_links, which no longer exists, so carrying them would resurrect stale knowledge in search - scratch-tier rows: ephemeral by contract
conversations,conversation_links,memory_links,project_configtables, and the bookkeeping columns (memory_type,tier,conversation_id,expires_at,access_count,last_accessed_at)
The migration copies rows first and excludes after: each legacy feature
sits behind its own conditional, so pre-tier DBs (no tier column) and
DBs without a memory_links table migrate too. An interrupted run leaves
the old data intact and retries cleanly on the next open.
- Embed the query; vec lookup
k = limit × 3. quote_fts(query); FTS lookupLIMIT limit × 3(skipped when the query has no usable tokens).- Union candidates, fetch rows, filter on
project(strict equality) in Python. - Score (
combine_scores), drop belowmin_score, sort, takelimit.
Scoring internals: docs/scoring.md.