Sémantické hledání nad SQLite: vektorová databáze, která je jen pole v paměti

Sémantické hledání nevyžaduje nákladnou vektorovou databázi. Pro 9 tisíc titulů stačí 36 MB pole v paměti a skalární součin v Go s latencí pod 10 ms – tady jsou naměřená data i pasti z provozu.
Hledání podle věty místo klíčových slov, odpověď do 10 ms, bez vektorové databáze i bez sqlite-vec na dvou vCPU zdarma. Jak to funguje a kde leží skutečné pasti?
Proč fulltext nestačí
Provozuju najdi-film.cz, katalog 8 898 filmů a seriálů s informací, kde který titul u nás legálně běží. Uživatel do hledání nenapíše „thriller 2019“, ale spíš „něco napínavého, ale ne moc krvavého“. Fulltext na to nemá šanci: slovo „napínavý“ se v synopsi nevyskytuje, a i kdyby, o to tomu člověku nejde.
Ideální úloha pro embeddingy. Učebnice ale zpravidla pokračují doporučením postavit vedle toho vektorovou databázi, případně aspoň sáhnout po rozšíření sqlite-vec. Neudělal jsem ani jedno a ukážu, proč to v tomhle měřítku vychází.
Co se embeduje
Vektor nevzniká ze synopse. Vzniká z profilu, který si o titulu skládám sám:
Film: Sedm (1995). Žánr: krimi, thriller. Nálada: temná, napínavá.
Tón: klaustrofobní. Kdy se hodí: když chceš být nervózní. Témata:
sériový vrah. Diváci hledají: Brad Pitt, detektivka s obratem na
konci. Děj: …
Poslední kolonka je nejužitečnější a říká se jí doc2query: k dokumentu se jazykovým modelem vygenerují dotazy, na které by měl odpovídat, a embedují se s ním. Vektor se tím naučí jazyk hledajícího diváka, ne jazyk distributora – mimo jiné katalogu dodá jména herců, která v datech nemám.
Formát profilu musí zůstat stabilní. Jakákoli změna znamená přegenerovat celý katalog, protože staré vektory popisují něco jiného. Vyplatí se mít to napsané v kódu přímo u funkce.
Uložení: BLOB a nic víc
Profily jdou dávkově přes embedovací API (u mě embed-multilingual-v3.0 od Cohere, po 96 kusech, což je jeho maximum) a vrací se jako 1024 čísel v plovoucí řádové čárce. Uložím je do sloupce v tabulce filmů jako BLOB, 4 bajty na složku v pořadí little-endian:
func EncodeEmbedding(v []float32) []byte {
b := make([]byte, 4*len(v))
for i, f := range v {
binary.LittleEndian.PutUint32(b[4*i:], math.Float32bits(f))
}
return b
}
To jsou 4 kB na titul a 36 MB za celý katalog, což je polovina velikosti databáze. Dekódování má pojistku: když BLOB nemá očekávanou délku, vrátí funkce nil a řádek se přeskočí. Je to ochrana pro den, kdy sáhnu po jiném modelu: vektory s různými dimenzemi nejsou kompatibilní a bez té kontroly by to nespadlo, jen tiše zhoršilo výsledky. SQLite slouží jen jako pasivní úložiště.
Hledání: lineární průchod polem
Backend si při startu všechny vektory načte do paměti jako obyčejné [][]float32 a jednou za šest hodin index obnoví, aby pobral tituly přibylé z noční dávky. Hledání je pak průchod tím polem a skalární součin s dotazem. Vektory jsou normalizované, takže skalární součin je rovnou kosinová podobnost.
Go vývojáře napadne garbage collector: nedělá 36 MB trvale živých dat v haldě pauzy? Nedělá. Pole []float32 neobsahuje ukazatele, takže jím GC při značkování neprochází – řeší 8 871 hlaviček slices, ne 9 milionů čísel. Při 500 hledáních za sebou proběhly 3 cykly GC s nejdelší pauzou 80 µs (0,004 % doby běhu).
Ano, je to hrubá síla přes celý katalog. Změřeno na dvou jádrech Ampere A1, což je od letošního června strop bezplatného tarifu Oraclu:
načtení indexu při startu 146 ms
skalární součiny přes 8 871 vektorů 7,9 ms
totéž včetně seřazení a výběru TopK 9,8 ms
Za necelých 8 ms tedy proběhne 9 milionů násobení a sečtení, v jednom vlákně a v obyčejném Go bez ručních SIMD triků. Řadím schválně celé pole, ne haldu o deseti prvcích: padnou na to 2 ms z 10, rozpočet žere průchod.
Kdy se to zlomí, se dá spočítat: doba roste lineárně, takže desetinásobný katalog je na 80 ms (pořád použitelné) a stonásobný už ne. Index typu HNSW má smysl až od statisíců titulů. Do té doby je to jen další závislost a další místo, kde se dá dostat špatná odpověď rychle.
První past: kosinus není kalibrovaný
Tady jsem si natloukl. Kosinová podobnost dává čísla mezi nulou a jedničkou, takže je svůdné určit práh a pod ním hlásit „nic jsme nenašli“. Nefunguje to. Změřeno na 34 dotazech, z toho 24 skutečných z provozního logu: nesmysly padnou do pásma 0,373 až 0,492, smysluplné dotazy do 0,466 až 0,681. Pásma se překrývají a uvnitř překryvu se to i prohodí – „recept na svíčkovou“ dá 0,492, víc než „dokument“ s 0,466.
Rozseknout se to dá druhým kolem. Vektorový průchod vybere třicet kandidátů a ty přerovná model typu cross-encoder, který čte dotaz a text dohromady, ne dva vektory zvlášť. Ten kalibrovaný je – a přesně tady jsem udělal tu lákavou chybu. Když dobré shody chodí „někde přes 0,5“, dám práh na 0,40. Znělo to opatrně a dva a půl týdne to tak běželo v produkci, než jsem to změřil:
nesmysly („recept na svíčkovou“, „qwertz asdfgh“) 0,034-0,091
široké, ale zodpověditelné („dokument“, „komedie“) 0,219-0,569
konkrétní trefa („žoldáci“, „72 hodin“) 0,660-0,772
Nesmysly nejsou u 0,3, ale u 0,05. A hlavně: polovina dotazů, na které hledání odpovídá dobře, leží pod mým prahem – „dokument“ má 0,219, „komedie“ 0,288, „horor“ 0,342. Práh 0,40 tak vyrobil falešné „nic jsme nenašli“ u sedmi dotazů ze 34; po přesunu na 0,15, doprostřed čisté mezery mezi nesmysly a nejhorším platným dotazem, nezůstal ani jeden.
Předtím jsem zkusil potlačovat hlášku u dotazů do tří slov v domnění, že skórují níže. Korelace počtu slov a skóre je ale r = 0,06 (nulová) – jednoslovné „žoldáci“ má 0,772, nejvyšší v sadě. Záplata jen léčila symptom špatného prahu.
Druhá past: lidé do sémantického hledání píšou názvy
Postavil jsem hledání podle věty, tak jsem čekal věty. Týden provozu ukázal, že polovina skutečných dotazů byly názvy filmů – „hry o trůny“, „manituova bota“, „matrix“. Sémantika na ně odpoví, ale draze a hůř než obyčejný LIKE.
K LIKE se přitom ani nedostanou kvůli překlepům nebo české deklinaci („hry o trůny“ místo „Hra o trůny“). Řešením není lepší embedding, ale porovnání celého názvu na jednu editační vzdálenost. Dotazy typu „něco jako Vetřelec“ pak řeší regulární výraz: embedding by poslušně vrátil na prvním místě samotného Vetřelce, takže se předloha z dotazu vytáhne a použije se její vlastní vektor s vyloučením jí samotné.
Jedna podmnožina zůstává nevyřešená a ukazuje mez přístupu: název filmu, který v katalogu nemám. Takový dotaz skóruje vysoko a zaslouženě – na „zámek hrůzy“ vrátí reranker Mys hrůzy s 0,620, na „osudové pokušení“ Osudovou přitažlivost s 0,541. Textově je to výborná odpověď. Že šlo o něco jiného, ví jedině uživatel, a žádný práh nad skóre to nerozhodne.
Třetí past: latence je jinde, než čekáte
Mediány celého požadavku z logu reverzní proxy:
/api/similar 13 ms
/api/hledani 579 ms
Obě cesty dělají nad tímtéž polem tentýž průchod. „Podobné filmy“ mají vektor už uložený, takže se odbaví celé z paměti: 9,8 ms hledání a zbytek na obsluhu požadavku, načtení nalezených titulů z databáze a JSON. Hledání podle věty musí dotaz nejdřív poslat embedovat a pak kandidáty přerovnat, tedy dvakrát ven po síti – a je z toho čtyřicetinásobek.
Ten rozdíl nedělá výpočet, dělá ho síť. Kdybych zrychlil skalární součiny, na kterých je v článku nejvíc kódu, ušetřím jednotky ms z 579 ms. Užitečnější je 24h cache odpovědí. Úzkým hrdlem bývá cizí služba na druhém konci drátu, ne procesor pod stolem.
Za cizí API se navíc platí (reranking stojí 200 dolarů za 100 tisíc volání), takže na to mám denní stropy. Po jejich vyčerpání se hledání degraduje na režim bez přerovnání – bez chybové hlášky, jen s o něco horšími výsledky.
Než přidáte další službu
Celá „vektorová databáze“ je v tomhle projektu 36 MB v paměti procesu, který už běží, plus jedna funkce se skalárním součinem. Odpovídá rychleji než síť do datového centra a nemá vlastní konfiguraci, vlastní zálohu ani vlastní režim výpadku. Nechci z toho dělat univerzální pravidlo. Jen mi přijde, že než do architektury přibude další služba, stojí za to se zeptat: kolik dat reálně máte a zkusili jste změřit, jak dlouho trvá je projít?