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. Firma ei siis ole muistiinpanosovellus tai RAG-startup, vaan ennen kaikkea AI-laskennan laite- ja pilvipalveluyhtiö.
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.
Siinä kohtaa koko homma meni uusiksi.
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.
Tää ero oli mun testissä iso.
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:
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.
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.
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.
Pieniä teknisiä yksityiskohtia. Paitsi että juuri niissä RAG yleensä joko toimii tai valehtelee uskottavasti.
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.
Paikallinen ei silti tarkoita automaattisesti turvallista.
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.
Tää oli projektin tärkeimpiä tuloksia. Turvallisuussääntö, jota ei ole yritetty rikkoa testissä, on lähinnä toive.
RAGissa haettu dokumentti ei ole luotettu ohje. Se on dataa.
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ä.
Tää kuulostaa ehkä ylivarovaiselta. Mun aineistossa on asiakashistoriaa, hintoja, sopimusehtoja ja omia muistiinpanoja. Mieluummin haku epäonnistuu näkyvästi kuin palauttaa vakuuttavan vastauksen väärästä kohdasta.
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 on edelleen totuus, 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.
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.
Projektissa käytetään BGE-M3-embeddingeja, ChromaDB:tä, SQLite FTS5:tä, RRF:ää ja paikallista Qwen-rerankeria. Niistä jokainen vaikutti tulokseen.
Silti tärkein osa oli testi, joka 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.
Paikallisen RAGin rakentaminen ei ollut vaikeinta. Vaikeinta oli rakentaa tapa nähdä, milloin se oli väärässä.
Ja just siksi se on nyt oikeasti käyttökelpoinen.