Mijn agent bleef feiten kwijtraken die hij al had opgeslagen
AIdaemon draait volledig op mijn eigen machine, op open-source modellen zoals Gemma 4, zonder dat er iets naar een cloud API wordt aangeroepen. Het slaat feiten over mij op terwijl ik ermee praat, en zoekt ze vervolgens op wanneer ik ernaar vraag. Het opslaan werkte al. Het vinden ervan was wat me bleef tegenhouden. Ik vroeg naar iets waarvan ik wist dat het was opgeslagen en kreeg niets nuttigs terug, of twee feiten die over het juiste onderwerp gingen maar de kern misten.
De zoekopdracht draait op betekenis. Elk feit wordt een vector wanneer het wordt opgeslagen, mijn vraag wordt een vector wanneer ik vraag, en het rangschikt feiten op hoe dicht de twee bij elkaar liggen. Het probleem is dat dichtbij niet hetzelfde is als juist. Een vraag haalt naar boven wat zijn onderwerp deelt, dus een paar woordrijke feiten over hetzelfde ding verdringen het korte feit dat daadwerkelijk het antwoord bevat. Bij één query die steeds faalde, controleerde ik waar het antwoord zich bevond. Ongeveer op rang 30. De zoekopdracht las de eerste paar en stopte lang voordat het daar kwam.
Waarom de essentie niet genoeg is
Die eerste zoekopdracht gebruikt een bi-encoder, een klein model genaamd all-MiniLM-L6-v2. Het codeert elk feit afzonderlijk wanneer je het opslaat, en je query afzonderlijk wanneer je zoekt, en vergelijkt vervolgens de twee. Het ziet ze nooit naast elkaar. Dat is de hele reden dat het snel is. Je embedt alles één keer en een zoekopdracht is gewoon goedkope wiskunde over de opslag, geen modelaanroep wanneer je vraagt.
Het is ook waarom het de volgorde verkeerd krijgt. Een bi-encoder vangt het algemene onderwerp en worstelt met de precieze match. Vraag naar iets en het drijft alles in dezelfde buurt naar boven terwijl het feit dat je wilde zinkt. Het is goedkoop genoeg om op elk feit dat je hebt te draaien, en dat is precies waarom je ernaar grijpt als alles op je eigen hardware moet leven. Het is gewoon niet voorzichtig genoeg om ze goed te rangschikken. Meestal is dat goed genoeg. Als ik een specifiek antwoord nodig heb, valt het uit elkaar.
Een tweede model laten lezen
De oplossing is een reranker. Het is een cross-encoder, dus het leest mijn vraag en één kandidaatfeit samen, in dezelfde pass, en beoordeelt hoe goed dat feit de vraag beantwoordt. Waar de bi-encoder twee samenvattingen vergeleek die los van elkaar waren opgebouwd, leest deze de paren. Betere vraag, beter antwoord. Het nadeel is snelheid. Het is één modelrun per feit, dus je kunt het niet op een hele geheugenopslag richten en wachten.
De manier om dat te omzeilen is oud en saai en het werkt. Eerst ophalen, dan opnieuw rangschikken. Laat het goedkope model een brede stapel mogelijk relevante feiten pakken, en besteed dan het dure model alleen aan die korte stapel.
In AIdaemon haalt de bi-encoder de top 50 kandidaten op met een losse cutoff van 0.22. Ik houd die losser dan de 0.30 die ik gebruik voor geheugen dat op zichzelf in prompts wordt opgenomen, omdat synoniemen laag scoren en een strakke cutoff het juiste feit zou weggooien voordat de reranker het ooit zag. Fase één hoeft niet correct te zijn. Het hoeft alleen het antwoord ergens in de 50 te landen. Vervolgens leest de cross-encoder alle 50 opnieuw tegen de vraag en rangschikt ze opnieuw. Het feit dat op rang 30 bleef steken, wordt eindelijk op zichzelf gelezen, springt omhoog en komt terug. Ik heb niets veranderd aan hoe feiten worden opgeslagen, alleen hoe ze bij het verlaten worden geordend.
// fase 1: bi-encoder werpt een breed net uit (cosinus over alle actieve feiten)
let mut pool = cosine_rank(&query_vec, &facts, MIN_SCORE); // 0.22
pool.truncate(CANDIDATE_POOL); // 50
// fase 2: cross-encoder leest elk (query, feit) paar opnieuw en rangschikt opnieuw
let docs: Vec<String> = pool.iter().map(|c| c.text()).collect();
let ranked = reranker.rerank(&query, docs, false, None)?;
De reranker draait op dezelfde machine als al het andere, via de fastembed crate als een ONNX-model, zonder rerank API om naar uit te bellen. Ik gebruik Jina Reranker v2 Base Multilingual, en ik heb bewust meertalig gekozen, aangezien mijn notities aan AIdaemon wisselen tussen Engels en Spaans, en een puur Engelse reranker zou struikelen over precies de feiten waar ik het meest om geef om ze correct te krijgen.
Het goedkoop houden
Een tweede model is een tweede ding dat kan breken, dus het blijft aan een kort touwtje. Het laadt pas de eerste keer dat een zoekopdracht het nodig heeft, omdat de download niet klein is. Als het niet kan laden, valt de zoekopdracht terug op de gewone cosinusvolgorde die het eerder gebruikte. Niets breekt, het wordt gewoon minder scherp. En het draait alleen wanneer ik de agent expliciet vraag iets op te zoeken. Het geheugen dat op zichzelf in elke prompt wordt opgenomen, neemt nog steeds het goedkope pad. Het uitvoeren van 50 feiten door een cross-encoder is redelijk, één keer, wanneer ik een vraag stelde. Bij elk bericht zou het verspilling zijn.
Ophalen-dan-reranken is niet nieuw. Zoekmachines leunen er al jaren op. Het past gewoon goed bij agentgeheugen. Het eerste model pakt een brede stapel, het tweede model leest die stapel goed, en die tweede lezing kost bijna niets, vijftig korte strings door één model. Het is de reden dat AIdaemon me nu het feit teruggeeft dat het heeft opgeslagen in plaats van leeg te komen wanneer ik vraag.