Troubleshooting

Common symptoms, their cause, and the fix. Each entry shows the error (or the surprising behaviour) you actually see, then what to change.

If your problem is a platform constraint rather than a mistake (macOS extension loading, the brute-force vector-search ceiling, FTS5 availability), see Known Limitations instead.

AttributeError: 'tuple' object has no attribute 'keys' on read

Symptom. Writes succeed, but the first get_thought / search call raises:

AttributeError: 'tuple' object has no attribute 'keys'

Cause. The aiosqlite connection has no row factory, so rows come back as plain tuples. Engrava maps rows to records by column name and needs aiosqlite.Row. The failure surfaces on read, not on connect or write, which makes it look unrelated to setup.

Fix. Set the row factory immediately after connecting:

import aiosqlite

conn = await aiosqlite.connect("engrava.db")
conn.row_factory = aiosqlite.Row  # required

SqliteEngravaCore.from_config(...) opens the connection for you and sets this correctly — the manual snippet above only applies when you construct the store from your own connection.

ValueError: '...' is not a valid ThoughtType (or Priority, EdgeType, …)

Symptom.

ValueError: 'INSIGHT' is not a valid ThoughtType

Cause. A string was passed that is not a member of the enum. The valid ThoughtType members are TASK, OBSERVATION, BELIEF, REFLECTION, OUTPUT_DRAFT, and NOTE — there is no INSIGHT. The same applies to Priority (P1–P4), EdgeType, LifecycleStatus, etc.

Fix. Use a real enum member, ideally the symbol rather than a string literal:

from engrava import ThoughtType

ThoughtType.BELIEF  # preferred
ThoughtType("BELIEF")  # also valid — must match a real member

See Core Concepts for the full taxonomy and when to use each type.

Search returns nothing (or fewer results than expected)

Symptom. search_hybrid / search_fts returns an empty or short result list even though matching thoughts exist.

Cause. A signal you assumed was active was silently skipped, so the query ran on fewer signals than you expected. Engrava skips a signal rather than erroring when its prerequisite is missing. Work through this checklist:

If… then…
No embedding_provider is configured and you pass no explicit query_vector the vector signal is skipped — only FTS/priority run. A purely semantic query with no shared keywords may find nothing. Pass an explicit query_vector to search_hybrid to search by vector without a provider.
No explicit current_cycle or recency_now, and no configured cycle_provider the recency signal is skipped because no recency reference is available.
recency_weight is 0.0 recency is disabled even when a cycle or transaction-time reference is available.
The query shares no FTS tokens with any thought FTS legitimately returns nothing — this is a real miss, not a bug. Note a bare query is OR-matched (any shared word hits), so this is rarer than it looks; if you instead get too many hits, you may want strict matching — see below.
You used lowercase and / or between words These are not FTS5 operators — they are matched as ordinary words (and OR-joined like any bare query). Booleans must be uppercase (AND, OR, NOT).

Inspect which signals actually ran via HybridSearchResult.backends_used:

result = await store.search_hybrid("python async", top_k=5, current_cycle=10)
print(sorted(result.backends_used))  # e.g. ['fts5', 'priority', 'recency']

If 'vector' is missing and you expected semantic matching, configure an embedding provider (see the Embeddings guide). If 'recency' is missing, use a positive recency_weight and provide exactly one recency mode: pass current_cycle, configure a cycle_provider, or pass recency_now for transaction-time recency.

Keyword search returns too many results (I wanted all words to match)

Symptom. A multi-word search_fts query returns documents that contain only some of the words, not all of them.

Cause. A bare keyword query is matched with OR, by design — a document matches when it shares any word, and BM25 ranks the ones sharing the most distinctive words first (see Keyword query syntax). This is what lets natural-language questions find relevant answers; it is not a bug.

Fix. When you genuinely need every term, use FTS5 expert syntax explicitly — uppercase AND between the words, or a quoted phrase for an exact sequence:

# require both words
await store.search_fts("python AND asyncio", top_k=10)
# require an exact phrase
await store.search_fts('"event loop"', top_k=10)

Lowercase and/or will not work — they are matched as ordinary words.

Symptom. You expect a query containing http://…, 12:30, or a Windows path to error or to be interpreted as an FTS column filter.

Cause / behaviour. It does neither. Only the real essence: and content: column filters are honoured; any other token:token (a URL scheme, a clock time) is split into ordinary search terms, so the query is safe to run. 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). It does not raise. No action needed — this is the intended robustness — though a rising fts_match_failure_count is how you see it happening. See Keyword query syntax (FTS) and Observability signals.

Keyword search returns a thought after restore that does not contain the word

Symptom. After restore (a plain merge or --clear), search_fts / recall returns a thought whose essence and content plainly do not contain the word you searched for.

Cause. restore writes each imported record with INSERT OR REPLACE — except during a journalled merge (no --clear, no --orphan-journal-entries, target journal non-empty), where it uses plain INSERT and refuses a collision instead (see Audit Trail). For the INSERT OR REPLACE paths: SQLite’s PRAGMA recursive_triggers defaults to off, and engrava never sets it, so when that statement resolves a primary-key or UNIQUE collision by deleting the existing row and re-inserting, SQLite’s internal delete never fires the FTS delete trigger — only the insert trigger for the replacement row fires. The old full-text index entry survives, keyed to a rowid the thought table can later hand to an unrelated row, at which point a keyword search for the original word resolves to that unrelated thought instead.

Fix. restore now checks for the full-text index inside its own transaction and, when it exists, rebuilds it after every merge or --clear, so a restore on a build carrying this fix does not leave a stale entry in it.

Repair a database an already-restored older build left stale. Rebuild the index directly with the SQLite CLI — FTS5’s own rebuild command, using the table’s already-configured tokenizer:

sqlite3 engrava.db "INSERT INTO thought_fts(thought_fts) VALUES('rebuild');"

Dreaming promotes nothing (consolidation is inert)

Symptom. run_consolidation(...) returns promoted_count == 0 every time.

Cause. Promotion requires a candidate to clear several gates and then beat a score threshold; missing any one of them keeps the count at zero. The two most common causes:

  1. The age gate. A thought is eligible only when current_cycle - created_cycle >= min_age_cycles (default 1). If you never advance your cycle counter — every thought stays at the same current_cycle you created it in — 0 >= 1 is false and nothing is ever eligible. This is the most common cause. See Core Concepts → Cycle.
  2. The promotion threshold. Even after every gate passes, a candidate’s weighted signal score must exceed promote_threshold — the comparison is strict (score > promote_threshold), so a tie does not promote. Brand-new, unconfirmed, never-accessed thoughts score low, so a high threshold promotes nothing.

Dreaming also applies a confirmation gate, a metadata-eligibility filter, a thought-type filter, and population-level caps on top of these two — see Dreaming → Gates for the full model.

Fix.

from engrava.config import DreamingConfig, DreamingGates
from engrava.extensions.dreaming import DreamingExtension

config = DreamingConfig(
    enabled=True,
    promote_threshold=0.4,  # lower it if nothing clears the bar
    gates=DreamingGates(
        allow_zero_confirmation=True,  # essential for single-write ingest
        min_age_cycles=1,
    ),
)
ext = DreamingExtension(config=config)

# Advance current_cycle past the thoughts' created_cycle so the age gate passes:
result = await ext.run_consolidation(store, current_cycle=10)
print(result.promoted_count)

See Dreaming for the full gate-and-signal model.

Embedding ingest fails against an OpenAI-compatible endpoint

Symptom. Writes that auto-embed (or explicit embed calls) raise after a pause, either immediately or after a few seconds of retrying.

Cause / what to expect. OpenAICompatibleProvider retries a request with a bounded backoff that grows linearly with the attempt number (base_retry_delay_s * attempt) on a transient failure — a read timeout or network blip, or a transient HTTP status (408, 409, 425, 429, 500, 502, 503, 504). Two outcomes:

  • A transient failure that persists across every attempt is raised as a RuntimeError once max_attempts is exhausted (it never loops forever). If you see this under sustained 429s, you are being rate-limited — raise base_retry_delay_s, lower your ingest concurrency, or batch more slowly.
  • A non-transient status (400, 401, 403, 404) is surfaced immediately with no retry — it indicates a request/auth/model error, not a blip. Check your api_key, base_url, and model_name.

Fix. Tune the retry budget on the provider (max_attempts, default 3; base_retry_delay_s, default 1.0), or address the underlying cause above. Only OpenAICompatibleProvider retries — OllamaProvider / HuggingFaceProvider do not. See the Embeddings guide.

EmbeddingModelMismatchError when opening an existing database

Symptom. A store that worked before now raises EmbeddingModelMismatchError on startup or first embed.

Cause. Engrava records the embedding model name and dimension — and, for an instruction-tuned provider, the document_prefix fingerprint — in the database the first time it embeds. If you later open that same database with a different model name, a different dimension, or a changed document_prefix, the stored vectors are incompatible with new ones, so it refuses rather than silently mixing them (which would corrupt similarity results). See Asymmetric prefixes for the prefix case. Changing only the query prefix, not the corpus’s document_prefix, does not raise this error — that raises the separate EmbeddingQueryPrefixMismatchError at search time instead.

Fix. Use the same embedding model the database was created with, or restore a trusted snapshot with a configured provider and deliberately re-embed the corpus. Direct mode uses top-level embeddings:

engrava --db restored.db --config engrava.yaml restore \
  -i backup.jsonl --clear --re-embed

Service mode prefers services.configs.<name>.embeddings and falls back to the top-level provider:

engrava --config engrava.yaml restore \
  --service main -i backup.jsonl --clear --re-embed

--re-embed requires --config with a provider at one of those levels. If no provider is configured, import with --skip-embeddings or keep the source embeddings unchanged. If the target already contains embeddings, restore also requires --clear; this prevents old vectors from surviving under the new model/dimension/prefix identity.

This is a different mechanism from restore’s own guard against a snapshot whose embeddings disagree with what the target already has — that check runs against whatever the target holds at that point in the restore and reports a CLI error (not this exception). --clear above wipes the target’s data rows, but it preserves any stored embedding-model lock in _metadata: if one is present, it is still the identity reference incoming snapshot embeddings are checked against, so --clear alone does not by itself guarantee nothing is left to disagree with. Add --clear-identity alongside --clear to also reset that lock. See CLI reference.

See Known Limitations → Embedding Dimension Consistency.

ReferentialIntegrityError when creating an edge

Symptom. Creating an edge to a thought that doesn’t exist raises:

referential integrity violation: edge.to_thought_id='...' does not reference an existing thought

Cause. One endpoint of the edge (from_thought_id or to_thought_id) is not a real thought id. Create both thoughts before the edge that links them.

Fix. ReferentialIntegrityError is importable directly from engrava, the same as most other exceptions in the table on the API Reference:

from engrava import ReferentialIntegrityError

try:
    await store.create_edge(edge)
except ReferentialIntegrityError:
    ...  # one endpoint is missing — create the thought, then retry

Still stuck?

For the exhaustive package reference, see the OSS troubleshooting.md docs.