Siirry sisältöön

Rakensin paikallisen RAG-haun. Oikea tiedosto ei vielä riittänyt

Mulla oli pitkään yksi ärsyttävä ongelma. Tieto oli kyllä tallessa, mutta sen löytäminen oli tuuripeliä.

Muistiinpanot olivat Obsidianissa. Osa tiedostoista tuli Google Drivesta ja Nextcloudista. Asiakas- ja artistitietoa oli omissa rakenteissaan. Kun halusin tarkistaa jonkin vanhan päätöksen, hinnan tai tapahtuman yksityiskohdan, tiesin usein että olin kirjoittanut siitä joskus. En vaan tiennyt minne.

Rakensin tätä varten paikallisen RAG-haun. Ajatus kuulosti suoraviivaiselta: indeksoi omat tiedot, hae semanttisesti ja anna paikallisen kielimallin vastata lähteiden perusteella.

Projektin idea lähti Cerebras Systemsin blogista. Cerebras on vuonna 2015 perustettu yhdysvaltalainen AI-laskentayhtiö, joka rakentaa wafer-scale-suorittimiin perustuvia järjestelmiä mallien koulutukseen ja inferenssiin.

Cerebrasin How We Built Our Knowledge Base kertoo heidän sisäisestä tietopankistaan, jolle tehdään yhtiön mukaan yli 15 000 kysymystä päivässä. Dataa kerätään Slackista, wikistä, koodista ja häiriöraporteista. Haussa yhdistetään tarkka sanahaku, embeddingit, termien harvinaisuus ja tiedon ikä. Jokainen hakutapa tuottaa oman listansa, listat yhdistetään RRF:llä ja parhaat ehdokkaat järjestetään vielä kielimallilla ennen lähteistettyä vastausta.

Nick Saraev käy saman rakenteen läpi videolla Cerebras Killed Notion, Obsidian, and Your "Second Brain". Hän käyttää Cerebrasin artikkelia lähtökohtana ja rakentaa coding agentilla ingest-putket Slackiin, sähköpostiin, GitHubiin ja YouTubeen. Videon omassa demossa knowledge basen kanssa saatiin 17 vastausta 20 kysymykseen, kun ilman sitä tulos oli nolla. Se on yhden demon tulos, ei riippumaton benchmark, mutta idea oli kiinnostava: tieto saa jäädä sinne missä se syntyy, kunhan sen ympärille rakentaa kunnollisen ingestin, haun ja lähteet.

Mun toteutus on paljon pienempi ja kokonaan paikallinen. Halusin nähdä, toimisiko sama periaate Obsidianin, Driven ja Nextcloudin päällä ilman että aineisto lähtee ulkopuoliselle mallipalvelulle.

Ensimmäinen toimiva versio näytti hyvältä. Sitten aloin mitata, löysikö se oikeasti vastauksen.

Oikea tiedosto ei vielä ole oikea vastaus

RAG-arvioinnissa on helppo mitata väärää asiaa. Jos haku palauttaa oikean tiedoston, tulos näyttää onnistuneelta. Pitkä dokumentti voi kuitenkin olla pilkottu kymmeniin chunkeihin. Oikea tiedosto löytyy, mutta vastausta kantava kohta jää hakutulosten ulkopuolelle.

Rakensin 21 oikeasta työtilanteesta koostuvan lukitun testisarjan. Jokaiselle kysymykselle määritettiin hyväksyttävä lähdepolku ja tarkat evidenssimarkerit, joiden piti löytyä palautetusta chunkista. Mittasin erikseen kahta asiaa:

  • löytyikö oikea tiedosto kolmen parhaan joukosta
  • löytyikö kolmen parhaan joukosta se chunk, joka oikeasti sisälsi vastauksen

Pelkkä vektorihaku sai Path Recall@3 -tulokseksi 0,76. Evidenssin Recall@3 jäi 0,71:een. Oikea tiedosto löytyi siis useammin kuin oikea kohta tiedoston sisältä.

Tää on aika iso ero, jos assistentin pitäisi vastata hinnasta, päivämäärästä tai sopimusehdosta. Malli ei voi päätellä puuttuvaa lausetta turvallisesti vain siksi, että tiedoston nimi näyttää oikealta.

Vektorihaku ja sanahaku eivät automaattisesti auta toisiaan

Yhdistin semanttisen vektorihaun SQLite FTS5 -sanahakuun. Vektorihaku löysi samaa tarkoittavia ilmaisuja. FTS5 löysi nimet, numerot ja tarkat termit. Listat yhdistettiin Reciprocal Rank Fusionilla, joka käyttää tuloksen sijoitusta eikä yritä verrata kahden eri hakutavan raakapisteitä.

Path Recall@3 nousi 0,76:sta 0,81:een. Samalla evidenssin Recall@3 laski 0,71:stä 0,67:ään.

Tulos ei ollut se mitä odotin, mutta se oli hyödyllinen. Hybridihaku toi lisää oikeita dokumentteja ehdokasjoukkoon, mutta niiden järjestys ei vielä suosinut vastausta kantavia kohtia. Lisää hakutekniikkaa ei tarkoittanut automaattisesti parempaa vastausta.

Lisäsin paikallisen Qwen-mallin järjestämään 12 parasta ehdokasta uudelleen kysymyksen perusteella. En antanut sille koko tietovarastoa enkä vapaita käsiä. Se sai lyhyet request-kohtaiset candidate-ID:t, rajatut otteet ja tiukan JSON-skeeman. Tuntematon ID, duplikaatti tai rikkinäinen vastaus hylättiin.

Tämän jälkeen Path Recall@3 nousi 1,00:aan ja evidenssin Recall@3 arvoon 0,952. Kahdestakymmenestäyhdestä kysymyksestä 20 sai vastausta kantavan chunkin kolmen parhaan joukkoon. Yksi jäi aidosti vajaaksi: oikea CRM-tiedosto löytyi, mutta oikea kohta ei noussut tarpeeksi korkealle.

En muuttanut testiä saadakseni täysiä pisteitä. Se jäi dokumentoiduksi missiksi.

Eri tietokantojen pisteitä ei voi vain laittaa samaan kasaan

Haussa oli toinenkin petollinen kohta. Data tuli useammasta Chroma-kokoelmasta. Jokainen kokoelma palautti omat etäisyytensä, mutta luvut eivät olleet suoraan yhteismitallisia.

Jos kokoelman A paras osuma saa etäisyyden 0,42 ja kokoelman B paras 0,61, siitä ei vielä seuraa että A:n osuma olisi globaalisti parempi. Kokoelmien jakaumat, sisältö ja indeksi voivat olla erilaisia.

Korjasin yhdistämisen käyttämään kokoelmakohtaista sijoitusta. Paras osuma kustakin kokoelmasta saa mahdollisuuden ennen toisen kokoelman syvempää häntää. Samalla chunkien ID:t saivat kokoelman nimen prefiksiksi. Muuten kahden kokoelman samanniminen tietue voi törmätä toiseen ja kadota deduplikoinnissa.

Myös deduplikointi oli aluksi liian aggressiivinen. Pidin vain yhden osuman per tiedostopolku. Se kuulostaa siistiltä, mutta samalla se hävittää saman dokumentin toisen chunkin, jossa vastaus saattaa olla. Uusi logiikka erottaa saman polun eri chunkit toisistaan ja voi tuoda rerankkaukseen lähimmät viereiset chunkit.

Yksityisyystesti löysi oikean vuodon

Paikallisuus oli mulle koko projektin lähtökohta. Obsidianin, Driven ja Nextcloudin sisältö ei lähde ulkopuoliselle mallipalvelulle. Embeddingit ja rerankkaus ajetaan omassa verkossa.

Rakensin suodattimen, joka hylkää päiväkirja- ja arkistopolut, perheasioihin liittyvän aineiston, secret-tyyppiset tiedostot ja korkean varmuuden tunnukset. Suomalaiset IBANit redaktoidaan jo siinä vaiheessa, kun data luetaan vektorihausta.

Sitten held-out-käyttäytymistesti löysi tavalliseen projektidokumenttiin merkityn 2FA-palautuskoodin. Tiedosto ei näyttänyt salaisuustiedostolta, joten alkuperäinen polkusuodatin ei pysäyttänyt sitä.

Kirjoitin ensin regressiotestin. Sen jälkeen laajensin sisältösuodatuksen tunnistamaan suomen- ja englanninkieliset 2FA- ja MFA-palautuskoodien nimikkeet. Uudelleenrakennus poisti indeksistä kolme päällekkäistä chunkia. Testissä assistentilta pyydettiin koodia suoraan. Sitä ei ilmestynyt työkalun tulokseen eikä vastaukseen.

Testi paljasti sisältösuodatuksesta puutteen, jota polkusuodatus ei voinut korjata.

Lähdeteksti voi yrittää komentaa mallia

RAGissa haettua dokumenttia käsitellään lähdeaineistona. Sen sisältämiä ohjeita ei saa noudattaa.

Muistiinpanoon voi vahingossa tai tarkoituksella päätyä teksti, joka näyttää agentin komennolta. Siksi lähteiden omat source-markerit ja terminaalin kontrollimerkit neutraloidaan ennen paikallista rerankkausta ja uudelleen ennen kuin paketti annetaan assistentille. Malli saa valita vain hakujärjestelmän jo tuntemista candidate-ID:istä.

Lopullinen evidenssipaketti on rajattu kolmeen lähteeseen ja 4 000 merkkiin. Jokaisella lähteellä on koneellisesti tarkistettava alku- ja loppuraja. Jos paketti on rikki, assistentti ei saa sitä.

Mun aineistossa on asiakashistoriaa, hintoja, sopimusehtoja ja omia muistiinpanoja. Mieluummin haku epäonnistuu näkyvästi kuin palauttaa vakuuttavan vastauksen väärästä kohdasta.

Sanahaku päivittyy nyt muun indeksoinnin mukana

Ensimmäinen oikea käyttökysymys paljasti vielä yhden ongelman. Vektorikanta ja sanahaku eivät olleet samassa tilassa. Yksi ingest-putki osoitti vanhaan embedding-palveluun, ja FTS5-indeksi piti rakentaa käsin.

Korjasin upstream-ingestit käyttämään nykyistä paikallista embedding-palvelua. Sen jälkeen kytkin FTS5-rakennuksen onnistuneiden Obsidian- ja Drive/Nextcloud-ingestien perään. Pilotilla ei ole omaa ajastinta. Olemassa oleva ingest määrää päivitysrytmin, ja erillinen oneshot-palvelu päivittää sanahaun vasta onnistuneen ajon jälkeen.

Prosessilukko estää kahta päivitystä kirjoittamasta SQLite-indeksiä yhtä aikaa. Palvelutiedostot ovat repossa, ja rollback-tarkistus vertaa käytössä olevien tiedostojen hasheja versionhallittuihin kopioihin.

Valmiissa indeksissä on 5 523 hyväksyttyä chunkia. Dry-run hylkäsi 301 yksityiseen polkuun osunutta chunkia, yhdeksän salaisuuteen viittaavaa polkua ja kolme secret-sisältöä. Rakennetussa indeksissä hylättäviä rivejä oli nolla.

Miksi tästä ei tullut automaattista kaikkialla toimivaa AI-muistia

Lopullinen 21 kysymyksen testi saavutti Path Recall@3:n 1,00 ja evidenssin Recall@3:n 0,952. Erillinen 12 kysymyksen held-out-setti sai molemmissa arvon 1,00. Rerank-fallbackeja ei tullut kummassakaan ajossa.

Laatu riitti manuaaliseen käyttöön. Nopeus ei vielä riitä huomaamattomaan taustatoimintoon. Paikallinen rerankkaus vei päätestissä mediaanina 8,47 sekuntia ja held-out-setissä 7,79 sekuntia.

Siksi haku käynnistyy vain silloin, kun pyydän assistenttia hakemaan paikallisesta tiedosta. Se ei lue Gmailia, ei syötä kontekstia jokaiseen keskusteluun eikä korvaa tavallista verkkohakua.

Mulla on nyt toimiva paikallinen tietohaku. En silti kutsu sitä valmiiksi universaaliksi muistiksi. Testissä oli yhteensä 33 retrieval-kysymystä ja kolme käyttäytymistapausta. Seuraava tärkeä aineisto tulee oikeasta käytöstä: mitä se ei löydä, missä se on hidas ja milloin lähde on tulkinnanvarainen.

Testi paljasti virheet, joita en itse huomannut

Projektissa käytetään BGE-M3-embeddingeja, ChromaDB:tä, SQLite FTS5:tä, RRF:ää ja paikallista Qwen-rerankeria. Niistä jokainen vaikutti tulokseen.

Testi erotti oikean tiedoston oikeasta evidenssistä.

Ilman sitä olisin voinut katsoa siistiä hakutulosta, nähdä tutun tiedostonimen ja todeta järjestelmän toimivaksi. Todellisuudessa osa vastauksista olisi perustunut väärään chunkiin, yksi salaisuus olisi päässyt indeksistä läpi ja vanhentuva sanahaku olisi hajonnut hiljaa.

← Blogi