Data Persistence¶
Vector Companion uses a single WAL-mode SQLite database as the canonical persistence layer. This replaces scattered JSON files with a transactional store supporting CRUD operations across nine entity types.
SQLiteStore — Canonical Store¶
SQLiteStore (core/data/sqlite_store.py) is the primary persistence mechanism for all runtime artifacts: conversations, messages, settings, memories, audit events, tool grants, and background tasks.
Configuration¶
- WAL mode:
PRAGMA journal_mode=WALfor concurrent read/write access - Synchronous:
PRAGMA synchronous=NORMALfor balanced durability - Foreign keys: Enabled with cascade deletes
- Schema versioning: Current schema v3, auto-migration on first use
- Concurrency: asyncio lock protects concurrent writes
Database Tables¶
| Table | Record Class | Description |
|---|---|---|
conversations |
ConversationRecord |
Chat sessions with title, agent, mode flags |
messages |
MessageRecord |
Individual messages with role, content, metadata, tool_name |
folders |
FolderRecord |
Conversation organization and grouping |
settings |
SettingRecord |
Key-value app settings |
memories |
MemoryRecord |
User memories and training data |
attachments |
AttachmentRecord |
File references and binary blobs |
audit_events |
AuditEvent |
Tool execution audit trail |
tool_grants |
ToolGrantRecord |
Persistent approval grants |
background_tasks |
BackgroundTaskRecord |
Async task state and results |
Query Limits¶
get_messages(limit=None)— returns all messages (no LIMIT clause). Defaultlimit=100for backward compatibility.list_conversations(limit=None)— returns all conversations. Defaultlimit=50.- Always pass
limit=Nonefor full history loads (switch conversation, startup init, embedding context, forget conversation). - Use
message_count(conversation_id)for O(1) count instead oflen(get_messages(...)).
Schema Migration v3¶
The v3 migration adds a tool_name column to the messages table. This preserves tool call identity across session reloads — without it, tool results lose their attribution on restart. Existing pre-v3 rows get default empty string after migration.
Protocol Conformance¶
SQLiteStore implements LocalStoreProtocol. In tests, use FakeLocalStore from tests/testkit/ to verify protocol conformance:
Vector Embedding (memory_mode only)¶
Memory Mode Only
Embedding is only used for memory_mode conversation context recall. It has been retired from web_search and read_file paths to conserve VRAM.
- Model: The configured
embedding_model(per-provider model selection). Defaults toqwen3-embedding:0.6bvia Ollama; under thellama_serverprovider it is an HF-style GGUF id (e.g.Qwen/Qwen3-Embedding-0.6B-GGUF:Q8_0). Both the upsert and query paths use the configured model — passing the Ollama tag to the llama-server router returns HTTP 400 (model not found), which silently disables RAG. - Storage: ChromaDB persistent client, lazy-initialized via
config.get_db_client() - Operations: Upsert conversation context, query by similarity
- Telemetry:
embedding.query_ms,embedding.upsert_ms,embedding.doc_count
Reranker (memory_mode only, provider-conditional)¶
Memory Mode Only — Lazy Loaded
Like embedding, the reranker is only active for memory_mode. Retired from web_search and read_file paths. Unlike other heavy models (ASR/TTS), the reranker is not loaded at startup — it's lazy-loaded via LazyLoader on first use when memory_mode activates folder-scoped RAG, saving ~250MB GPU memory for users who never enable memory_mode.
The implementation is selected by config.llm_provider at lazy-load time (create_reranker() in core/data/server_reranker.py):
- ollama —
Reranker(core/data/reranker.py):tomaarsen/Qwen3-Reranker-0.6B-seq-clsvia CrossEncoder, in-process. - llama_server —
ServerReranker(core/data/server_reranker.py):Voodisss/Qwen3-Reranker-0.6B-GGUF-llama_cpp:Q8_0(config.reranker_model_llama_server) scored natively by the router'sPOST /v1/rerankendpoint — the GGUF is downloaded through the router (POST /models) like any other model, andensure_ready()writes (and patches, when keys are missing) the preset INI section the spawned child needs:reranking = true(else/v1/rerankanswers 501),hf-repo = <id>(else the child self-identifies as the router and never loads), andbatch-size/ubatch-size = 4096(else pairs longer than the physical batch 500). No in-process VRAM cost.
Shared by both (core/data/rerank_prepare.py): oversized-document cleaning/chunking/dedup, 5000-char query tail-truncation guard, and the numbered-passage join format. Both implement the same rerank() signature and return contract, so the RAG code path is provider-agnostic.
- Purpose: Second filtering pass after embedding-based retrieval for improved relevance
- Loading: Deferred via
LazyLoader.ensure_loaded("reranker")in the RAG code path - Telemetry:
reranker.duration_ms,reranker.doc_count - Task parameter: Accepts per-call
taskstring to override the default instruction. main.py passes conversation-memory framing so the CrossEncoder scores conversational relevance rather than factual-answer similarity. Under llama_server the same call shape works buttaskis nominal — the instruction is baked into the GGUF'srerankchat template (no per-request override in llama.cpp yet). - Scoring note (llama_server):
relevance_scoreis the raw "yes" logit (the CrossEncoder path ranks by the yes-no margin). Rankings almost always agree;thresholdis a raw-logit cutoff. - Message-level chunks: ChromaDB stores sentence-level chunks, so main.py reassembles retrieved fragments back into full messages before reranking (
_build_rerank_chunksinmain.py): groups fragments bymessage_id, fetches each full message from SQLite, strips prompt metadata, caps content at 1000 chars per message, and packs up to 4 consecutive messages per chunk (capped at 12 messages total). Missing or unfetchable messages fall back to the joined fragments. This lets the CrossEncoder score coherent conversation turns instead of isolated sentences. - Context window exclusion: Retrieved documents already visible in the agent's context window are dropped before reranking (
_filter_documents_in_windowincore/data/embedding.py). The window mirrors the agent's actual slice (message_slice_len, or 125 in auto-talk). In-memory messages carry no persisted id, so matching is content-based: each in-window message is stripped of prompt metadata (the same transform applied before upsert) and any document whose text is a substring of a window message is dropped — whitespace-insensitively, so sentence-splitting artifacts (space inserted at][tag boundaries) don't break the match. This stops the agent from re-reading what it just said, keeping[FOLDER MEMORY CONTEXT]purely historical. The current utterance is naturally excluded — it isn't embedded yet (upsert runs after the agent responds). - Max results: Configured to
max_results=3chunks — each chunk holds up to 4 messages, so the[FOLDER MEMORY CONTEXT]block carries up to 12 messages of historical context. - Historical-context note: A short
HISTORICAL_CONTEXT_NOTE(core/agent/memory_context_service.py) is appended after the retrieved passages, before the closing tag, telling the agent the passages are past conversation excerpts — background, not current facts — so it never presents them as the result of a tool call or web search. The note sits inside the block, so the persistence strip removes it along with the passages. - Oversized threshold: Accepts per-call
oversized_threshold(default 1000) for the pre-flight size guard. main.py passesoversized_threshold=5000so multi-message chunks (~4000 chars) are scored whole instead of being re-split sentence-wise.
VRAM OOM Protection¶
The CrossEncoder reranker operates with a max_length=4096 token budget. Production investigation revealed that oversized ChromaDB entries (up to 2311 characters) caused CUDA out-of-memory crashes after sustained use — the XML template wrapping pushed individual pairs over the token limit, exhausting GPU memory.
Three-layer defense prevents this:
- Sentence splitting (
_split_into_sentences(max_length=800)) — Splits on terminal punctuation (.!?;), then mid-sentence markers (dashes, colons), then brute-force chunks at word boundaries. No single sentence exceeds 800 characters. - Active truncation at upsert (
_EMBED_MAX_SENTENCE_CHARS = 1000) —upsert_message_turn()truncates and re-chunks any oversized entry before it reaches ChromaDB. - Reranker pre-flight guard — Before scoring,
rerank()processes any document over theoversized_threshold(default 1000 chars, configurable per call) by stripping prompt metadata and splitting into sentence-sized pieces; deduplicated and empty-after-processing documents are filtered out.
Conversation Backup¶
The backup system exports and imports conversations as JSON files with defensive type validation:
- Exporter: Exports from SQLiteStore with optional content redaction
- Importer: Validates schema version, rejects malformed data gracefully
- Format: Structured sections for metadata, folders, conversations, messages, settings, memories, tool grants
Conversation Search¶
Full-text search across conversation history stored in SQLiteStore. Supports keyword matching with context line retrieval.
Memory Lifecycle¶
Automatic memory extraction from conversations. Integrates with both the memories table in SQLite and ChromaDB for semantic search when memory_mode is active.
See Also¶
- Security Pipeline — audit events persist through SQLiteStore
- Runtime — startup phase 10 loads conversation history
- API Reference: Data — class and function reference