Skip to main content
Le Context Assembler (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 = active uniquement ;
  • scope exact (project_id, subject_id) ;
  • valid_to IS NULL OR valid_to > now() — encore valide métier ;
  • un fait listé dans un ConflictSet ouvert n’est jamais servi : il est bloqué avec reason_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).
Ces poids sont documentés dans le code mais ne font pas partie du contrat public — ils peuvent changer entre deux versions sans être considérés comme un breaking change d’API.

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é).
Compromis documenté : un fait qui n’est ni dans le top-K vectoriel ni dans le top-K plein-texte ne peut jamais être servi, même si la récence l’aurait fait remonter. Les faits au-delà de CANDIDATE_LIMIT ne sont pas non plus tracés.

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. Quand HAKI_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.
Le plancher porte uniquement sur l’axe sémantique (distance cosinus), jamais sur le score hybride : la similarité est le seul terme borné et calibrable par embedder. Le bon seuil dépend de HAKI_EMBED_PROVIDER — calibrer avec scripts/check_recall_floor.py avant d’activer dans un environnement ; ne jamais coder en dur une valeur mesurée pour un autre embedder.
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 = 2 entités, au plus MULTI_HOP_MAX_PER_ENTITY = 5 faits par entité, un seul saut, jamais récursif.
  • Marquée reason_code = multi_hop_expansion dans la trace.

Mémoire épisodique

Après les faits, les EPISODE_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

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