Wat verandert er als je zoekindex op S3 staat?

Bij Infino bouwen we een retrieval engine om vragen van agenten op schaal te beantwoorden, en de full-text search hiervan behoort tot de snelste die beschikbaar zijn [1]. Omdat agenten query's uitvoeren op gestructureerde, semi-gestructureerde en ongestructureerde data, moeten we ondersteuning bieden voor SQL, vector search, BM25 full-text search en elke combinatie hiervan. In de meeste gevallen bevindt de data zich al in de object store, wat schaalbaarheid en hoge beschikbaarheid garandeert. Daarom hebben we besloten de zoekindex op de object store te bouwen, met ondersteuning voor S3, GCS en Azure Blob Storage.

Zoekmachines hebben hun index traditioneel op een lokale schijf bewaard. Lucene, waar de meeste zoekmachines op zijn gebouwd of naar zijn gemodelleerd, gaat ervan uit dat de index op een schijf naast de CPU staat, waardoor een random seek relatief goedkoop is. Dat is echter niet het geval wanneer je de index verplaatst naar object storage, waar elke random read een netwerkverzoek (round-trip) is. In dit artikel beschrijven we enkele ontwerpbeslissingen die we anders hebben gemaakt vanwege deze omstandigheid.

Architectuur

Infino organiseert de data in superfiles, wat Parquet-bestanden zijn met de full-text en vector-indexen van Infino. Meerdere superfiles samen vormen een supertable. Elke supertable heeft:

  1. Een manifest: dit bevat een lijst van superfiles samen met hun footers, index-headers en kolomstatistieken.
  2. Een term-index: dit bevat een lijst van termen, inclusief de superfiles waarin ze voorkomen en informatie over de score bound.

Het kostenmodel

Op een lokale SSD kost een random read niets als een pagina in de cache staat, en minder dan een milliseconde in het geval van een page fault. Een zoekmachine waarvan de index op een SSD staat, kan zich dus veel random reads veroorloven.

Op S3, GCS of Azure Blob is elke random read echter een HTTP GET-verzoek. Dit duurt typisch tientallen milliseconden en wordt gefactureerd per verzoek, ongeacht hoeveel bytes er worden teruggestuurd. Dit verandert waar je op moet optimaliseren. De schaarste zit niet in de CPU of de bytes, maar in het aantal round trips naar de object store. De meeste onderstaande ontwerpbeslissingen vloeien hier uit voort [2].

Eén bestand per index-eenheid

Zoekindexen worden gebouwd als onveranderlijke (immutable) eenheden. Je voert een batch documenten in en de engine schrijft daar een zelfstandige index voor. Updates worden ook als nieuwe eenheden geschreven, en een merge-proces op de achtergrond combineert later oude eenheden tot nieuwe onveranderlijke eenheden. In Lucene wordt zo'n onveranderlijke eenheid een segment genoemd; bij Infino noemen we dit een superfile.

Een Lucene-segment bestaat uit een set bestanden: een term-dictionary (.tim), een index naar de term-dictionary (.tip), postings (.doc), posities (.pos), enzovoort. Standaard verpakt Lucene kleine segmenten in een compound file (.cfs, met een entry-tabel in .cfe), terwijl grote segmenten hun aparte bestanden behouden. In beide gevallen vereist het openen van een segment op object storage meerdere GET-verzoeken.

Een Infino superfile is altijd één enkel object: een gewoon Parquet-bestand. De documenten zijn normale row groups, en de full-text en vector-indexen zijn ingevoegd als byte regions vlak voor de Parquet-footer, die vindbaar zijn via keys in de metadata van de footer. Een Parquet-reader zoals DuckDB of PyArrow kan dit bestand als een normaal Parquet-bestand lezen. Omdat de byte offsets van elke sectie in de metadata staan, kan elk deel van een superfile worden opgehaald met één enkele ranged GET.

Het manifest

Elke supertable heeft een manifest dat de bijbehorende superfiles opsomt. Een schrijfactie creëert nieuwe superfiles, schrijft een nieuw manifest waarin deze zijn opgenomen en vervangt vervolgens atomair een kleine pointer-file naar het nieuwe manifest, zodat lezers altijd een consistente set superfiles zien.

De byte offsets van de full-text en vector-indexen staan in de Parquet-footer (aan het einde van het bestand). De full-text index heeft daarnaast een kleine header die een reader nodig heeft voordat er een term kan worden opgezocht. Als een query deze gegevens uit elk superfile zou lezen, zou dat per superfile een extra GET-verzoek kosten voordat het eigenlijke zoekwerk begint. Daarom slaat het manifest de Parquet-footer en de index-headers van elk superfile inline op. Het manifest wordt meestal gecached; omdat het de footer en headers al bevat, is er geen GET-verzoek nodig om een superfile te "openen". Het eerste GET-verzoek dat een query naar een superfile stuurt, haalt direct de data op die daadwerkelijk nodig is, zoals de postings van een term. Dit reduceert de cold cost van elk superfile van twee round trips naar één.

Het manifest slaat ook kolomstatistieken op voor elk superfile, zoals de minimum- en maximumwaarde en het aantal null-waarden per kolom. Een query met een filter zoals createdat > '2026-10-01' kan een superfile overslaan waarvan de maximale createdat ouder is dan die datum, zonder het bestand te hoeven openen. Kolomstatistieken kunnen echter niet vertellen of een superfile het woord "refund" bevat; daar is de term-index voor.

De term-index

Zonder term-index zou een full-text query bij elk superfile moeten navragen of het de gezochte termen bevat. Dat vereist het lezen van de term-dictionary van elk superfile, wat minstens één GET-verzoek per bestand betekent, zelfs voor bestanden die de term helemaal niet bevatten. Een supertable kan duizenden superfiles bevatten, wat dit snel duur maakt.

Daarom heeft elke supertable ook een term-index, waarin elke term staat samen met de superfiles waarin deze voorkomt. Voor elke term en elk superfile slaat de index het volgende op:

  • Het aantal documenten in dat superfile die de term bevatten.
  • Een score bound: de hoogste BM25-score die de term aan een document in dat superfile kan geven.
  • De byte offset van de postings van de term binnen het superfile.

De term-index is opgesplitst in slices per term-bereik, zodat een query alleen de slices ophaalt die zijn termen bevatten, en niet de volledige index.

Bij een zoekopdracht naar "refund" kijkt de query eerst in de term-index en ontdekt bijvoorbeeld dat alleen sf-03 en sf-06 dit woord bevatten. Alle andere superfiles worden overgeslagen. Omdat de term-index ook de byte offset van de postings bevat, kan de query de postings voor "refund" uit sf-03 en sf-06 ophalen met elk één ranged GET, zonder de term-dictionaries van die bestanden te lezen.

De documentaantallen en score bounds helpen daarnaast. Bij een top-k query bezoekt de engine de superfiles in aflopende volgorde van score bound en slaat een superfile over zodra de score bound lager is dan de $k$-de beste score die tot nu toe is gevonden. Een count query voor een enkele term kan zelfs beantwoord worden aan de hand van de documentaantallen alleen, zonder enig superfile te openen [3]. In een van onze tests ging een single-term count op een supertable met 141 superfiles van het openen van 141 bestanden naar nul. Deze documentaantallen leveren ook de corpus-brede statistieken die BM25 nodig heeft voor de scoring.

De term-index wordt bij elke commit bijgewerkt: een commit die superfiles toevoegt, voegt hun termen toe aan de term-index. Een optimalisatiestap op de achtergrond voegt deze toevoegingen periodiek samen tot één compacte index.

Alleen lezen wat nodig is

Een superfile kan groter zijn dan een gigabyte, terwijl een query meestal slechts een klein deel nodig heeft. Infino leest een superfile daarom lazy, via byte ranges.

Wanneer een query een term nodig heeft, worden alleen de postings van die term opgehaald. De documentlengtes, die BM25 nodig heeft voor scoring, worden pas opgehaald wanneer de query daadwerkelijk scores berekent; een count query leest deze dus nooit. Posities worden in hun eigen regio bewaard en worden alleen opgehaald wanneer een phrase query de woordvolgorde moet controleren.

Wanneer een query postings nodig heeft van meerdere termen die dicht bij elkaar in het bestand staan, haalt Infino ze in één verzoek op, zolang de gaten ertussen klein zijn (tot 64 KB). Het lezen van een paar extra bytes is namelijk goedkoper dan een extra round trip. Bytes die al zijn opgehaald, worden binnen de query hergebruikt, zodat niets twee keer wordt opgehaald.

Caching op schijf en in het geheugen

Zelfs enkele GET-verzoeken zijn trager dan lezen van een lokale schijf. Daarom cached Infino superfiles ook op de lokale schijf. Wanneer een query voor het eerst een superfile raakt, leest hij de benodigde byte ranges direct van de object storage, terwijl Infino op de achtergrond het volledige superfile naar de lokale schijf downloadt [4]. Zodra de download is voltooid, wordt het superfile memory-mapped en vereisen volgende queries op dit bestand geen GET-verzoeken meer. De disk-cache heeft een budget (standaard 10 GB, maar configureerbaar), en de minst recent gebruikte superfiles worden verwijderd als de cache vol is.

Caching in het geheugen gebeurt via de memory map: het besturingssysteem houdt veelgebruikte pagina's van het superfile in het RAM, en Infino geeft de pagina's van superfiles die onlangs niet zijn bevraagd vrij. Het manifest en de term-index worden ook agressief gecached, aangezien deze door vrijwel elke query worden gelezen.

Conclusie

Het verplaatsen van de zoekindex naar object storage verandert waar je op moet optimaliseren. Op een lokale schijf is een random read bijna gratis, maar op object storage is elke random read een GET-verzoek dat tientallen milliseconden duurt en per request wordt gefactureerd. Het ontwerp van Infino is daarom primair gericht op het minimaliseren van round trips.

Een superfile is één enkel object, waardoor een query nooit veel bestanden hoeft te openen voor één index-eenheid. Het manifest bevat de footer en index-headers van elk superfile, zodat het openen van een superfile geen GET-verzoek vereist. De term-index vertelt de query welke superfiles de termen bevatten, zodat de rest kan worden overgeslagen. Een query leest alleen de benodigde byte ranges, en dankzij de disk- en geheugencache vereisen herhaalde queries vaak helemaal geen GET-verzoeken.

De kern van de zoekmachine is echter niet veranderd. Postings lists, BM25-scoring, block-max pruning en de query-algoritmen zijn dezelfde concepten die Lucene gebruikt; op diverse punten bouwt Infino direct voort op ideeën die Lucene heeft gepionierd. Wat is veranderd is de storage layer, omdat daar het kostenmodel is veranderd.

In volgende artikelen zullen we dieper ingaan op de index-datastructuren binnen een superfile en hoe Infino een query parallel over vele superfiles uitvoert.

***

Voetnoten

[1] Search Benchmark Game: Vergelijking van Infino met andere zoekmachines. Uitgevoerd op 13 okt 2026, 5 miljoen Wikipedia-documenten. infino-ai.github.io/search-benchmark-game/full/

[2] CPU en bytes blijven belangrijk, maar ze zijn minder cruciaal dan het aantal round trips naar de object store.

[3] Dit geldt zolang er geen documenten zijn verwijderd. Bij verwijderingen die nog niet door de optimalisatiestap op de achtergrond zijn verwerkt, moet de query het superfile toch openen om te controleren welke matching documenten nog actief zijn.

[4] De download op de achtergrond wacht zolang queries het superfile nog lazy lezen, zodat ze niet concurreren om bandbreedte en de query-latentie laag blijft. De trade-off is dat een superfile twee keer gelezen kan worden: één keer in ranges en één keer volledig.