Generated banner for Engrava
Generated logo for Engrava

Engrava

Memory frameworkOfficial Sovantica project

Created by Sovantica

Published in sovantica/engrava. Publisher page

12 GitHub stars

What it is

The memory database for AI agents - graph memory, hybrid search, tamper-evident thought/edge journal.
— the description by Sovantica

AutoGineer overview, from the brain’s own files

Engrava is an in-process memory database designed for AI agents, built on SQLite. It provides a structured way to store and retrieve agent thoughts using a graph-based model, where thoughts are linked by typed edges. The system supports hybrid search, combining vector similarity, full-text search (FTS5), recency, and priority. It includes built-in mechanisms for memory consolidation (dreaming), memory hygiene (archiving and garbage collection), and a tamper-evident journal for tracking mutations. Engrava operates as a library with no external service dependencies in its core path, allowing agents to maintain persistent, queryable state locally.

How it works

Memory model: Hybrid

AutoGineer overview, from the brain’s own files

Engrava uses a SQLite database to store thoughts and their relationships. It organizes data into thoughts and typed edges, which can be queried using the MindQL language. The system supports hybrid search by combining vector embeddings, keyword search, and metadata signals. It manages memory through a lifecycle that includes archiving and garbage collection, and it can consolidate memories into reflections. The system is extensible via hooks and provides an MCP server for integration with various AI agent frameworks.

Folders
FolderRoleFiles
.githubagent-config16
docsdocs37
examplesexamples13
scriptsscripts24
srcsource-code89
teststests201
.githooksfolder1

The agent reads

Requirements

Runtimes: Python >=3.11

Databases: SQLite

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Google Antigravity, Kiro, OpenCode, Amp, Roo Code, Cline, JetBrains Junie, Devin (ex-Windsurf)

Set it up

  1. This brain runs code automatically: 2 scripts. Review .githooks/commit-msg, Makefile before opening the folder in Claude Code. No step below turns a hook on for you.

    • .githooks/commit-msg: runs on git commit-msg
    • Makefile: echo "$(BLUE)>> Installing engrava + dev dependencies$(RESET)"; $(PIP) install --upgrade pip setuptools wheel; $(PIP) install -e ".[dev]"; bash scripts/install_hooks.sh; echo "$(GREEN)>> Done. Run 'make check' to verify quality gate.$(RESET)"
  2. Not scanned by AutoGineerIts dependencies are installed by pip or uv when you run it and were not scanned by AutoGineer (5 declared). https://pypi.org/project/engrava/
  3. Created by Sovantica. Published in sovantica/engrava. Licence: MIT License.

Usage

AutoGineer overview, from the brain’s own files

  • Persistent memory for autonomous AI agents
  • Personal knowledge base management
  • Conversation storage with semantic retrieval
  • Research notes with associative linking
  • Memory-backed agent development
  • Audit-trailed thought and edge tracking
“Store a new observation about a project requirement.”
The agent uses the remember() method to persist the thought, which is then indexed for future hybrid search and graph traversal.
“Retrieve relevant context for a coding task.”
The agent uses the recall() method, which performs a hybrid search across FTS5, vector, and graph signals to return ranked, contextually relevant thoughts.
“Query the memory graph for all observations with high priority.”
The agent executes a MindQL query: FIND thoughts WHERE thought_type = 'OBSERVATION' AND priority = 'P1'.
“Consolidate old memories to free up space.”
The agent triggers the DreamingExtension to cluster related thoughts and compute reflections, or uses the memory hygiene loop to archive low-signal thoughts.

Documentation by Sovantica

Engrava

The memory database for AI agents.

A queryable memory graph with bi-temporal valid-time predicates — in an in-process Python library over one SQLite file, with no generative-model call in the core write path.

Image: CI Image: PyPI Image: Python Image: License: MIT

Engrava is a standalone, in-process database for AI agent memory. Built on SQLite, it provides thought CRUD, edge-based knowledge graphs, embedding-based similarity search, full-text search (FTS5/BM25), and a declarative extension system — all in a single package with zero external service dependencies.

Benchmark results are published, and the runs are reproducible from a separate repository. Every published run is Group A — memory_pipeline_llms: [], no language model anywhere in the memory layer's built-in path. That is a property of the architecture's default signals and hooks rather than a measurement, unless a custom Dreaming signal or Memory Hygiene hook is configured to call one.

  • Benchmark results — the live table: every published row with the comparability segment it belongs to.

  • engrava-benchmark — the runner. Clone it and reproduce a result against the package from PyPI. MIT.

  • Discussions — questions; reproduction questions belong in the Q&A category.

Use Cases

  • AI agent persistent memory

  • Personal knowledge base

  • Conversation storage with semantic search

  • Research notes with associative linking

  • Any application that needs a thought-graph with embeddings

Quick Start

Installation
pip install engrava

Optional extras:

pip install 'engrava[vec]'                # sqlite-vec vector search backend
pip install 'engrava[embeddings-local]'   # sentence-transformers embeddings (local model)
pip install 'engrava[embeddings-openai]'  # OpenAI-compatible embeddings API
pip install 'engrava[embeddings-ollama]'  # Ollama local embeddings server
pip install 'engrava[embeddings-hf]'      # HuggingFace Inference API embeddings

Dreaming/consolidation and the knowledge graph need no extra — they are part of the base install.

embeddings-local carries a large first-run download. It pulls sentence-transformers and torch, plus a further, separate model download (~88 MB for all-MiniLM-L6-v2, cached under ~/.cache/huggingface/hub) on first use. On Linux, the PyPI build of torch also pulls the NVIDIA CUDA libraries as dependencies: several gigabytes, most of it usable only with an NVIDIA GPU. Without one, install the CPU-only build first (see Configuration: No NVIDIA GPU for the exact command and numbers). engrava-mcp[local] is the exact same download, reached through the server package instead of this one. If you do not need semantic search in-process, pip install engrava alone (no extra, no download, no model) already gives you keyword search, the graph, and MindQL — see Configuration → Quick-start profiles for that and the Ollama-backed alternative that keeps the model out of this process entirely.

Basic Usage

Store a memory and search for it in two calls — no IDs to generate, no record to assemble:

import asyncio

import aiosqlite

from engrava import SqliteEngravaCore


async def main() -> None:
    # SqliteEngravaCore wraps an open aiosqlite connection.
    async with aiosqlite.connect(":memory:") as conn:
        conn.row_factory = aiosqlite.Row
        store = SqliteEngravaCore(conn)
        await store.ensure_schema()

        await store.remember("Python is great for AI agents")
        await store.remember("SQLite needs no server")

        result = await store.recall("what language is good for agents?")
        for thought_id, score in result.results:
            thought = await store.get_thought(thought_id)
            if thought is not None:
                print(f"{thought.essence}  (score: {score:.3f})")


asyncio.run(main())

remember() stores the text as a thought (generating its ID for you) and returns the stored ThoughtRecord; recall() runs the same hybrid search as search_hybrid() and returns the ranked results. This store has no SearchConfig of its own, and it still ranks with the same default weights — FTS 0.30, vector 0.55, recency 0.10, priority 0.05, graph 0.00 (opt-in) — as a store built with one; see Hybrid Search. For full control — setting priority, thought type, metadata, or the cognitive cycle on a write — build a ThoughtRecord yourself and call create_thought().

From here, link thoughts with typed edges, query them with MindQL, or run the full ingest → dream → search tour in the Quick Start guide.

Configuration-Driven Setup
from engrava import SqliteEngravaCore

# from_config opens and OWNS the connection — use it as an async context manager.
async with await SqliteEngravaCore.from_config("engrava.yaml") as store:
    # The schema is already applied by from_config.
    thought = await store.get_thought("some-id")

See docs/configuration.md for the full YAML schema.

Upgrading

Automatic schema migration runs on first connection. See the upgrade guide for compatibility notes, backup guidance, and troubleshooting steps.

Features

Thought CRUD

Create, read, update, and archive thoughts with full lifecycle management. All models are frozen Pydantic objects — mutations happen via evolve().

Edge-Based Knowledge Graph

Link thoughts with typed, weighted edges. Edge types include ASSOCIATED, DEPENDS_ON, DERIVED_FROM, CONSOLIDATED_FROM (created by dreaming), and CONTESTED_BY.

Store embeddings alongside thoughts and search them with the built-in NumPy cosine backend or the optional sqlite-vec backend (pip install 'engrava[vec]'). Pluggable embedding providers:

ProviderExtraBackend
SentenceTransformerProviderembeddings-localLocal model via sentence-transformers
OpenAICompatibleProviderembeddings-openaiAny OpenAI-compatible API
OllamaProviderembeddings-ollamaLocal Ollama server
HuggingFaceProviderembeddings-hfHuggingFace Inference API
CallbackProvider(built-in)Custom callable
Full-Text Search (FTS5)

SQLite FTS5 virtual table with BM25 ranking. Hybrid search combines vector similarity, text relevance, recency, priority, and graph connectivity. Signals that cannot run for a query are skipped and the remaining weights are redistributed.

MindQL Query Language

Declarative query language for the thought-graph:

FIND thoughts WHERE thought_type = 'OBSERVATION' AND priority = 'P1' LIMIT 10
COUNT thoughts WHERE lifecycle_status = 'ACTIVE'
SELECT thought_id, essence FROM thought WHERE thought_type = 'BELIEF'

Extensible with custom commands through MindQLExecutor or an ExtensionManifest; the lifecycle hook registry is reserved and is not consulted by the core executor.

Extension System

Plug into the thought lifecycle via EngravaHooksProtocol. Subclass DefaultEngravaHooks when you only need selected active methods:

from engrava import DefaultEngravaHooks, ThoughtRecord

class MyHooks(DefaultEngravaHooks):
    async def on_store(self, thought: ThoughtRecord) -> ThoughtRecord:
        # Observe or enrich the object returned after persistence.
        return thought

    async def decay_function(
        self, thought: ThoughtRecord, elapsed_cycles: int
    ) -> float:
        # Supply a decay multiplier to an enabled Memory Hygiene pass.
        return 1.0

Core currently invokes on_store, on_retrieve, and decay_function. on_store runs after the source thought's row is inserted; changing its return value does not rewrite the persisted row. score_function and mindql_extension_registry() remain reserved protocol methods and are not called by core.

Dreaming / Memory Consolidation

Built-in DreamingExtension for periodic memory consolidation — scores thoughts via its default signals (no LLM calls), promotes high-value entries, and creates REFLECTION thoughts by clustering semantically related thoughts and computing centroid embeddings through a deterministic structural function, not an LLM. A custom signal you register with DreamingExtension runs whatever code it contains. Available since 0.3.0.

→ See docs/benchmarks.md for reproducible evidence (synthetic benchmark suite runnable in ~5 minutes).

Forgetting / Memory Hygiene

The subtractive half of memory maintenance, paired with Dreaming: an opt-in loop whose built-in scoring makes no LLM calls, that archives cold, low-signal thoughts — the default action, reversible via restore_thought — and, as a separately opted-in step, garbage-collects them (not reversible) once both restore windows have elapsed under their non-zero defaults — a cycle count and a wall-clock duration, either of which can be configured to 0 to disable that window. OFF by default; once enabled, archived thoughts drop out of default retrieval and can be restored (restore_thought / include_archived).

→ See docs/memory-hygiene.md for the loop, protection, restore windows, and the honest deletion posture.

Tamper-Evident Thought/Edge Journal

Opt-in hash-chain journal that records thought and edge mutations (plus action status/verification_status transitions) as SHA-256-linked, before/after entries — a tamper-evident thought/edge journal, not a whole-database audit (embeddings and action creation are not covered). Off by default, one config flag to enable. Query history with store.journal.get_entries(...) and validate the chain with store.verify_journal(), which audits whatever chain is on disk independent of the current journal.enabled state — the store.journal writer handle does not exist while journaling is off, but the chain earlier sessions wrote is still in journal_entry and still needs verifying.

→ See docs/audit-trail.md for enabling, querying, verification, and the security model (what "tamper-evident" does and does not guarantee).

Multi-Service Isolation

Run multiple independent databases under one EngravaManager:

from pathlib import Path

from engrava import EngravaManager

async with EngravaManager(data_dir=Path("./data")) as mgr:
    agent_a = await mgr.get_store("agent-a")
    agent_b = await mgr.get_store("agent-b")
    # Completely isolated databases
MCP Server

Want Engrava as a memory server for your agent? The MCP server ships as its own package, engrava-mcp — a native stdio server (no HTTP shim) with read tools, optional write tools, attachable engrava:// resources, and guided prompts, for any MCP client (Claude Desktop, Claude Code, Cursor, Windsurf, VS Code):

uvx engrava-mcp        # or: pip install engrava-mcp

engrava-mcp pulls engrava in transitively, so installing it also gives you the import engrava library. See the engrava-mcp package for install, client configuration, the full tool/resource/prompt reference, and read-only mode.

uvx vs. a persistent install. uvx engrava-mcp (equivalently uv tool run engrava-mcp) installs into "an ephemeral virtual environment in the uv cache directory" per uv's own --help text — fine for the lexical/network profiles above, which add nothing heavier than httpx. Once you are on the local profile (engrava-mcp[local], the same sentence-transformers + torch download as engrava[embeddings-local]), uv tool install 'engrava-mcp[local]' is the better fit: it installs once into a persistent environment — the same uvx engrava-mcp invocation then reuses that installed environment instead of resolving a fresh ephemeral one — and a later uv tool upgrade engrava-mcp re-pays only the changed packages, not the whole dependency tree.

CLI

engrava --db mydata.db info          # Database stats
engrava --db mydata.db query "FIND thoughts WHERE thought_type = 'OBSERVATION' LIMIT 5"
engrava --db mydata.db snapshot -o backup.jsonl
engrava --db mydata.db restore -i backup.jsonl
engrava --db mydata.db gc            # Collect ARCHIVED thoughts + their edges/embeddings/actions
engrava --db mydata.db migrate       # Ensure schema is up-to-date
engrava --db mydata.db export -o portable.json

gc physically deletes ARCHIVED thoughts together with every edge touching one on either end — including edges whose other end is still live — their embeddings and the actions sourced from them, then reconciles the vector index by removing every vec0 row no embedding row owns; on a vec0-indexed store where sqlite-vec cannot be loaded — most commonly because engrava[vec] is not installed — a pass that is about to delete stops before deleting anything and exits 1 rather than stranding those vectors in an index nothing can then reach them through.

engrava info's --format json output carries every field of the metrics snapshot exposed by await store.metrics(), with the snapshot's own schema version renamed metrics_schema_version, plus two fields the snapshot itself does not carry, db_path and database_schema_version — see Upgrade Guide. The default text output is a shorter summary of that same data, not the full snapshot.

See the CLI reference for every command and option.

Architecture

  • SQLite with WAL mode for concurrent reads

  • Frozen Pydantic models — immutable domain objects

  • Async-first — all I/O via aiosqlite

  • Hook-based extension — zero monkey-patching

  • Template method pattern — subclass SqliteEngravaCore for extended schemas

  • Zero external services — everything runs locally in-process

Documentation

  • Documentation Index — choose a path by task: learn, build, operate, extend, or migrate

  • Core Concepts — the mental model (thought, edge, reflection, cycle, …) — start here

  • The Bi-temporal Model — the optional valid-time axis: query a fact as of any instant, invalidate without deleting

  • Positioning — when Engrava is (and isn't) the right tool, and how it compares

  • Quick Start — 5-minute setup guide

  • Tutorial — build a small notes memory end to end

  • Recipes — copy-paste snippets for common tasks (store a turn, retrieve context, TTL, dedup, …)

  • Building a memory-backed agent — the end-to-end agent turn loop (ingest → retrieve → generate → consolidate)

  • Migrating from another memory system — concept mapping, porting calls, bulk import, and scoping/multi-tenancy

  • Embeddings — wiring a real embedding provider (local / OpenAI / Ollama / HuggingFace / custom)

  • MCP server (engrava-mcp) — expose a store to MCP clients (Claude Desktop, Claude Code, Cursor, Windsurf, VS Code) via the standalone server package: install, run, client config, tools/resources/prompts

  • Configuration — YAML config format and options

  • Upgrade Guide — compatibility matrix, backups, and troubleshooting

  • Extensions — Writing custom extensions and hooks

  • Observability — Metrics snapshot API

  • Audit Trail — Tamper-evident hash-chain journal (enabling, querying, verifying, security model)

  • Evidence and Conflicts — model claims, provenance, contested facts, and caller-owned clarification

  • Security — trust boundaries, data egress, tenant isolation, encryption posture, and journal limits

  • Error Handling and Recovery — decide when to retry, repair input, or replace a store

  • Benchmarks — release gates, versioned observed values, and LongMemEval

  • API Reference — Full protocol and class reference

  • CLI Reference — every engrava command and option

  • Glossary — quick definitions of every Engrava term

  • MindQL — Query language syntax and examples

  • Troubleshooting — symptom → cause → fix for common errors

  • FAQ — quick answers (LLM/keys, embeddings-optional, scale, concurrency, backups, …)

  • Performance & Scaling — the vector-backend switch, bulk-ingest, and dreaming cost at scale

  • Data Lifecycle & Retention — lifecycle states, TTL, archive-vs-delete, GDPR erasure, disk reclamation

  • Deployment — process model, database files on disk, containers, graceful shutdown

  • Concurrency — the WAL single-writer model, busy timeout, and per-service isolation

  • Backup & Recovery — WAL-safe backups, snapshot vs file copy, restore verification

  • Known Limitations — Platform notes and constraints

Development

make install   # deps + dev extras + local git hooks -- from the primary checkout
make gate      # fast: hooks-active + lint + format check + type check + goldens drift, ~70s
make check     # lint + format check + type check + the full test suite with coverage (unchanged; no hooks-active, no goldens drift)

make install also wires a commit-msg hook that lints the message of the commit a squash merge creates — including on a local branch that never opens a pull request, where nothing else does. It refuses to install from a linked worktree (core.hooksPath is shared across all of them). See CONTRIBUTING.md for what it covers and what it does not.

License

MIT — see LICENSE for details.

docs/search.md

engrava's search_hybrid() combines up to five scoring signals into a single ranked result list.

Signal Model

#SignalWeight keyDefaultSource
1FTS5 keyworddefault_fts_weight0.30BM25 full-text score (min-max normalized)
2Vector similaritydefault_vector_weight0.55Cosine similarity from embedding search
3Recencydefault_recency_weight0.10Exponential decay along one recency axis — cognitive-cycle (current_cycle) or transaction-time (recency_now); see Two recency axes
4Prioritydefault_priority_weight0.05Boost multiplier per priority level (P1–P4)
5Graphdefault_graph_weight0.001-hop-weighted neighbour boost (opt-in)

Default weights sum to 1.0. When a signal is unavailable (e.g. neither recency reference nor a cycle provider can resolve recency, or no embeddings can resolve the vector arm), its weight is redistributed proportionally across active signals.

The table's defaults apply on every construction path. SqliteEngravaCore(conn, ...) with no search_config argument resolves them exactly like a store built with a default SearchConfig() or from_config with no override — one source, SearchConfig's own field defaults, so an omitted search: section and search: {} resolve identically too. An explicit per-call weight, or a SearchConfig you built with a different value, still wins over the table.

Graceful Degradation

  • FTS5 unavailable or empty query → FTS skipped.

  • query_vector is None and no embedding provider → vector skipped.

  • No explicit current_cycle, no configured cycle provider, and no recency_now → recency skipped.

  • priority_weight is 0.0 → priority skipped.

  • graph_weight is 0.0 → the graph ranking signal is skipped and costs nothing. (Reflection-source candidate expansion is a separate step; see below.)

  • All signals disabled → a dedicated query-less window of top_k rows (not a list_thoughts call), pre-ordered updated_cycle DESC so the truncation keeps the freshest rows. With no cycle reference, every row scores a flat 0.0. A reference supplied at recency_weight=0.0 — current_cycle or recency_now — is accepted but inert here too: the weight gates recency on this query-less path the same way it does everywhere else, so the row still scores a flat 0.0 rather than a decayed one. If another signal is active on this same path (priority_weight defaults on), zeroing recency_weight can change which row ranks first, not just shrink the scores, because the now-flat recency term stops competing with it. The compiled filters= / visibility= predicate is applied in-query and archived thoughts stay excluded unless include_archived is set, so this path enforces the same eligibility as the FTS and vector arms.

  • That same query-less window serves whenever both the FTS and vector arms are out, even if recency survives — recency stays active only with a reference and a recency weight above 0.0. The window is then ordered and scored along the active recency axis: COALESCE(updated_at, created_at) DESC with transaction-time decay when the transaction axis is active, updated_cycle DESC with cognitive-cycle decay otherwise (the two axes are mutually exclusive).

  • A fallback result is still a result: collapse_key / collapse_max_per_unit and reflection_topk_cap apply to this query-less window exactly as they do to an ordinary FTS/vector-active search — a fragmented unit is still collapsed to its best-ranked row(s), and REFLECTIONs are still capped at top_k * reflection_topk_cap, with the same off-list-candidate backfill. When collapse_key is set (or the reflection cap is below 1.0, which the default 0.3 is), the fallback also widens its own row window by search.collapse_pool_factor beyond top_k, the same bounded headroom collapse and the cap already get from the FTS/vector arms' fts_top_k / vector_top_k pools — each is a minimum per-arm pool, raised to top_k when smaller, before that widening — so backfill has distinct candidates to draw from. See the "De-fragmentation / collapse" and reflection_topk_cap sections below.

The vector arm distinguishes two bad-query-vector cases:

  • A degenerate query_vector (empty, all-zero, or non-finite) → the vector arm returns nothing and the read-only vector_arm_degradation_count counter is incremented (a bad query embedding, not an empty corpus). It does not raise.

  • A wrong-dimension query_vector (its length differs from the store's embedding dimension) is a caller-contract violation and raises VectorDimensionMismatchError — it is not silently degraded. See Observability signals and Known Limitations.

Both health counters are read-only, monotonically increasing properties on one store instance and reset when a new store is constructed:

print(store.fts_match_failure_count)
print(store.vector_arm_degradation_count)

fts_match_failure_count increments before the one safe fallback retry whenever the primary normalized FTS5 MATCH expression fails. The fallback may still return valid matches; a non-zero count therefore means recovery occurred, not that the FTS arm was necessarily lost. vector_arm_degradation_count increments only for degenerate vectors. A dimension mismatch is not counted because it is raised as a typed contract error.

v0.6.0 compatibility note. A wrong-dimension query vector is rejected with VectorDimensionMismatchError instead of being confused with an empty neighbourhood or leaking a backend-specific shape error. Callers that accept externally supplied vectors should catch this error (or EngravaError) and correct the embedding/model configuration; retrying the same vector cannot produce a valid result.

Archived thoughts are excluded by default

Every ranked read — search_hybrid(), recall(), search_fts(), and search_similar() — drops archived thoughts (lifecycle_status = ARCHIVED) from its default candidate set, the same eligibility class as expired rows and retired reflections. This is the retrieval side of Forgetting — an opt-in, off-by-default hygiene loop — but the exclusion applies to any archived row, whether or not that loop is enabled: an archived (forgotten) thought stops surfacing without being deleted.

The exclusion is reversible:

  • store.restore_thought(thought_id) flips the row back to ACTIVE, so it is eligible again; and

  • passing include_archived=True to any of the four methods re-admits archived rows for that one call, without restoring them.

Behaviour change. Marking a thought ARCHIVED — including via the TTL archive strategy — now removes it from default retrieval; previously an archived thought still surfaced in search. It is still counted by count_thoughts() / list_thoughts() (those are not ranked retrieval), provided it is not also expired — both methods default to excluding expired rows regardless of lifecycle status, so an archived-and-expired row needs include_expired=True too. To exclude an archived row deliberately, filter on lifecycle_status yourself. The retired-reflection freshness floor is independent — a retired REFLECTION stays excluded even under include_archived=True. See Data lifecycle and Known Limitations.

Two recency axes

Recency ranks along two separately-typed axes, and a query picks exactly one. Passing neither explicit reference uses a configured cycle provider when present; without one, recency remains off:

AxisReferenceAges a row byHalf-life unitFor
Cognitive-cyclecurrent_cycleupdated_cycle vs the cyclecycles (recency_half_life)agents that own and advance a logical cycle
Transaction-timerecency_nowupdated_at (→ created_at) vs the instantseconds (recency_now_half_life)"recently stored" — wall-time recency on any store

Both use the same exponential half-life decay; only the clock differs. The transaction-time axis makes "recently written" rankable even on a store where every row shares one cognitive cycle (the common case for externally-written memories) — the signal cycle-recency cannot express there.

recency_now is a caller-supplied ISO-8601 instant: engrava's core reads no wall clock in ranking, so retrieval stays deterministic and replayable (same store + same recency_now → same ranking). A naive value is interpreted as UTC (the host timezone is never consulted); a malformed recency_now (or a non-positive recency_now_half_life) raises InvalidRecencyArgumentError. A row whose updated_at/created_at is missing or malformed is treated as maximally old (the minimum recency score). An explicit recency_now takes precedence over a passive cycle_provider (the provider is not consulted when recency_now is supplied with no explicit current_cycle); supplying both explicit references — current_cycle and recency_now — raises RecencyModeConflictError.

# Transaction-time recency: rank by how recently each memory was written,
# relative to a caller-supplied "now" (a 24-hour freshness half-life).
from datetime import UTC, datetime

results = await store.search_hybrid(
    "incident timeline",
    recency_now=datetime.now(UTC).isoformat(),  # the caller owns "now"
    recency_weight=0.4,
    recency_now_half_life=86400,  # seconds (1 day); default 604800 (7 days)
)

The units never mix: a wall-clock age is never subtracted from a cognitive cycle. To use transaction-time recency on a store that has a cycle provider configured, pass recency_now while omitting an explicit current_cycle — the provider is passive and is not consulted, so the query ranks by transaction time. (Omitting recency_now instead selects the provider's cognitive-cycle recency.) You only hit RecencyModeConflictError if you explicitly pass both current_cycle and recency_now.

Keyword query syntax (FTS)

The keyword signal — and the search_fts() method that exposes it directly — runs your text against an SQLite FTS5 index. engrava normalises the query before handing it to FTS5, with two modes that switch automatically on what you type:

Bare queries are matched with OR. A plain natural-language query like what was my sister doing is treated as a bag of words joined with OR, so a document matches when it shares any word. BM25's IDF weighting then ranks the documents that share the most distinctive words first, so common function words (what, was, my) carry little weight and need no stopword list or stemmer — this works in any language. (Before this, the words were joined with FTS5's implicit AND, so a question only matched documents containing every word and relevant answers were missed.)

# Bare query -> OR-matched: finds docs sharing any content word, best-ranked first
hits = await store.search_fts("what was my sister doing", top_k=10)

Expert syntax is preserved unchanged. If your query uses FTS5 operators, it is passed through as written:

  • quoted phrases — "machine learning" matches the exact phrase;

  • uppercase booleans — AND, OR, NOT (must be uppercase) compose terms, e.g. python AND NOT snake;

  • prefix — a trailing * does prefix matching, e.g. neur*;

  • column filters — essence: and content: restrict a term to that column, e.g. content:berlin.

Punctuation never raises. Unsafe characters split a token into separate terms rather than breaking the query: a contraction like sister's becomes sister OR s, so it still matches a stored sister's dog. Pasting a URL or a timestamp is safe too — only the real essence: / content: column filters are honoured, so http://example.com and 12:30 are treated as ordinary search terms (they do not become spurious http: / 12: column filters). When a normalized full-text expression is a genuinely malformed FTS5 query, engrava logs a warning, increments the read-only fts_match_failure_count counter, and retries once through the bare normalization (unsafe characters dropped, wildcards collapsed to legal prefixes, any exposed AND/OR/NOT phrase-quoted so FTS5 cannot read it as an operator), which is always a valid MATCH; the FTS arm returns that query's matches (an empty set when the sanitized query matches nothing). See Observability signals.

Graph-Aware Ranking

The graph signal uses 1-hop-weighted neighbour boost. If a candidate thought's graph neighbours also match the query, the candidate receives a boost proportional to the neighbour's semantic score and the connecting edge weight.

Algorithm
candidate_scores = { C: max(fts_score[C], vector_score[C]) for C in fusion pool }  # pool members only
For each candidate C in the fusion pool:
  neighbours = get_edges(C, direction="BOTH", limit=max_neighbors)
               ordered by edge.weight DESC (deterministic)
  For each (edge, neighbour):
    neighbour_base = candidate_scores.get(neighbour, 0.0)  # 0 if neighbour is off-pool
    boost[C] += edge.weight × neighbour_base × graph_edge_decay
final_score[C] += graph_weight × boost[C]

A neighbour outside the fusion pool contributes 0, not its own fts/vector score — only pool members' base scores propagate (see Graph-aware search).

Key properties:

  • Only semantic scores propagate — priority, recency, and graph scores are excluded from neighbour_base to prevent hub-cascade effects.

  • No new candidates — graph signal re-ranks existing results, it does not add thoughts to the result set.

  • Deterministic — neighbours are sorted by edge.weight DESC before the max_neighbors cap is applied.

Configuration
search:
  default_graph_weight: 0.0          # opt-in (0.0 = disabled)
  graph_edge_decay: 0.5              # decay factor for 1-hop distance
  max_neighbors_per_candidate: 5     # safety cap

Per-query override:

result = await store.search_hybrid(
    "python async",
    graph_weight=0.1,
    graph_edge_decay=0.3,
)
Performance

When graph_weight=0.0 (default), the graph ranking signal is off: its 1-hop neighbour query is not executed and it adds no ranking cost. This does not switch off reflection-source candidate expansion, which is controlled separately by graph_expansion_enabled (default true) and reads CONSOLIDATED_FROM edges whenever a REFLECTION ranks among the top candidates. When the ranking signal is active, the implementation batches the candidate pool into fixed-size ID chunks (one query per chunk, needed to stay under SQLite's bound-parameter ceiling once the pool is large) and each chunk's query itself is bounded at the SQL layer to at most max_neighbors_per_candidate rows per candidate — via a ranking window over that candidate's edges, not a limit applied in Python after fetching everything — so the total rows returned across all chunks is O(top_k × max_neighbors_per_candidate), not the size of the full adjacency.

Observability

When the graph signal contributes to at least one candidate, "graph" appears in HybridSearchResult.backends_used.

Reflection-source candidate expansion

Graph expansion is separate from the weighted graph ranking signal above. It is a bounded candidate-generation step over dreaming lineage, not a sixth fusion signal:

  1. After the available fusion signals have scored the current pool, engrava selects up to graph_expansion_top_n top-ranked REFLECTION candidates.

  2. It follows each reflection's outgoing CONSOLIDATED_FROM edges, ordered by edge weight, and admits only source thoughts of type OBSERVATION.

  3. Each admitted source receives parent_score × graph_expansion_propagation_factor × edge.weight; when the source is already present, the higher of its existing and propagated score wins.

Unlike graph-aware ranking, expansion can add new candidates and is not controlled by graph_weight. It is enabled by default even though the weighted graph signal defaults to 0.0. Expansion re-applies expiry, archive, metadata, and visibility eligibility, so it cannot bring back a source excluded by the active query (unless include_archived=True explicitly re-admits archived rows). Non-observation targets are ignored. Expansion runs before the final reflection filter, so eligible source observations may still be admitted when include_reflections=False; only the reflection rows are removed.

The store-level controls are:

search:
  graph_expansion_enabled: true
  graph_expansion_top_n: 5
  graph_expansion_propagation_factor: 0.7
  graph_expansion_max_sources_per_reflection: 20
  graph_expansion_reflection_source_ceiling: 50

The per-reflection source cap keeps traversal bounded. A reflection with more than graph_expansion_reflection_source_ceiling lineage sources is skipped entirely, preventing a very large cluster from flooding the candidate pool. These settings belong to SearchConfig; search_hybrid() has no per-query expansion override. When expansion adds or improves at least one candidate, "graph_expansion" appears in HybridSearchResult.backends_used.

Per-Query Overrides

All weights can be overridden per call via keyword arguments:

result = await store.search_hybrid(
    "quantum computing",
    query_vector=embedding,
    fts_weight=0.4,
    vector_weight=0.4,
    recency_weight=0.1,
    priority_weight=0.05,
    graph_weight=0.05,
    current_cycle=42,
)

Scoped retrieval

search_hybrid() and recall() accept two optional, keyword-only filter arguments that scope the ranked query to rows whose metadata satisfies a typed predicate. Both default to None (no filtering, the candidate set is unchanged).

from engrava import FieldOp, FieldPredicate, MetadataFilter, VisibilityQueryFilter

# Equality / membership over your metadata keys (an AND of predicates):
result = await store.search_hybrid(
    "deployment runbook",
    top_k=10,
    filters=MetadataFilter(
        [
            FieldPredicate("$.project", FieldOp.EQ, "atlas"),
            FieldPredicate("$.env", FieldOp.IN, ["staging", "prod"]),
        ]
    ),
)

# "Public, or mine" — admit public rows plus rows this user owns:
result = await store.recall(
    "deployment runbook",
    top_k=10,
    visibility=VisibilityQueryFilter(allowed={"public"}, owner="u1"),
)

Where the filter is applied. The predicate runs inside each search arm, before that arm's limit — so a narrow filter still returns up to top_k matching rows and is never starved by out-of-filter candidates consuming the ranking budget. This is the key advantage over over-fetching and post-filtering (which can starve top_k) or a raw json_extract pre-filter (which loses the hybrid ranking).

Semantics:

  • filters is a MetadataFilter — an AND of FieldPredicate(path, op, value) over JSON paths ($, $.key, $.key[0]). Operators are EQ and IN.

  • EQ None matches both a missing path and an explicit JSON null. An empty IN matches nothing. An empty MetadataFilter is a match-all no-op.

  • Values compare under SQLite value equality: booleans are stored as integers, so EQ True matches a stored 1 (and EQ False matches 0).

  • visibility is a VisibilityQueryFilter(allowed, owner) reading $.visibility and $.owner. It composes with filters by AND; its OR is bounded and parenthesised, so an owner match can never escape the metadata AND.

  • Filter objects validate at construction (bad path, unsupported operator, out-of-range integer, non-finite float, an all-empty VisibilityQueryFilter) and raise InvalidFilterError / InvalidFilterPathError — never mid-query.

A filter is a query refinement, not an isolation boundary. It narrows what a ranked query considers; it performs no authentication, authorization, ownership validation, or write enforcement. The visibility filter reads only the metadata your application wrote, and a caller can omit it or supply any owner. Do not use it to keep one tenant's data away from another — for tenant isolation give each tenant its own store via EngravaManager (see migrating-from-other-memory.md).

Determinism. Results are ordered by combined score descending, with ties broken by canonical thought_id ascending — a stable total order independent of the underlying scan order. (This tie-break is always applied, with or without a filter.)

Note on BM25 scores. The keyword arm's BM25 ranking uses store-global corpus statistics. A filter changes which rows are eligible, but does not recompute term frequencies over the filtered subset — relative scores are still computed against the whole corpus. This is the intended behaviour for a query refinement within one memory.

De-fragmentation / collapse

search_hybrid() and recall() also accept an optional, keyword-only collapse_key that de-duplicates fragments of the same logical unit in the result. When many rows in your store describe the same thing — for example several chunks of one conversation turn, or several versions of one document — a plain ranked query can fill the top-k window with near-duplicates of one unit and push other, distinct units out. collapse_key keeps only the single best-ranked row per unit and lets deeper distinct units take the freed slots.

# One best row per (caller-defined) unit; deeper distinct units backfill in.
result = await store.recall(
    "what did we decide about the rollout?",
    top_k=10,
    collapse_key="$.turn_id",            # a single metadata path
)

# Composite unit key — rows share a unit only if ALL components are equal:
result = await store.search_hybrid(
    "rollout decision",
    top_k=10,
    collapse_key=["$.session_id", "$.turn_index"],
)

What it does. Among the already-ranked candidates, the highest-ranked row of each unit is kept; lower-ranked rows of that same unit are removed, and the slots they vacate are backfilled by the next distinct units. So the prompt sees one best row per unit plus more distinct units, instead of a pile of fragments of one unit. collapse_key=None (the default) leaves the result exactly unchanged.

Semantics:

  • collapse_key is a single JSON path ("$.turn_id") or an ordered sequence of paths forming a composite key (["$.session_id", "$.turn_index"]). Paths use the same grammar as filters ($, $.key, $.key[0]) and are validated at call time — a malformed path raises InvalidFilterPathError, never mid-query.

  • A row whose key is missing, holds malformed metadata, or (for a composite key) is missing any component is treated as its own unit — it is never collapsed with another row. Distinct key-less rows all survive.

  • The kept row per unit is the highest-ranked one under the same deterministic order used everywhere on this path (combined score descending, then canonical thought_id ascending), so the keeper and final order are stable across runs.

  • To give backfill a deeper pool to draw from, each search arm's candidate budget is widened by a small, bounded factor only while collapse_key is set (configurable as search.collapse_pool_factor, default 4); the collapse_key=None path is never widened. Because the keyword arm's scores are min-max normalized over the candidate set, this wider pool can rescale the normalized fusion scores and shift the relative order among units — so setting collapse_key is not score-neutral even though the collapse step itself never mutates a score. Only collapse_key=None is byte-identical to a plain query.

Keeping more than one row per unit (collapse_max_per_unit). By default collapse keeps a single best row per unit. When a unit is genuinely a long, multi-part thing — say one long turn split into several chunks whose useful detail is spread across more than the top chunk — dropping every chunk but the best one can discard the part you actually need. The optional, keyword-only collapse_max_per_unit sets how many of a unit's highest-ranked rows may reach the result, so a long unit can keep its deeper rows while the remaining slots still backfill deeper distinct units:

# Keep up to 2 rows of each unit; distinct deeper units still backfill.
result = await store.search_hybrid(
    "rollout decision",
    top_k=20,
    collapse_key="$.turn_id",
    collapse_max_per_unit=2,
)
  • None (the default) keeps exactly one best row per unit — identical to passing only collapse_key. An integer >= 1 keeps up to that many of a unit's highest-ranked rows; 1 is the single-best-row default.

  • It only takes effect together with collapse_key (there is no unit to retain by otherwise), and is validated at call time — a value below 1 raises InvalidFilterError.

  • It only relaxes the per-unit retention count. It never adds a row a search arm did not produce, never mutates a score, and never merges or drops a distinct unit as a side effect; the usual top_k truncation still applies, so distinct units beyond top_k are dropped exactly as they are without it. Key-less rows are unaffected — each is already its own unit.

Collapse is a presentation convenience, not a filter and not an isolation boundary. It does not change which rows are eligible (use filters / visibility for that). The collapse step itself mutates no score — it only decides which of the already-eligible rows is shown per unit, dropping lower-ranked members of the same unit. (Setting collapse_key does widen the internal candidate pool, which can affect normalized fusion scores and order, as noted above.) It is only as meaningful as the unit metadata your application writes: if two genuinely different rows carry the same key they will be treated as one unit, and rows without the key are never merged. Eligibility (filters / visibility) always applies first, then collapse.

Whole-turn assembly (a caller-side recipe)

Collapse and collapse_max_per_unit decide which rows reach your prompt. If you also want the reader to see a unit's contiguous text — a whole conversation turn in order, not only the chunks that happened to rank — assemble it yourself from the results, using only the public API. engrava stores no intrinsic chunk-sequence, so this works exactly to the extent your ingest wrote (a) a stable unit key and (b) an orderable ordinal (e.g. a chunk_index) on each chunk.

from engrava.domain.models.filters import FieldOp, FieldPredicate, MetadataFilter

# 1. Retrieve as usual — collapse dedupes fragments; distinct units backfill.
result = await store.search_hybrid(
    "what did the assistant explain about retries?",
    top_k=20,
    collapse_key=["$.session_id", "$.turn_index"],
    collapse_max_per_unit=2,
)

# 2. For a result you want in full, read its unit-key value from its own metadata,
#    gather the unit's chunks with a metadata-filtered search, then order them by
#    your ordinal and concatenate.
async def assemble_unit(query: str, thought_id: str) -> str:
    seed = await store.get_thought(thought_id)
    assert seed is not None
    unit = await store.recall(
        query,                       # any query — the filter selects the unit
        top_k=100,                   # generous, to cover the unit's chunk count
        filters=MetadataFilter([
            FieldPredicate("$.session_id", FieldOp.EQ, seed.metadata["session_id"]),
            FieldPredicate("$.turn_index", FieldOp.EQ, seed.metadata["turn_index"]),
        ]),
    )
    chunks = [await store.get_thought(tid) for tid, _score in unit.results]
    ordered = sorted((c for c in chunks if c), key=lambda t: t.metadata["chunk_index"])
    return "\n".join(t.content for t in ordered)
  • No new API, no result-shape change. This uses only get_thought and the metadata filters= you already have on search_hybrid()/recall() — the same surface you call to read a result's text. search_hybrid()'s result stays (thought_id, score) tuples.

  • The metadata predicate lives on the ranked search surface (search_hybrid/recall filters=), not on the public search_similar or search_fts methods or on list_thoughts (whose filters cover the built-in columns + provenance, not arbitrary metadata), so gathering a unit's chunks is a filtered hybrid search with a generous top_k; the ranking is irrelevant because you re-order by your own ordinal.

  • Precondition (yours to guarantee). Contiguous order is only as good as the metadata you wrote: without a stable, orderable ordinal on each chunk, siblings can be fetched but not meaningfully ordered — engrava has no chunk-sequence concept of its own. Write the ordinal at ingest alongside the unit key.

  • Where it runs. Entirely on the results engrava already returned — prompt and context assembly is your application's concern, not the store's.

Configuration Reference

See configuration.md for the full YAML reference of SearchConfig fields.

Querying reflections

After DreamingExtension.run_consolidation() runs its clustering phase, ThoughtType.REFLECTION meta-thoughts exist in the store. Three knobs control how hybrid search handles them.

include_reflections (default True)

When False, REFLECTION thoughts are excluded from search_hybrid() results. Useful when you want raw observations / insights without higher-order aggregates:

result = await store.search_hybrid(
    "machine learning",
    query_vector=embedding,
    include_reflections=False,
)
reflection_boost (default SearchConfig.reflection_boost = 1.0)

When REFLECTIONs are included, their final score is multiplied by this factor. The default 1.0 leaves REFLECTIONs competing on equal footing; raise it above 1.0 for a modest upranking so high-level abstractions surface for broad queries without dominating narrow ones.

# Stronger boost — reflections rank near the top for broad queries
result = await store.search_hybrid(
    "patterns in memory",
    query_vector=embedding,
    reflection_boost=1.5,
)

# Disable boost — reflections compete on equal footing
result = await store.search_hybrid(
    "specific fact",
    query_vector=embedding,
    reflection_boost=1.0,
)

Configure the default in YAML:

search:
  reflection_boost: 1.0   # applies when reflection_boost not overridden per-call
reflection_topk_cap (default 0.3)

Caps how much of the final top-K window REFLECTION thoughts may occupy. After all signals are applied, if reflections hold more than top_k * reflection_topk_cap slots, the lowest-scoring excess reflections are evicted and the freed slots are backfilled with the highest-scoring off-list non-reflection candidates. Set it to 1.0 to disable the cap.

When the cap actually evicts, the result carries a programmatic signal and the event is logged at INFO:

result = await store.search_hybrid("patterns in memory", query_vector=embedding, top_k=10)
if result.reflections_evicted:
    # e.g. cap=0.3 * top_k=10 → 3 reflection slots; extra reflections were evicted
    print(f"{result.reflections_evicted} reflection(s) evicted by reflection_topk_cap")

HybridSearchResult.reflections_evicted is 0 on every query where the cap did not evict (its default), so existing consumers are unaffected; a positive value means the cap reshaped the top-K window.

search_reflections_only()

Convenience helper that returns only REFLECTION thoughts, scored by cosine similarity to the query vector (plus optional recency blend). Designed for queries like "what themes exist in my memory?":

result = await store.search_reflections_only(
    "recurring ideas about learning",
    query_vector=embedding,
    top_k=5,
    current_cycle=42,   # optional recency blend
)
for thought_id, score in result.results:
    ref = await store.get_thought(thought_id)
    print(ref.content)  # JSON with member_ids + keywords

Key difference from search_hybrid(include_reflections=True): search_reflections_only() fetches all eligible REFLECTIONs directly from the store (no pagination gap) and scores them purely by cosine similarity to the query. It does not compete against regular thoughts for result slots. Only active, unexpired REFLECTIONs are eligible. Expiry is compared against one UTC instant captured for the call, and a row with expires_at <= now is excluded. Equal scores are ordered by thought_id for deterministic results.

402 files · 357 not served here

Advantages

Limitations

Security

Risk level: Low risk

This brain runs

Git hooks (1)

  • .githooks/commit-msg (commit-msg): runs on git commit-msg

Agent instructions that run or fetch something (4)

  • AGENTS.md: Run `git commit` and it fires on the patterns it checks for.
  • CLAUDE.md: pip install -e ".[dev]"
  • CLAUDE.md: Run a clean test suite locally before pushing;
  • CLAUDE.md: Branch from `dev`, follow Conventional Commits, run the local quality gate before pushing.

Install scripts (1)

  • Makefile (make install): echo "$(BLUE)>> Installing engrava + dev dependencies$(RESET)"; $(PIP) install --upgrade pip setuptools wheel; $(PIP) install -e ".[dev]"; bash scripts/install_hooks.sh; echo "$(GREEN)>> Done. Run 'make check' to verify quality gate.$(RESET)"
  • Low risk: scripts-present (.githooks/commit-msg)
  • Low risk: network-instructions (CLAUDE.md:40)
  • Low risk: network-instructions (pyproject.toml:98)
  • Low risk: scripts-present (scripts/install_hooks.sh)

Attribution and licence

Created by
Sovantica
Declared authors
  • Sovantica (declared in pyproject.toml)
Publisher
Sovantica (Official Sovantica project)
Source
sovantica/engrava · commit f6a8b73
Licence
MIT License (files served here)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by Sovantica.

Install from source

357 of its files are not served here, so there is no ZIP. sovantica/engrava at f6a8b73