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.
Pasting a URL, path, or timestamp into search
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:
- The age gate. A thought is eligible only when
current_cycle - created_cycle >= min_age_cycles(default1). If you never advance your cycle counter — every thought stays at the samecurrent_cycleyou created it in —0 >= 1is false and nothing is ever eligible. This is the most common cause. See Core Concepts → Cycle. - 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
RuntimeErroroncemax_attemptsis exhausted (it never loops forever). If you see this under sustained429s, you are being rate-limited — raisebase_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 yourapi_key,base_url, andmodel_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?
- Use Error handling and recovery to decide whether to retry, repair the input, or replace the store.
- Re-read the relevant guide: Core Concepts, Search, Embeddings, Dreaming.
- Check the FAQ for “is this supposed to work this way?” questions.
- Confirm it isn’t a documented constraint in Known Limitations.
- Open an issue with a minimal reproduction.
For the exhaustive package reference, see the OSS troubleshooting.md docs.