app/context/__init__.py) builds the
ContextPacket served by POST /v1/context: a hybrid retrieval over the
facts of one exact scope, assembled under a token budget, with a decision
trace persisted for every fact considered.
Hard filters, before any scoring
Applied before score computation, never after:status = activeonly;- exact
(project_id, subject_id)scope; valid_to IS NULL OR valid_to > now()— still business-valid;- a fact listed in an open
ConflictSetis never served: it is blocked withreason_code = conflict_open.
The hybrid score
pgvector
1 - cosine_distance(embedding, query_embedding), hnsw index.PostgreSQL
ts_rank_cd(search_vector, websearch_to_tsquery('simple', query)).
search_vector is a generated column (migration 0004): the
tsvector is built once at write time, never re-parsed per query.
websearch_to_tsquery accepts arbitrary user text (unlike
to_tsquery, which raises on a query without &/| operators).exponential
exp(-Δt / τ) with τ = 30 days,
Δt = now() - coalesce(valid_from, recorded_from).Two-phase retrieval
Scoring every active fact of a scope costs ~200 ms at 10,000 facts (measured, sprint 3) — too slow for the critical path. Retrieval therefore happens in two steps:1
Phase 1 — candidate generation via the indexes
Union of two indexed queries, each capped at
RETRIEVAL_TOP_K = 64 rows: the top-K by cosine distance (hnsw)
UNION the top-K by full-text rank (GIN).2
Phase 2 — full score on the union only
The full hybrid score is computed only on this union (≤ 128 rows),
then capped at
CANDIDATE_LIMIT = 256. Only the columns needed for
packing are selected: decoding the 384-dim embedding of every
returned row costs more than the scoring itself (measured).Token budget
budget_tokens (default 900, must be a positive integer — otherwise
budget_exceeded). Text is estimated as max(1, len(text) // 4). Facts
are packed by decreasing score until the budget is exhausted; the
rest is excluded with reason_code = over_budget. Every decision
(included / excluded / blocked) is written to context_traces.
Recall gate (M3) — the budget is a ceiling, not a target
By default the token budget is the only limit: facts are packed greedily until it fills up, whatever their actual relevance. WhenHAKI_RECALL_MAX_DISTANCE is set above 0 (disabled by default — exact
previous behavior), a candidate whose cosine distance to the query
exceeds it is excluded (reason_code = below_relevance_floor) before
packing, facts and episodes alike, however much budget remains.
A call fully emptied by the gate (candidates existed, none passed) returns
empty_reason: "no_relevant_memory" with status still "ok" — not a
failure, an honest “nothing relevant enough”. This is deliberately not
a warning: a warning would force status = "degraded", and the SDKs
already render an empty packet as an empty string (no <haki_memory>
block) — injecting a “no relevant memory” block would itself be a
distractor. Multi-hop rows are never gated: they exist precisely to
surface evidence that is semantically far from the original query.
GET /v1/stats/overview exposes injection_rate (canonical name for what
hit_rate has always measured: the share of context calls that served at
least one fact) — the metric to watch while calibrating the gate.
Multi-hop expansion (sprint 10)
After the main pack, if budget remains, a second full-text pass (no new embedding call) looks for facts linked by shared entities with the facts already kept — useful when two facts are related by a common name but not by semantic closeness to the original query.- Rule-based entity detection (no NER, no LLM): capitalized tokens
(regex
[A-ZÀ-Ý][a-zà-ÿ]{2,}), ranked by frequency, excluding common sentence-starter words (the,le,and,et…) and words already present in the query. - Bounded: at most
MULTI_HOP_MAX_ENTITIES = 2entities, at mostMULTI_HOP_MAX_PER_ENTITY = 5facts per entity, one hop only, never recursive. - Marked
reason_code = multi_hop_expansionin the trace.
Episodic memory
After the facts, theEPISODE_TOP_K = 8 closest source events
(cosine, events.embedding, hnsw) of the same scope are packed into
the same budget — facts first, episodes with what’s left. This is
what answers “what happened / when?” questions: the extractor keeps only
durable facts, episodes keep the dated events.
The ContextPacket
open_conflict: N fact(s) hidden pending conflict resolution warning
appears as soon as at least one fact is blocked by an open conflict; a
volatility_expired: N fact(s)... warning appears the same way when the
volatility filter hides stale facts (see
Facts and lifecycle). empty_reason is
"no_relevant_memory" only when the recall gate (above) emptied an
otherwise non-empty result.
API reference — Context
POST /v1/context and GET /v1/inspect/{trace_id}: exact schemas.
