Skip to content

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=WAL for concurrent read/write access
  • Synchronous: PRAGMA synchronous=NORMAL for 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). Default limit=100 for backward compatibility.
  • list_conversations(limit=None) — returns all conversations. Default limit=50.
  • Always pass limit=None for full history loads (switch conversation, startup init, embedding context, forget conversation).
  • Use message_count(conversation_id) for O(1) count instead of len(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:

assert isinstance(store, LocalStoreProtocol)

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 to qwen3-embedding:0.6b via Ollama; under the llama_server provider 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):

  • ollamaReranker (core/data/reranker.py): tomaarsen/Qwen3-Reranker-0.6B-seq-cls via CrossEncoder, in-process.
  • llama_serverServerReranker (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's POST /v1/rerank endpoint — the GGUF is downloaded through the router (POST /models) like any other model, and ensure_ready() writes (and patches, when keys are missing) the preset INI section the spawned child needs: reranking = true (else /v1/rerank answers 501), hf-repo = <id> (else the child self-identifies as the router and never loads), and batch-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 task string 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 but task is nominal — the instruction is baked into the GGUF's rerank chat template (no per-request override in llama.cpp yet).
  • Scoring note (llama_server): relevance_score is the raw "yes" logit (the CrossEncoder path ranks by the yes-no margin). Rankings almost always agree; threshold is 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_chunks in main.py): groups fragments by message_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_window in core/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=3 chunks — 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 passes oversized_threshold=5000 so 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:

  1. 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.
  2. Active truncation at upsert (_EMBED_MAX_SENTENCE_CHARS = 1000) — upsert_message_turn() truncates and re-chunks any oversized entry before it reaches ChromaDB.
  3. Reranker pre-flight guard — Before scoring, rerank() processes any document over the oversized_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

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