Betere Vector Search voor Lange Documenten: Chunking in Manticore Search

Stel dat je een zoekfunctie bouwt voor de interne documentatie van je team — handleidingen, runbooks, postmortems. Je hebt een tabel met automatische embeddings: je voert tekst in, Manticore voert het model uit en vult de vector-kolom voor je in. Je uploadt een document van 4.000 woorden. De invoer slaagt, de zoekopdracht werkt en alles lijkt in orde.

Er is echter één probleem: het model dat je hebt gekozen heeft een input-venster van 512 tokens, terwijl dat document ongeveer 5.000 tokens lang is. Het model heeft de eerste 380 woorden gelezen en de overige 3.600 weggegooid. Alles in het document na dat punt kan nooit worden teruggevonden, en niets heeft je hierover geïnformeerd. Bovendien is de embedding mogelijk niet representatief voor het document als geheel.

Tot nu toe moest je het document handmatig in verschillende stukken splitsen, voor elk stuk embeddings maken en vervolgens uitzoeken hoe je de resultaten combineerde als je op documentniveau wilde zoeken in plaats van op chunk-niveau. Manticore regelt dit nu in de tabeldefinitie: voeg chunk_strategy toe aan de vector-kolom in CREATE TABLE. Manticore splitst elk document in chunks, maakt van elke chunk een embedding en doorzoekt ze allemaal.

DROP TABLE IF EXISTS docs;
CREATE TABLE docs (
    title text,
    content text,
    chunks float_vector_array knn_type='hnsw' hnsw_similarity='cosine'
           model_name='Xenova/all-MiniLM-L6-v2' from='title,content'
           chunk_strategy='sentence' max_tokens='256' overlap_tokens='32'
);

Dit is de volledige functionaliteit. Geen ingest-pipeline, geen splitter-library, geen tweede tabel voor chunks en geen GROUP BY om chunk-hits terug te voeren naar documenten.

Samenvatting (TL;DR)

  • Vijf strategieën: truncate (de oude standaard), mean, fixed, recursive en sentence. Deze stel je in via chunk_strategy op een model-gestuurde vector-kolom.
  • Kolomtypes: truncate en mean produceren één vector per document en werken op een floatvector kolom. fixed, recursive en sentence produceren meerdere vectoren en vereisen daarom een floatvector_array kolom.
  • Zoekresultaten: Een document blijft één zoekresultaat. Chunks concurreren individueel, en Manticore retourneert het document één keer, waarbij knn_dist() de afstand tot de dichtstbijzijnde chunk rapporteert. De parameter k telt documenten, niet chunks.
  • Instellingen: maxtokens (chunk-grootte), overlaptokens (gedeelde tokens tussen buren) en max_chunks (plafond per document).
  • Prestaties: Gemeten op de Manticore-handleiding (189 pagina's, ~298k woorden): voor content die buiten het venster van het model viel, steeg de recall@5 van 55,1% → 83,3% en de MRR van 0,44 → 0,70, tegen ongeveer 2,5× meer RAM en 4× langere ingest-tijd.
  • Queries: Zoekopdrachten worden nooit in chunks gesplitst. Een query is kort genoeg om in zijn geheel te embedden; alleen opgeslagen documenten worden gesplitst.

Het probleem in de praktijk

Stel dat je vier documenten hebt:

  1. Backup and restore runbook — ongeveer 700 woorden (900 tokens). Bevat secties over back-upschema's, retentie, hersteltests, inloggegevens en capaciteitsplanning. De laatste sectie legt uit hoe je het TLS-certificaat roteert dat door de replicatiepoort wordt gebruikt.
  2. Monitoring and alerting guide — niet relevant.
  3. Getting started with the CLI — niet relevant.
  4. TLS and certificates for the HTTP API — een korte pagina die volledig over certificaten gaat, maar nooit over rotatie of replicatie spreekt.

Je kunt de tabel als volgt aanmaken met drie vector-kolommen (één per strategie) om ze te vergelijken:

DROP TABLE IF EXISTS docs;
CREATE TABLE docs (
    title text,
    body text,
    v_truncate float_vector knn_type='hnsw' hnsw_similarity='cosine'
               model_name='Xenova/all-MiniLM-L6-v2' from='title,body',
    v_mean     float_vector knn_type='hnsw' hnsw_similarity='cosine'
               model_name='Xenova/all-MiniLM-L6-v2' from='title,body' chunk_strategy='mean',
    v_sentence float_vector_array knn_type='hnsw' hnsw_similarity='cosine'
               model_name='Xenova/all-MiniLM-L6-v2' from='title,body'
               chunk_strategy='sentence' max_tokens='128' overlap_tokens='32'
);

Wanneer je een vraag stelt over het roteren van het TLS-certificaat voor replicatie, zijn dit de resultaten:

Strategie1e resultaat2e resultaat
truncate (default)TLS and certificates for the HTTP API — 0.762Backup and restore runbook — 0.936
meanBackup and restore runbook — 0.656TLS and certificates for the HTTP API — 0.762
sentence (128 tok, 32 ov)Backup and restore runbook — 0.254TLS and certificates for the HTTP API — 0.700

Bij truncate verliest het document met het juiste antwoord van een "decoy" (afleider) die simpelweg over certificaten gaat. De vector van het runbook werd namelijk alleen gebouwd op basis van de beginpagina's over back-upschema's, omdat het model niet verder kon lezen.

Met sentence chunking wordt het runbook opgeslagen als negen vectoren in plaats van één. Een van die negen is de paragraaf over certificaatrotatie. Deze matcht de query bijna exact, waardoor het document met een ruime marge wint.

Overzicht van de chunking-strategieën

StrategieVectoren per docKolomtypeWerking
truncate1float_vectorEmbedt zoveel als in het venster van het model past, gooit de rest weg. De standaardinstelling.
mean1float_vectorSplitst het hele document, embedt elk stuk en middelt deze tot één vector.
fixedNfloatvectorarrayVaste vensters van max_tokens tokens.
recursiveNfloatvectorarraySplitst op basis van een hiërarchie: paragraaf → regel → zin → spatie, om elk stuk binnen max_tokens te houden.
sentenceNfloatvectorarrayGebruikt zin-grenzen (Unicode UAX #29), gepakt tot max_tokens.

Het cruciale verschil is wat een "match" betekent:

  • Eén vector per document: De zoekopdracht vraagt: "Is dit document als geheel vergelijkbaar met de query?" Een relevante paragraaf wordt verwaterd door de rest van de tekst.
  • Eén vector per chunk: De zoekopdracht vraagt: "Bevat dit document iets dat vergelijkbaar is?" Elke chunk concurreert op eigen kracht, en Manticore retourneert het document gebaseerd op de beste chunk.

truncate — voor korte documenten

Gebruik dit wanneer je tekst echt in het venster van het model past: producttitels, korte beschrijvingen, tags, chatberichten, logregels of commit-onderwerpen.

  • Capaciteit: all-MiniLM-L6-v2 hanteert 512 tokens (ca. 380 Engelse woorden). text-embedding-3-small hanteert 8.192 tokens.

mean — één vector, maar het hele document

Manticore splitst het document, embedt elke chunk en middelt de vectoren tot één genormaliseerde vector. De opslag- en zoekkosten zijn identiek aan truncate.

  • Wanneer gebruiken: Wanneer je wilt dat het hele document meetelt maar geen extra RAM hebt voor meer vectoren, of wanneer de kolom een float_vector moet blijven (bijv. bij een ALTER TABLE).
  • Wanneer niet gebruiken: Als een document verschillende ongerelateerde onderwerpen behandelt. Het middelen van een vrijwaringsclausule met betalingsvoorwaarden resulteert in een vector die tussen beide in ligt en op geen van beide goed matcht.

fixed — voorspelbaar en eenvoudig

Knipt elke max_tokens tokens, ongeacht de inhoud.

  • Wanneer gebruiken: Tekst zonder betrouwbare structuur: OCR-output, gescrapte HTML zonder paragrafen, machine-transcripts zonder interpunctie of log-dumps.

recursive — de beste standaard voor proza

Vergelijkbaar met fixed, maar elke knip wordt teruggeschoven naar de dichtstbijzijnde natuurlijke grens (blanke regel, regelafbreking, zin-einde, spatie).

  • Wanneer gebruiken: Markdown en HTML-documentatie, wiki's, blogposts en gestructureerde rapporten.

sentence — voor volledige gedachten

Detecteert zin-grenzen en pakt volledige zinnen tot het token-budget is bereikt. Een chunk begint of eindigt nooit midden in een zin.

  • Wanneer gebruiken: Support-tickets, e-mailthreads, juridische teksten, medische abstracts of alles waarbij een fragment van een zin de betekenis zou kunnen veranderen. Ideaal als chunks later naar een LLM worden gevoerd.

De drie instellingen (knobs)

  1. chunk_strategy: Keuze uit truncate, mean, fixed, recursive of sentence.
  2. max_tokens: De grootte van de chunk. 0 (standaard) gebruikt de limiet van het model. Kleinere chunks zorgen voor scherpere matches maar meer vectoren. Voor Engelse proza is 128–512 meestal voldoende.
  3. overlaptokens: Tokens die gedeeld worden tussen opeenvolgende chunks. Dit voorkomt dat een zin die precies op de grens valt, onbruikbaar wordt. Meestal wordt 10–20% van maxtokens ingesteld.
  4. max_chunks: Een plafond voor het aantal vectoren per document. Dit dient als beveiliging tegen extreem grote documenten (bijv. een PDF van 400 pagina's) die anders duizenden HNSW-nodes zouden creëren.

Hoe de zoekopdracht werkt

Je query verandert niet. Er is geen aparte chunk-tabel of GROUP BY nodig.

SELECT id, title, knn_dist() FROM notes
WHERE knn(chunks, 3, 'how do I replace an expiring certificate on every node');

De regels zijn:

  • Een document matcht als een van zijn vectoren dicht bij de query-vector ligt.
  • Manticore retourneert elk match-document precies één keer.
  • knn_dist() is de afstand tot de dichtstbijzijnde chunk.
  • k telt documenten, niet vectoren.

Prestaties: Testen op de Manticore-handleiding

Er is een test uitgevoerd op de Engelse Manticore-handleiding (189 pagina's, ~298.000 woorden) met twee soorten queries:

  • Deep-content queries: Vragen over inhoud die na de eerste 1.200 tekens van een pagina verschijnt.
  • Head-content queries: Vragen over de beginsecties (controle-groep).

Deep content — waar chunking voor is

StrategieVectorenIngestIndex RAMhit@1hit@5hit@10MRR
truncate18921 s4.2 MB33.7%55.1%63.2%0.44
mean18972 s4.2 MB43.9%65.2%74.7%0.54
fixed3,43073 s9.5 MB56.3%81.1%86.2%0.68
recursive4,66486 s11.7 MB58.7%83.3%89.5%0.70
sentence4,04179 s10.6 MB55.4%83.5%89.0%0.68

Chunking transformeert de zoekfunctie van een "muntworp" naar een werkend systeem. De recall@5 stijgt van 55,1% naar 83,3%.

Head content — de controle-groep

Voor content die het model al kon zien, is truncate het meest precies op rank 1 (65,9% vs 58,0% voor recursive), omdat een vector van het hele document de algemene context van de pagina beter vangt. Echter, op rank 5 is het verschil verdwenen (beide 86,4%).

Kostenanalyse

  • Index RAM: Steeg van 4,2 MB → 11,7 MB (ca. 2,5×) voor ongeveer 25× zoveel vectoren.
  • Data load: Steeg van 21 s → 86 s. Dit komt door de embedding-kosten van het hele corpus in plaats van alleen het begin.
  • Query responstijd: Zeer gering verschil (6,3 ms → 8,5 ms bij p50), omdat HNSW efficiënt omgaat met het aantal vectoren.

Aanbevelingen voor het kiezen van een strategie

Jouw dataBegin met...
Titels, namen, korte beschrijvingen, tags, logregelstruncate
Lang maar met één onderwerp; of RAM is de harde limietmean
Documentatie, wiki's, kennisbanken, artikelen, README'srecursive, max_tokens 128–256
Support-tickets, e-mail, transcripts, juridische tekstensentence, max_tokens 128–256
OCR, gescrapte HTML, ongestructureerde dumpsfixed, max_tokens 256 + overlap
Chunks worden doorgegeven aan een LLM als contextsentence, max_tokens 384–512

De optimale chunk-grootte bepalen

Uit tests met de recursive strategie blijkt:

  1. Kleinere chunks winnen consistent: Het gaan van 512 naar 128 tokens verhoogt de recall@5 van 80,2% → 85,2% en verbetert de ranking (MRR) aanzienlijk.
  2. Overlap heeft weinig effect op kwaliteit: Bij gestructureerd proza voegt overlap nauwelijks kwaliteit toe, maar verhoogt het wel de kosten (meer vectoren en RAM).

Advies: Begin bij 128–256 tokens zonder overlap. Voeg overlap alleen toe als je meet dat feiten routinematig over de grens van een chunk vallen.

Praktijkvoorbeelden (Recipes)

Documentatie en Helpcentra

Lange Markdown-pagina's waarbij gebruikers in eigen woorden zoeken.

CREATE TABLE docs (
    url string,
    title text,
    body text,
    chunks float_vector_array knn_type='hnsw' hnsw_similarity='cosine'
           model_name='Xenova/all-MiniLM-L6-v2' from='title,body'
           chunk_strategy='recursive' max_tokens='192'
);

Support-tickets en E-mailthreads

Threads hebben geen natuurlijke lengtelimiet, dus gebruik max_chunks.

CREATE TABLE tickets (
    ticket_id bigint,
    customer string,
    status string,
    thread text,
    chunks float_vector_array knn_type='hnsw' hnsw_similarity='cosine'
           model_name='Xenova/all-MiniLM-L6-v2' from='thread'
           chunk_strategy='sentence' max_tokens='256' overlap_tokens='32' max_chunks='64'
);

Contracten en Beleidsdocumenten

Hier is retrieval op clausule-niveau essentieel.

chunks float_vector_array knn_type='hnsw' hnsw_similarity='cosine'
       model_name='Xenova/all-MiniLM-L6-v2' from='body'
       chunk_strategy='sentence' max_tokens='128' overlap_tokens='32'

Productcatalogus met lange beschrijvingen

Eén product is meestal één onderwerp.

embedding float_vector knn_type='hnsw' hnsw_similarity='cosine'
          model_name='Xenova/all-MiniLM-L6-v2' from='name,description'
          chunk_strategy='mean'

RAG: Retrieval voor een LLM

Chunks moeten leesbaar zijn als proza voor de prompt.

chunks float_vector_array knn_type='hnsw' hnsw_similarity='cosine'
       model_name='Xenova/all-MiniLM-L6-v2' from='title,body'
       chunk_strategy='sentence' max_tokens='512' overlap_tokens='64'

Vergelijking met andere zoekmachines

EngineEmbeds in-engineChunks in-engineStrategieënEén resultaat per doc
Manticore SearchJaJatruncate, mean, fixed, recursive, sentenceJa, native
ElasticsearchJaJasentence, word, recursive (9.1+), noneJa (via semantic_text)
OpenSearchJaJafixedtoken, fixedchar, delimiterNee (vereist nested field)
VespaJaJafixed-length, sentence, customJa
Azure AI SearchJaJapages (chars), sentencesNee (resultaat per chunk)
PostgreSQL+pgaiJaJacharacter, recursive characterNee (aparte tabel + join)
Milvus / QdrantJaNeeApp-side—

Manticore minimaliseert de complexiteit: de strategie is een optie op de kolom, de chunks zijn de waarde van de kolom, en de zoekopdracht retourneert documenten.

Wat chunking niet oplost

  1. Verlies van context: Een chunk weet niet waar hij vandaan komt. Een fragment als "voer één node tegelijk uit" is onbruikbaar zonder te weten welk product wordt bedoeld.
  • Oplossing: Voeg context toe aan de tekst voordat je deze invoert (bijv. herhaal de titel in elke sectie).
  1. Statische grenzen: Manticore splitst eerst en embedt daarna. "Late chunking" (eerst embedden, dan poolen) is een alternatief voor documenten die zwaar leunen op context tussen paragrafen, maar wordt momenteel niet ondersteund.

Beperkingen en valkuilen

  • max_chunks verwijdert tekst: Overschrijding wordt samengevoegd in de laatste chunk en vervolgens afgekapt tot het model-venster.
  • Remote modellen (OpenAI, etc.): Gebruiken bytes in plaats van tokens. Manticore schat dit op 3 bytes per token. In de praktijk worden chunks iets kleiner dan gevraagd.
  • ALTER TABLE: Multi-vector kolommen (floatvectorarray) kunnen niet worden toegevoegd via ALTER. Je moet de tabel opnieuw aanmaken.
  • Alleen voor auto-embeddings: Handmatig ingevoerde vectoren worden niet door Manticore gesplitst.
  • Queries worden niet gesplitst: Een query wordt altijd als één enkele vector behandeld.

Zelf proberen

De kortste weg naar een werkende chunked semantic search:

DROP TABLE IF EXISTS docs;
CREATE TABLE docs (
    title text,
    content text,
    chunks float_vector_array knn_type='hnsw' hnsw_similarity='cosine'
           model_name='Xenova/all-MiniLM-L6-v2' from='title,content'
           chunk_strategy='recursive' max_tokens='256' overlap_tokens='32'
);

INSERT INTO docs (id, title, content) VALUES
(1, 'Backup and restore runbook', 'Nightly backups run at 02:00 UTC ... ');

SELECT id, title, knn_dist() FROM docs
WHERE knn(chunks, 5, 'how do I rotate the replication certificate');