Skip to main content
The Context Assembler (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 = active only;
  • exact (project_id, subject_id) scope;
  • valid_to IS NULL OR valid_to > now() — still business-valid;
  • a fact listed in an open ConflictSet is never served: it is blocked with reason_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).
These weights are documented in the code but are not part of the public contract — they may change between versions without being treated as an API breaking change.

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).
Documented trade-off: a fact that is neither in the vector top-K nor in the full-text top-K can never be served, even if recency would have lifted it. Facts beyond CANDIDATE_LIMIT are not traced either.

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. When HAKI_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.
The floor is on the semantic axis only (cosine distance), never on the hybrid score: similarity is the only bounded, embedder-calibratable term. The right threshold depends on HAKI_EMBED_PROVIDER — calibrate with scripts/check_recall_floor.py before enabling in an environment; never hardcode a value measured for a different embedder.
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 = 2 entities, at most MULTI_HOP_MAX_PER_ENTITY = 5 facts per entity, one hop only, never recursive.
  • Marked reason_code = multi_hop_expansion in the trace.

Episodic memory

After the facts, the EPISODE_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

A 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.