Mon agent continuait de perdre les faits qu'il avait déjà enregistrés
AIdaemon fonctionne entièrement sur ma propre machine, avec des modèles open-source comme Gemma 4, sans aucune connexion à une API cloud. Il enregistre des faits à mon sujet pendant que je lui parle, puis les recherche lorsque je le lui demande. L'enregistrement fonctionnait déjà. La recherche était ce qui me faisait défaut. Je demandais quelque chose dont je savais qu'il était stocké et je n'obtenais rien d'utile, ou deux faits qui portaient sur le bon sujet mais manquaient l'essentiel.
La recherche s'effectue sur le sens. Chaque fait devient un vecteur lorsqu'il est enregistré, ma question devient un vecteur lorsque je la pose, et il classe les faits par proximité des deux vecteurs. Le problème est que la proximité n'est pas la même chose que la justesse. Une question fait remonter tout ce qui partage son sujet, de sorte que quelques faits verbeux sur la même chose évincent le fait court qui contient réellement la réponse. Lors d'une requête qui échouait constamment, j'ai vérifié où se trouvait la réponse. Environ au rang 30. La recherche a lu les quelques premiers et s'est arrêtée bien avant d'y arriver.
Pourquoi le résumé ne suffit pas
Cette première recherche utilise un bi-encodeur, un petit modèle appelé all-MiniLM-L6-v2. Il encode chaque fait individuellement lors de son enregistrement, et votre requête individuellement lors de la recherche, puis compare les deux. Il ne les voit jamais côte à côte. C'est toute la raison de sa rapidité. Vous intégrez tout une fois et une recherche n'est qu'un calcul simple sur le stockage, aucun appel de modèle lorsque vous demandez.
C'est aussi pourquoi il se trompe dans l'ordre. Un bi-encodeur saisit le sujet général et rate la correspondance précise. Demandez quelque chose et il fait remonter tout ce qui se trouve dans le même voisinage pendant que le fait que vous vouliez coule. Il est suffisamment peu coûteux pour fonctionner sur tous les faits que vous possédez, ce qui explique pourquoi vous y avez recours lorsque tout doit résider sur votre propre matériel. Il n'est tout simplement pas assez précis pour bien les classer. La plupart du temps, cela suffit. Quand j'ai besoin d'une réponse spécifique, cela s'effondre.
Laisser un second modèle lire réellement
La solution est un reranker. C'est un cross-encodeur, il lit ma question et un fait candidat ensemble, en une seule passe, et évalue à quel point ce fait répond à la question. Là où le bi-encodeur comparait deux résumés construits séparément, celui-ci lit la paire. Meilleure question, meilleure réponse. L'inconvénient est la vitesse. C'est une exécution de modèle par fait, vous ne pouvez donc pas le pointer sur toute une base de connaissances et attendre.
La solution est ancienne, ennuyeuse et elle fonctionne. Récupérer d'abord, réordonner ensuite. Laissez le modèle peu coûteux saisir un large éventail de faits potentiellement pertinents, puis utilisez le modèle coûteux uniquement sur ce petit ensemble.
Dans AIdaemon, le bi-encodeur récupère les 50 meilleurs candidats avec un seuil lâche de 0.22. Je le garde plus lâche que le 0.30 que j'utilise pour la mémoire qui est directement intégrée dans les prompts, car les synonymes obtiennent un score bas et un seuil strict écarterait le fait correct avant même que le reranker ne le voie. La première étape n'a pas besoin d'être parfaite. Elle doit juste placer la réponse quelque part dans les 50. Ensuite, le cross-encodeur relit les 50 contre la question et les réordonne. Le fait bloqué au rang 30 est finalement lu en ses propres termes, remonte et revient. Je n'ai rien changé à la façon dont les faits sont stockés, seulement à la façon dont ils sont ordonnés à la sortie.
// étape 1 : le bi-encodeur lance un large filet (cosinus sur tous les faits actifs)
let mut pool = cosine_rank(&query_vec, &facts, MIN_SCORE); // 0.22
pool.truncate(CANDIDATE_POOL); // 50
// étape 2 : le cross-encodeur relit chaque paire (requête, fait) et réordonne
let docs: Vec<String> = pool.iter().map(|c| c.text()).collect();
let ranked = reranker.rerank(&query, docs, false, None)?;
Le reranker s'exécute sur la même machine que tout le reste, via le crate fastembed en tant que modèle ONNX, sans API de rerank à appeler. J'utilise Jina Reranker v2 Base Multilingual, et j'ai choisi le multilingue à dessein, car mes notes à AIdaemon alternent entre l'anglais et l'espagnol, et un reranker uniquement en anglais trébucherait sur les faits mêmes que je tiens le plus à bien obtenir.
Garder les coûts bas
Un second modèle est une seconde chose qui peut tomber en panne, il reste donc sous surveillance étroite. Il ne se charge que la première fois qu'une recherche en a besoin, car le téléchargement n'est pas négligeable. S'il ne parvient pas à se charger, la recherche revient à l'ordre cosinus simple qu'elle utilisait auparavant. Rien ne casse, cela devient juste moins précis. Et il ne s'exécute que lorsque je demande explicitement à l'agent de rechercher quelque chose. La mémoire qui est intégrée à chaque prompt par elle-même continue d'emprunter le chemin le moins coûteux. Exécuter 50 faits via un cross-encodeur est raisonnable une fois, lorsque j'ai posé une question. À chaque message, ce serait du gaspillage.
Récupérer puis réordonner n'est pas nouveau. Les moteurs de recherche s'en servent depuis des années. Il s'avère que cela convient aussi bien à la mémoire d'un agent. Le premier modèle saisit un large éventail, le second lit correctement cet éventail, et cette seconde lecture coûte presque rien, cinquante courtes chaînes à travers un modèle. C'est la raison pour laquelle AIdaemon me renvoie maintenant le fait qu'il a enregistré au lieu de revenir les mains vides lorsque je pose une question.