Natiu, no pas un segon índex
La manera habitual d’afegir cerca és aixecar un índex separat — l’FTS5 de SQLite, o un motor de cerca al costat de la teva base de dades. Això obliga dos magatzems a mantenir-se sincronitzats: cada escriptura ha d’actualitzar-los tots dos, i una fallada entremig els deixa en desacord. Pitjor encara: amb dades privades ara tens el contingut duplicat, i la segona còpia sovint és un índex en text pla que viu fora del teu magatzem xifrat.
La cerca nativa elimina tota aquesta família de problemes. L’índex invertit s’escriu dins de la mateixa transacció que la fila, amb l’únic fdatasync que ja paga cada commit d’Arkeion, de manera que no pot divergir mai de les dades. Hereta el xifratge, les còpies de seguretat, el viatge en el temps i la cadena d’auditoria sense cap cost addicional — un sol fitxer xifrat, sense cap segona còpia del teu contingut.
L’índex invertit
A sota, la cerca de text complet és un índex invertit. Un índex normal fa correspondre una fila amb els seus valors; un índex invertit fa correspondre cada terme amb la llista de documents que el contenen (un “document” és una fila; cada columna indexada es tokenitza de manera independent). El text entra, es trenca en termes, i cada terme apunta a les files on apareix.
El tokenitzador
El tokenitzador converteix el text en termes. És determinista i sense model (no és un tokenitzador d’LLM): la mateixa fila produeix sempre els mateixos termes, que és el que fa que l’índex sigui auditable. No té dependències i no conté codi unsafe — tot prové de la biblioteca estàndard.
Avui se n’inclouen dos:
unicode(per defecte): un terme és una seqüència màxima de caràcters alfanumèrics, en minúscules, amb plegat de diacrítics escrit a mà (café → cafe,ñ → n,ß → ss,œ → oe, …) per als rangs europeus habituals.ascii: només alfanumèrics ASCII, en minúscules, sense plegat — més ràpid quan no el necessites.
El tokenitzador és un trait, de manera que més endavant s’hi poden afegir stemmers específics per llengua o un tokenitzador que entengui adreces (que separi bob@example.com en bob, example.com i l’adreça sencera) sense canviar el comportament per defecte. Cada token registra els seus desplaçaments en bytes dins del text original, de manera que snippet() i highlight() poden subratllar la coincidència exacta sense desar desplaçaments dins de l’índex.
Com s’emmagatzema
Un índex FTS és una mena especial d’índex secundari: el mateix b-tree versionat i el mateix manteniment a cada inserció, actualització i esborrat — però amb un gra més fi, una entrada per token en comptes d’una per valor de columna. Perquè no interfereixi amb els índexs ordinaris, les dades de text complet viuen en el seu propi rang de claus, dividit en un diccionari, els postings i les estadístiques que necessita BM25.
Les peces:
- Diccionari de termes. Cada terme es desa un sol cop i es fa correspondre amb un identificador de 4 bytes. Els postings tenen aquest identificador com a clau, de manera que la paraula mateixa no es repeteix mai dins d’un posting. Una cerca per prefix
term*esdevé un escaneig de rang del diccionari que retorna el conjunt d’identificadors coincidents. - Postings. Una cel·la per
(term, row), amb clau(term id, row id). El valor conté, per cada camp, les posicions del token com a delta-varints (field, count, pos0, Δpos…). Un escaneig per prefix sobre un identificador de terme retorna cada(row, positions)d’aquell terme. Mantenir una cel·la per(term, row)— en comptes d’un sol blob empaquetat per terme — és el que fa que les actualitzacions i els esborrats siguin operacions netes d’una sola cel·la, que és exactament el que volen el copy-on-write i l’aïllament per snapshots. - Estadístiques. BM25 necessita tres recomptes: la longitud de cada document, la longitud mitjana de tot l’índex i la freqüència documental (
df) de cada terme. Es mantenen incrementalment a mesura que canvien les files, de manera que mai no s’han de recalcular en temps de consulta.
La definició de l’índex (nom, columnes, tokenitzador) es desa dins de l’esquema de la taula, de manera que el manteniment veu l’índex sense cap consulta addicional a cada escriptura.
Compressió de prefixos
La palanca que va fer petit l’índex és la compressió de prefixos del b-tree a nivell de fulla. Totes les cel·les de posting d’un terme donat comencen amb el mateix prefix de clau — l’etiqueta de text complet més l’identificador del terme, uns nou bytes. En comptes de repetir aquest prefix a cada cel·la, la fulla el desa un sol cop per pàgina i cada cel·la només conserva allò que difereix (l’identificador de fila). Els identificadors de fila i de terme es desen amb longitud variable i preservant l’ordre, en comptes de fer-ho en camps fixos de 8 i 4 bytes.
Ordenació amb BM25
MATCH troba les files; BM25 les ordena. És la puntuació de rellevància estàndard, i equilibra tres idees: un terme que apareix més sovint en una fila compta més (freqüència del terme), un terme que és rar dins del corpus compta més (freqüència documental inversa) i una coincidència en un camp curt compta més que la mateixa coincidència en un de molt llarg (normalització per longitud). Les tres entrades — freqüència del terme, longitud del document, longitud mitjana i df — surten directament del valor del posting i de les claus d’estadístiques.
SELECT id, snippet(body, 'index'), bm25(body, 'index') AS rank
FROM docs
WHERE body MATCH 'index'
ORDER BY rank DESC
LIMIT 10;
Quan una consulta és un MATCH ordenat per bm25(...) amb un LIMIT, l’ordenació es calcula directament des de l’índex i només es recupera i es torna a puntuar amb exactitud una llista curta de les millors files — així la majoria de files coincidents no es llegeixen ni es tornen a tokenitzar mai. La mateixa fórmula de BM25 sosté tant aquest camí ràpid com la funció bm25() per fila, de manera que no poden divergir. snippet() i highlight() només tornen a tokenitzar les files que estàs mostrant per col·locar-hi els marcadors, fent servir els desplaçaments en bytes que va registrar el tokenitzador.
El llenguatge de consulta
La cadena que hi ha a la dreta de MATCH té la seva pròpia gramàtica petita, propera a la d’FTS5:
- termes —
index - booleans —
index AND rust,index OR search,index NOT stale - frase —
"inverted index" - prefix —
index* - proximitat —
NEAR(index rust, 5) - per columna —
title:index
Viatge en el temps, i sobreviure al vacuum
Com que l’índex és un ciutadà corrent de l’historial copy-on-write, dues coses útils venen de franc. MATCH … AS OF VERSION n cerca l’índex tal com era en una versió anterior — cerca de text complet sobre el passat, no només sobre les dades. I el vacuum, que recupera l’espai de les versions substituïdes, manté intacte l’índex actual sense cap tractament especial, perquè no hi ha cap magatzem separat per reconstruir.
Rendiment
Mesurat sobre 50k passatges de MS MARCO, encastat i dins del mateix procés, contra l’FTS5 de SQLite a la mateixa màquina:
| format anterior | Arkeion | SQLite FTS5 | |
|---|---|---|---|
| Mida de l’índex | 146 MB | 26.2 MB | 27.2 MB |
| Construcció | 63 s | 11 s | 0.6 s |
| Consulta de terme comú | 25.6 ms | 2.5 ms | 1.1 ms |
| Consulta de prefix / frase | 9.3 / 9.1 ms | 1.4 / 2.6 ms | 0.5 / 0.6 ms |
Llegeix-ho amb honestedat. L’índex ha acabat sent més petit que el d’FTS5 (26.2 vs 27.2 MB) — un magatzem versionat, xifrat i amb viatge en el temps guanyant en compacitat a un motor especialitzat escrit en C. On FTS5 encara guanya és en velocitat de construcció i latència de consulta: els seus segments empaquetats es construeixen i s’escanegen més ràpid que una cel·la de b-tree per posting. El bescanvi compra una cosa que FTS5 no pot oferir — cerca demostrable, xifrada i adreçable en el passat — i sense cap risc de sincronització entre dos magatzems.