Full-text search in Postgres wordt volwassen: wat Tin betekent voor je .NET-zoekfunctie

PlanetScale bracht Tin uit, een BM25-zoekextensie voor Postgres met benchmarkcijfers die je twee keer laat lezen. Dit is wat het verandert voor een .NET-team, met werkende EF Core-code, en waar ik toch nog buiten Postgres zou kijken.

Jean-Pierre Broeders

Freelance .NET Developer

21 september 202612 min. leestijd
Full-text search in Postgres wordt volwassen: wat Tin betekent voor je .NET-zoekfunctie

Deze week zette PlanetScale "Tin: full-text search for Postgres" bovenaan Hacker News, en de cijfers in dat stuk zijn lastig te negeren. Tin is een nieuwe extensie die BM25-scoring aan Postgres toevoegt. PlanetScale claimt dat Tin 541 keer meer queries per seconde aankan dan een gewone GIN-index op conjunctie- en frasequeries, met p99-latencies die drie ordes van grootte lager liggen, en 25 tot 36 keer meer doorvoer dan ParadeDB bij gemengde workloads met gelijktijdige schrijfacties.

Ik las dat met een stuk van mezelf in mijn achterhoofd. Een maand geleden schreef ik op deze blog dat full-text search in Postgres prima is voor een zoekvak dat mensen af en toe gebruiken, maar dat je een grens raakt zodra zoeken de kernfunctie van je product wordt, en dat een echte zoekmachine dan geen luxe meer is. Dat advies klopte toen ik het schreef. Tin, en ParadeDB daarvoor, schuift die grens stilletjes op. Dus laat ik doorlopen wat er echt veranderd is, in termen waar een .NET-team iets mee kan, met code die je kunt draaien.

De grens die ik trok, en waarom die er stond

Postgres levert al jaren full-text search. Het werkt, het is geïndexeerd, en voor een blog of een docs-site is het echt alles wat je nodig hebt. De reden dat ik mensen aanraadde om weg te gaan voor iets serieuzers was niet dat Postgres geen tekst kon matchen. Het ging om relevantie en om snelheid bij een bepaalde vorm van query.

De native ranking van Postgres gebruikt ts_rank, en ts_rank is grof. Het telt termfrequentie en gewichten, maar het heeft geen echt idee van hoe zeldzaam een term is in je hele corpus. BM25, het algoritme dat elke echte zoekmachine gebruikt, wel. Dat verschil is het gat tussen "de resultaten bevatten mijn woorden" en "het beste resultaat staat bovenaan". Voor een product waar zoeken de functie is, is dat gat alles.

Het tweede probleem is mechanisch. Een GIN-index over een tsvector is uitstekend in het beantwoorden van "welke rijen bevatten dit woord". Hij is veel slechter in frasequeries en conjuncties met meerdere termen, omdat hij grote posting lists moet ophalen en snijden, en onder gelijktijdige schrijfacties kan hij opzwellen en op grote datasets zonder geheugen komen te zitten. Dat is de muur waar mensen tegenaan lopen.

Hoe zoeken in Postgres echt werkt

Voordat je naar iets nieuws grijpt, helpt het om precies te weten wat je al hebt, want voor heel veel apps is dat genoeg. Hier is het hele ding in gewone SQL.

CREATE TABLE articles (
    id     bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    title  text NOT NULL,
    body   text NOT NULL
);

-- Een generated column houdt de zoekvector synchroon met de brontekst.
ALTER TABLE articles
    ADD COLUMN search tsvector
    GENERATED ALWAYS AS (
        to_tsvector('english', title || ' ' || body)
    ) STORED;

CREATE INDEX idx_articles_search ON articles USING gin (search);

Een query ziet er dan zo uit, met ts_rank die de volgorde bepaalt:

SELECT id, title, ts_rank(search, query) AS rank
FROM articles, plainto_tsquery('english', 'skip locked queue') AS query
WHERE search @@ query
ORDER BY rank DESC
LIMIT 20;

Het goede nieuws voor .NET-mensen is dat de Npgsql EF Core-provider dit allemaal mapt zonder dat je naar ruwe SQL hoeft. Je modelleert de vector als een echte property en laat de provider de generated column en de GIN-index voor je aanmaken.

public class Article
{
    public long Id { get; set; }
    public string Title { get; set; } = "";
    public string Body { get; set; } = "";
    public NpgsqlTsVector Search { get; set; } = null!;
}

protected override void OnModelCreating(ModelBuilder b)
{
    b.Entity<Article>()
        .HasGeneratedTsVectorColumn(
            a => a.Search,
            "english",
            a => new { a.Title, a.Body })
        .HasIndex(a => a.Search)
        .HasMethod("GIN");
}

En de query, volledig in LINQ, gerankt en gepagineerd:

public async Task<List<Article>> SearchAsync(string term, CancellationToken ct)
{
    var query = EF.Functions.PlainToTsQuery("english", term);

    return await _db.Articles
        .Where(a => a.Search.Matches(query))
        .OrderByDescending(a => a.Search.Rank(query))
        .Take(20)
        .ToListAsync(ct);
}

Dat is een werkende, geïndexeerde, gerankte zoekfunctie in een .NET-app zonder ook maar één extra stuk infrastructuur. Is je zoekvak een gemak en niet het product, stop dan hier. Je bent klaar, en elke extensie die ik hieronder bespreek is ballast die je niet hoeft te dragen.

Waar GIN vastloopt

Het gedoe begint wanneer zoeken geen gemak meer is. Stel, je bouwt een zoekfunctie voor supporttickets, of codezoeken, of een productcatalogus waar mensen lange, specifieke frases intikken en de scherpste match bovenaan verwachten. Drie dingen bijten.

Relevantie is het eerste, en dat behandelde ik hierboven: ts_rank geeft je geen BM25-volgorde, dus je bovenste resultaat is vaak gewoon een resultaat. Het tweede is de prestatie op frases en conjuncties. Vraag om vier woorden die allemaal dicht bij elkaar moeten staan, over tien miljoen rijen, en de GIN-index doet een hoop lijstsnijwerk waar hij niet voor gebouwd is. Het derde is schrijfconcurrency. Een drukke tabel met constante inserts en updates maakt GIN-onderhoud duur, en ik heb een index zien opzwellen en daarna omvallen onder precies dat patroon.

Niets hiervan betekent dat zoeken in Postgres kapot is. Het betekent dat de standaardmachinerie ontworpen is voor een andere vorm van probleem dan "zoeken is het product".

Wat Tin en ParadeDB anders doen

Een tijd lang was het antwoord daarop ParadeDB, dat een pg_search-extensie levert bovenop een Tantivy-achtige inverted index in Postgres, met echte BM25-scoring en een eigen operator. Tin is de versie van PlanetScale op datzelfde idee, en de belofte is dat het zowel sneller is als Postgres-eigener in hoe het dingen opslaat.

Het interessante zit in hoe Tin aan zijn snelheid komt, en dat is de moeite waard om te snappen omdat het de afwegingen verklaart. Volgens PlanetScale gebruikt Tin de Postgres-ctid, de fysieke tuple-identifier, direct als document-id in plaats van een eigen doorlopende nummering bij te houden. Die ene keuze werkt door. Omdat document-ids op fysieke pagina's mappen, kan Tin postings in bitmaps op paginaniveau proppen, waar een veelvoorkomende term ongeveer één bit per posting kost. Die bitmaps passen in de vectorregisters van de CPU, dus een AND of OR over twee termen wordt een vectorinstructie in plaats van een lus over posting lists. En omdat het documenten nooit hernummert, vermijdt het samenvoegen van indexsegmenten de write amplification die merges normaal duur maakt. Zichtbaarheidschecks voor MVCC gebeuren op indexniveau tegen de visibility map, dus je krijgt correcte transactionele resultaten zonder aparte lookup per rij.

Dat is een echt slim ontwerp. Het is ook waarom de benchmarkgaten tegenover GIN zo groot zijn: GIN doet algemeen lijstwerk waar Tin bitwise rekenwerk doet op data die precies hiervoor is neergelegd.

Een BM25-extensie koppelen aan .NET

Hier zit de praktische adder voor een .NET-ontwikkelaar. Deze extensies stellen eigen operators beschikbaar, geen standaard tsvector-matching, dus de EF Core LINQ-mapping die ik hierboven liet zien dekt ze niet. Je zakt af naar ruwe SQL. Dat is prima, FromSql houdt het geparametriseerd en veilig.

Bij ParadeDB, waarvan de syntax vandaag gedocumenteerd is, ziet een zoekopdracht er zo uit:

public async Task<List<Article>> Bm25SearchAsync(string term, CancellationToken ct)
{
    return await _db.Articles
        .FromSql($@"
            SELECT * FROM articles
            WHERE title @@@ {term} OR body @@@ {term}
            ORDER BY paradedb.score(id) DESC
            LIMIT 20")
        .ToListAsync(ct);
}

De @@@-operator raakt de BM25-index en paradedb.score(id) geeft je de relevantiescore om op te ordenen. Tin is jonger, dus check zijn eigen documentatie voor de exacte operator en scorefunctie voordat je deze vorm kopieert. Het patroon is hoe dan ook hetzelfde: een eigen index, een eigen match-operator, en ruwe SQL vanuit je datalaag omdat LINQ geen idee heeft dat deze operators bestaan.

En dan de kanttekening die dit voor veel teams beslist nog voor er een benchmark aan te pas komt. Zowel Tin als ParadeDB zijn extensies, en managed Postgres laat je niet installeren wat je wilt. Amazon RDS, Azure Database for PostgreSQL en Google Cloud SQL leveren elk een vaste allowlist, en een gloednieuwe extensie staat daar op dag één niet op. Supabase en Neon zijn sneller met sommige extensies, en PlanetScale levert Tin op zijn eigen platform, maar draait je Postgres bij een hyperscaler, dan kun je dit misschien simpelweg nog niet draaien zonder zelf te hosten. Native tsvector-zoeken zit daarentegen in elke Postgres, overal. Dat verschil in beschikbaarheid is een echte kostenpost, en die vergeet je makkelijk als je naar een cijfer van 541x staart.

De cijfers, en waarom ik ze niet als feit napraat

Ik citeerde de benchmarks van PlanetScale bovenaan omdat dat het nieuws is. Ik ga niet doen alsof ze op jouw data standhouden. Zoekbenchmarks worden berucht gevormd door het corpus, de querymix, de verhouding tussen lezen en schrijven, en de hardware. Een winst van 541x op synthetische conjunctiequeries vertelt je dat het plafond hoog ligt. Het vertelt je niet wat jouw ticketzoekfunctie doet bij jouw aantal rijen met jouw updatetempo.

Behandel de claim dus als reden om te testen, niet als resultaat dat je op de bank kunt zetten. Laad een echte plak van je eigen data, speel echte queries uit je logs opnieuw af, en meet latency op p50 en p99 terwijl er tegelijk geschreven wordt. Het hele argument voor deze extensies is dat ze standhouden onder gelijktijdige schrijfacties waar GIN dat niet doet, dus een read-only benchmark mist het punt.

Wanneer ik toch buiten Postgres zou kijken

Zelfs met Tin in beeld zou ik zoeken in een paar gevallen nog op een eigen, aparte machine zetten. Heb je facetten, typotolerantie en synoniemen out of the box nodig, plus een query-DSL die je productteam kan uitbreiden, dan geeft een tool die voor zoeken gebouwd is, zoals Elasticsearch, Typesense of Meilisearch, je nog altijd meer dan een Postgres-extensie. Moet je zoekindex horizontaal over nodes schalen los van je transactionele database, dan is dat een taak die Postgres niet probeert te doen. En draai en begrijp je al een van die engines, dan is er een extensie bij zetten om hem weg te halen churn, geen vereenvoudiging.

Wat wel veranderd is, is de drempel. Een maand geleden had ik gezegd dat elke serieuze zoekfunctie buiten Postgres hoort. Nu zou ik zeggen: probeer eerst de BM25-extensie als je hem kunt draaien, meet hem eerlijk, en tuig pas een apart zoekcluster op als de cijfers of de featureset je er echt toe dwingen. Voor een middelgrote .NET-app is zoeken houden in de database die je toch al back-upt en al begrijpt een hoop operationele rust waard.

Hoe ik het vandaag zou beslissen

Wees eerlijk over wat zoeken is in je product. Is het een vak dat mensen af en toe gebruiken, dan is native tsvector met een GIN-index en de EF Core-mapping hierboven het juiste antwoord. Is zoeken een kernfunctie en loop je tegen relevantie- of frasepijn aan, kijk dan naar een BM25-extensie, maar check eerst of je hosting je hem überhaupt laat installeren. Doet die dat niet, of heb je de volle featureset van een zoekmachine nodig, reken dan op een aparte engine en behandel het als de operationele kostenpost die het is.

De les van Tin is niet dat Postgres nu Elasticsearch vervangt. Het is dat het punt waarop je gedwongen wordt Postgres te verlaten verder is opgeschoven, en dat is goed nieuws voor iedereen die liever één database draait dan drie.

Veelgestelde vragen over full-text search in Postgres met .NET

Wanneer is native tsvector-zoeken genoeg voor een .NET-app? Wanneer zoeken een gemak is en niet de kernfunctie. Een generated tsvector-kolom met een GIN-index, gemapt via Npgsql EF Core, geeft je geïndexeerd, gerankt en gepagineerd zoeken zonder extra infrastructuur, en het schaalt tot in de miljoenen rijen voor normaal zoekvakgebruik.

Waarom is ts_rank niet goed genoeg voor een product waar zoeken centraal staat? Omdat ts_rank scoort op termfrequentie en gewichten maar geen echt corpusbreed zeldzaamheidssignaal heeft, dus het kan geen BM25-volgorde produceren. Je meest relevante resultaat is daardoor vaak gewoon een resultaat, wat prima is voor een zoekvak en niet prima wanneer de beste match vinden het product is.

Kan ik Tin of ParadeDB direct vanuit EF Core LINQ gebruiken? Niet via de standaard tsvector-mapping, want ze stellen eigen operators beschikbaar zoals de @@@ van ParadeDB die LINQ niet kent. Je roept ze aan met FromSql, waarbij je parameters gebonden houdt zodat de query veilig blijft, en je gebruikt hun scorefunctie in de ORDER BY.

Draaien deze extensies op mijn managed Postgres? Misschien nog niet. RDS, Azure Database for PostgreSQL en Cloud SQL staan elk alleen een allowlist van extensies toe, en een nieuwe staat daar zelden meteen op, dus je hebt mogelijk een platform nodig dat hem levert of een zelfgehoste instance. Native tsvector-zoeken is daarentegen in elke Postgres beschikbaar.

Moet ik het benchmarkcijfer van 541x vertrouwen? Behandel het als reden om te testen, niet als resultaat. Zoekprestaties hangen sterk af van je corpus, querymix, lees-schrijfverhouding en hardware, dus laad een echte plak van je eigen data, speel echte queries opnieuw af, en meet p50- en p99-latency terwijl er gelijktijdig geschreven wordt voordat je beslist.

Verder lezen


Conclusie: Tin maakt van Postgres geen Elasticsearch, maar het schuift het moment op waarop je gedwongen wordt te vertrekken. Probeer de BM25-extensie op je eigen data voordat je een apart zoekcluster optuigt, en houd native tsvector voor alles wat echt gewoon een zoekvak is.

Bronnen: Introducing Tin (PlanetScale) · Hacker News-discussie.

Wil je op de hoogte blijven?

Schrijf je in voor mijn nieuwsbrief of neem contact op voor freelance projecten.

Neem Contact Op