app/context/__init__.py) construit le
ContextPacket servi par POST /v1/context : un retrieval hybride sur
les faits d’un scope exact, assemblé sous budget de tokens, avec une trace
de décision persistée pour chaque fait considéré.
Filtres durs, avant tout score
Appliqués avant le calcul du score, jamais après :status = activeuniquement ;- scope exact
(project_id, subject_id); valid_to IS NULL OR valid_to > now()— encore valide métier ;- un fait listé dans un
ConflictSetouvert n’est jamais servi : il est bloqué avecreason_code = conflict_open.
Le score hybride
pgvector
1 - cosine_distance(embedding, query_embedding), index hnsw.PostgreSQL
ts_rank_cd(search_vector, websearch_to_tsquery('simple', query)).
search_vector est une colonne générée (migration 0004) : le
tsvector est construit une fois à l’écriture, jamais reparsé à chaque
requête. websearch_to_tsquery accepte du texte utilisateur arbitraire
(contrairement à to_tsquery, qui lève sur une requête sans opérateurs
&/|).exponentielle
exp(-Δt / τ) avec τ = 30 jours, Δt = now() - coalesce(valid_from, recorded_from).Retrieval en deux phases
Scorer tous les faits actifs d’un scope coûte ~200 ms à 10 000 faits (mesuré, sprint 3) — trop pour le chemin critique. Le retrieval se fait donc en deux temps :1
Phase 1 — génération de candidats via les index
Union de deux requêtes indexées, chacune limitée à
RETRIEVAL_TOP_K = 64 lignes : le top-K par distance cosinus (hnsw)
UNION le top-K par rang plein-texte (GIN).2
Phase 2 — score complet sur l'union seulement
Le score hybride complet n’est calculé que sur cette union (≤ 128
lignes), puis plafonné à
CANDIDATE_LIMIT = 256. Seules les colonnes
nécessaires au packaging sont sélectionnées : décoder l’embedding
384-dim de chaque ligne coûte plus cher que le scoring lui-même
(mesuré).Budget de tokens
budget_tokens (défaut 900, doit être un entier positif — sinon
budget_exceeded). L’estimation d’un texte : max(1, len(texte) // 4).
Les faits sont empaquetés par score décroissant jusqu’à épuisement du
budget ; le reste est exclu avec reason_code = over_budget. Chaque
décision (included / excluded / blocked) est écrite dans
context_traces.
Porte de rappel (M3) — le budget est un plafond, pas une cible
Par défaut, le budget de tokens est la seule limite : les faits sont empaquetés de façon gloutonne jusqu’à le remplir, quelle que soit leur pertinence réelle. QuandHAKI_RECALL_MAX_DISTANCE est fixé au-dessus de
0 (désactivée par défaut — comportement précédent exact), un candidat
dont la distance cosinus à la requête dépasse ce seuil est exclu
(reason_code = below_relevance_floor) avant l’empaquetage, faits et
épisodes confondus, quel que soit le budget restant.
Un appel entièrement vidé par la porte (des candidats existaient, aucun
n’a franchi) renvoie empty_reason: "no_relevant_memory" avec status
toujours "ok" — pas un échec, un honnête « rien d’assez pertinent ».
Ce n’est délibérément pas un warning : un warning forcerait
status = "degraded", et les SDK rendent déjà un paquet vide comme une
chaîne vide (aucun bloc <haki_memory>) — injecter un bloc « aucune
mémoire pertinente » serait lui-même un distracteur. Les lignes du
multi-hop ne sont jamais soumises à la porte : leur raison d’être est
justement de rapatrier des preuves sémantiquement éloignées de la requête
d’origine.
GET /v1/stats/overview expose injection_rate (nom canonique de ce que
hit_rate a toujours mesuré : la part des appels contexte ayant servi au
moins un fait) — la métrique à surveiller pendant la calibration.
Expansion multi-hop (sprint 10)
Après l’empaquetage principal, s’il reste du budget, une seconde passe plein-texte (sans nouvel appel d’embedding) cherche des faits liés par des entités partagées avec les faits déjà retenus — utile quand deux faits sont reliés par un nom commun mais pas par une proximité sémantique avec la requête d’origine.- Détection d’entités par règle (pas de NER, pas de LLM) : tokens
capitalisés (regex
[A-ZÀ-Ý][a-zà-ÿ]{2,}), classés par fréquence, en excluant les mots de début de phrase courants (the,le,and,et…) et les mots déjà présents dans la requête. - Bornée : au plus
MULTI_HOP_MAX_ENTITIES = 2entités, au plusMULTI_HOP_MAX_PER_ENTITY = 5faits par entité, un seul saut, jamais récursif. - Marquée
reason_code = multi_hop_expansiondans la trace.
Mémoire épisodique
Après les faits, lesEPISODE_TOP_K = 8 événements sources les plus
proches (cosinus sur events.embedding, hnsw) du même scope sont
empaquetés dans le même budget — faits d’abord, épisodes avec ce qui
reste. C’est ce qui répond aux questions « qu’est-ce qui s’est passé /
quand ? » : l’extracteur ne garde que les faits durables, les épisodes
gardent les événements datés.
Le ContextPacket
warning open_conflict: N fact(s) hidden pending conflict resolution
apparaît dès qu’au moins un fait est bloqué par un conflit ouvert ; un
warning volatility_expired: N fact(s)... apparaît de la même façon
quand le filtre de volatilité cache des faits périmés (voir
Faits et cycle de vie). empty_reason
vaut "no_relevant_memory" uniquement quand la porte de rappel
(ci-dessus) a vidé un résultat autrement non vide.
Référence API — Context
POST /v1/context et GET /v1/inspect/{trace_id} : schémas exacts.
