← Blog

E-commerce

Magento 2-zoek op pgvector: swap-playbook voor 12k SKU's

De verlengingsofferte van de search-vendor steeg ver genoeg dat de CFO vroeg: hebben we dit nog nodig? Een playbook om Magento 2-zoek over te zetten naar pgvector op 12.000 B2B-SKU's.

Jacob Molkenboer· Oprichter · A Brand New Company· 6 jun 2026· 8 min
Koperen weegschaal met klein pakketje aan linkerpan en stapel papieren labels aan rechterpan, op ivoorkleurig papier.

De verlengingsofferte van de search-vendor kwam binnen op een woensdagochtend. Het bedrag was zo gegroeid dat de CFO hem doorstuurde naar de operations lead met één regel: "Hebben we dit nog nodig?" De catalogus is rond de 12.000 SKU's aan bevestigingsmateriaal, fittingen en industriële reserveonderdelen, verkocht aan Nederlandse installateurs en aannemers. De shop draait op Magento 2.4. Search is het enige onderdeel van de stack dat bij een externe partij hangt, en alles eromheen staat al op Postgres.

Dit is het playbook dat we volgen wanneer een klant besluit de catalogus-search weer in huis te halen op Postgres met pgvector, terwijl de checkout ongemoeid blijft. Het werkt omdat de zoeklaag in Magento 2 een van de netst geabstraheerde onderdelen van het platform is, en omdat B2B-zoek vooral een kwestie is van het ontrafelen van SKU-codes en Nederlandse synoniemen, niet van crawl-schaal.

Waarom pgvector en niet Elasticsearch

Magento 2.4 heeft MySQL-search laten vallen en is voluit op Elasticsearch gegaan, met OpenSearch als ondersteunde fork vanaf 2.4.6. Allebei werken prima. Geen van beide is het eerste waar je naar grijpt als de rest van je stack Postgres is en je zoekprobleem 12.000 documenten groot is.

Een OpenSearch-cluster dat productie aankan heeft een JVM-heap, een replica, een coordinator en een onderhoudsritme. pgvector is een Postgres-extensie die een vector-kolomtype en twee indexmethodes toevoegt (IVFFlat en HNSW). Op 12k rijen antwoordt hij onder de 5 ms op een kleine Hetzner-bak, en hij indexeert 's nachts op dezelfde database waar je orders al in staan. Je back-upt het met pg_dump. Je schaalt het door Postgres te upgraden.

De afweging: pgvector is geen search engine. Er is geen document mapping language, geen analyzer chain, geen UI voor relevance tuning. Je krijgt een vector-kolom en een similarity-operator, en de rest schrijf je in SQL. Voor 12k SKU's is dat een voordeel, geen beperking.

Wat blijft, wat verandert, en waar de checkout-grens ligt

Magento 2 stuurt elke search-query via Magento\Search\Model\SearchEngine, die hem doorzet naar de geregistreerde adapter. De productresultatenpagina, layered navigation, search-as-you-type-widget en het "bedoelde je"-blok consumeren allemaal die adapter. De cart, de quote, de btw-berekening en de payment capture niet. Die lopen via heel andere services.

Dat geeft je een schone knip. Je vervangt de search-adapter en de indexer die hem voedt. De rest van Magento laat je met rust. Een klant kan nog steeds een product in z'n cart leggen, een order plaatsen en betalen via iDEAL of je kaartverwerker, zonder dat één code path pgvector raakt.

Kort gezegd

Raakt jouw swap iets voorbij de zoekresultatenpagina, dan heb je de scope verkeerd getrokken. De grens is de adapter-interface, en Magento bewaakt die voor je.

Stap 1: spiegel de catalogus naar Postgres

Ook als je operationele database MySQL is (de Magento-default), leeft de search-index in zijn eigen Postgres-database. Behandel hem als een read-side projectie. Magento publiceert catalog-events waar je je op kunt abonneren: catalog_product_save_after, catalog_product_delete_commit_after, attribute-set-wijzigingen en stock-updates.

Voor de eerste build doe je een volledige dump. Een directe query over de EAV-tabellen werkt voor kleine catalogi, maar is broos. Gebruik het REST-endpoint /rest/V1/products met een store-scoped token. Die platst EAV al uit, lost attribute-labels op en respecteert multi-store-zichtbaarheid. Page hem per 200 en schrijf naar een staging-tabel.

CREATE TABLE catalog_search_doc (
  sku            text PRIMARY KEY,
  entity_id      bigint NOT NULL,
  name           text NOT NULL,
  description    text,
  manufacturer   text,
  ean            text,
  attrs          jsonb NOT NULL DEFAULT '{}'::jsonb,
  price_cents    integer,
  in_stock       boolean,
  updated_at     timestamptz NOT NULL DEFAULT now()
);

In het attrs-JSONB-veld zitten de B2B-specifieke velden: spoed van de draad, kopvorm, materiaalkwaliteit, certificaatreferenties. Die hoef je niet te normaliseren naar kolommen. Je bevraagt ze met predicaten als attrs ? 'rvs_a2', en GIN maakt dat goedkoop.

Stap 2: kies het embedding-model

Voor een Nederlandse B2B-catalogus telt het embedding-model zwaarder dan de vector-index. Engelstalige modellen missen synoniemen die een schroevenkoper als vanzelfsprekend gebruikt: "RVS A2" tegenover "stainless steel 304", "zeskant" tegenover "hex", "verzinkt" tegenover "galvanised". Je wilt een meertalig model dat getraind is op Europese talen.

Twee redelijke opties: BAAI/bge-m3 (1024 dimensies, draait op CPU voor een catalogus van 12k, MIT-achtige licentie) en OpenAI's text-embedding-3-small (1536 dimensies, gehost, kost ongeveer een euro voor het hele corpus). Wij kiezen standaard bge-m3, omdat de embeddings op je eigen hardware blijven en het opnieuw embedden van de catalogus na een model-upgrade niets kost.

Embed de samenvoeging van name, manufacturer, kernattributen en de eerste 300 woorden van de description. Embed niet de hele HTML-body. De meeste productbeschrijvingen in B2B-catalogi zijn voor 80% boilerplate ("Levertijd 2-3 werkdagen, prijzen excl. BTW") die het signaal wegdrukt.

def doc_text(row):
    parts = [
        row["name"],
        row["manufacturer"] or "",
        " ".join(f"{k}: {v}" for k, v in row["attrs"].items()),
        (row["description"] or "")[:1200],
    ]
    return "\n".join(p for p in parts if p).strip()

embeddings = model.encode(
    [doc_text(r) for r in batch],
    normalize_embeddings=True,
)

Stap 3: indexontwerp

De uiteindelijke index heeft drie lagen: een gegenereerde tsvector voor lexicale search, een vector-kolom voor semantische search, en een paar GIN-indexen voor gestructureerde filters.

ALTER TABLE catalog_search_doc
  ADD COLUMN embedding vector(1024),
  ADD COLUMN fts tsvector GENERATED ALWAYS AS (
    setweight(to_tsvector('dutch',  coalesce(name,'')),         'A') ||
    setweight(to_tsvector('simple', coalesce(sku,'')),          'A') ||
    setweight(to_tsvector('dutch',  coalesce(manufacturer,'')), 'B') ||
    setweight(to_tsvector('dutch',  coalesce(description,'')),  'C')
  ) STORED;

CREATE INDEX catalog_fts_idx   ON catalog_search_doc USING gin (fts);
CREATE INDEX catalog_attrs_idx ON catalog_search_doc USING gin (attrs);
CREATE INDEX catalog_hnsw_idx  ON catalog_search_doc
  USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 64);

Het 'simple'-woordenboek op de SKU-kolom is een bewuste keuze. Je wilt niet dat Postgres "M8x40" stemt naar "m8x". Het 'dutch'-woordenboek op de leesbare velden geeft je stemming op Nederlandse woorden (zodat "schroeven" matcht met "schroef") zonder dat je zelf een synoniemenlijst hoeft te onderhouden.

Let op

De bouwtijd van HNSW schaalt mee met het aantal rijen. Op 12k rijen is hij klaar in seconden, op 12 miljoen kan het een uur duren. Groei je richting miljoenen, bouw de index dan op een replica en promoot die.

Stap 4: de hybride query

Een vector-only query verliest van een lexicale query op het moment dat een koper een exacte SKU intypt. Een lexicale-only query verliest op het moment dat iemand "schroef voor buitengevel rvs" intypt. Je wilt allebei, gewogen, met een kleine tiebreaker op voorraadstatus. Dat is één SQL-statement:

WITH q AS (
  SELECT
    $1::text   AS qtext,
    $2::vector AS qemb
)
SELECT
  d.sku,
  d.name,
  d.entity_id,
  ts_rank_cd(d.fts, plainto_tsquery('dutch', q.qtext)) AS lex,
  1 - (d.embedding <=> q.qemb)                         AS sem,
  (
      0.45 * ts_rank_cd(d.fts, plainto_tsquery('dutch', q.qtext))
    + 0.55 * (1 - (d.embedding <=> q.qemb))
    + CASE WHEN d.in_stock THEN 0.05 ELSE 0 END
  ) AS score
FROM catalog_search_doc d, q
WHERE
  d.fts @@ plainto_tsquery('dutch', q.qtext)
  OR (d.embedding <=> q.qemb) < 0.55
ORDER BY score DESC
LIMIT 48;

De verdeling 0.45/0.55 is een startpunt, geen wet. Ga met de operations lead zitten, draai twintig echte queries uit de search-logs van vorige week, kijk welke resultatenlijst ze liever zien, en stel bij. We hebben B2B-catalogi gezien waar het lexicale gewicht naar 0.7 moest omdat SKU-verkeer overheerst, en consumentencatalogi waar het naar 0.25 zakt.

Stap 5: registreer de adapter in Magento

De selectie van Magento's search engine verloopt via etc/search_engine.xml in je module. Implementeer Magento\Framework\Search\AdapterInterface, registreer hem als pgvector, en zet hem als actieve engine in app/etc/env.php of via het admin-paneel.

<?php
namespace Abn\PgvectorSearch\SearchAdapter;

use Magento\Framework\Search\AdapterInterface;
use Magento\Framework\Search\RequestInterface;
use Magento\Framework\Search\Response\QueryResponse;
use Magento\Framework\Search\Response\QueryResponseFactory;

class Adapter implements AdapterInterface
{
    public function __construct(
        private \Abn\PgvectorSearch\Client $client,
        private QueryResponseFactory $responseFactory,
    ) {}

    public function query(RequestInterface $request): QueryResponse
    {
        $text = $this->extractQueryText($request);
        $hits = $this->client->search($text, limit: 48);

        $documents = array_map(
            fn($h) => ['entity_id' => $h['entity_id'], 'score' => $h['score']],
            $hits,
        );

        return $this->responseFactory->create([
            'documents'    => $documents,
            'aggregations' => $this->client->facets($text),
            'total'        => count($documents),
        ]);
    }
}

De client-klasse is een dunne PHP-wrapper rond de SQL hierboven. Hij roept een kleine embedding-service aan (een FastAPI-proces dat bge-m3 draait) voor de query-vector, en draait daarna de SQL via PDO. Op een warme connection pool zit je op een paar milliseconden per request.

Stap 6: shadow-verkeer voordat je live gaat

Wissel de search engine op productie niet op een vrijdagmiddag. Draai pgvector twee weken in shadow-modus. Elke search-request gaat naar de bestaande engine voor het zichtbare resultaat, en naar pgvector alleen voor logging. Vergelijk de top-10 op dezelfde query en log het symmetrische verschil.

SELECT
  qtext,
  count(*) FILTER (WHERE jaccard_top10 >= 0.7) AS agree,
  count(*) FILTER (WHERE jaccard_top10 <  0.3) AS diverge,
  count(*) AS n
FROM search_shadow_log
WHERE captured_at > now() - interval '7 days'
GROUP BY qtext
ORDER BY n DESC
LIMIT 200;

Sorteer de afwijkende queries op frequentie. Loop de top 50 door met iemand die de catalogus kent. In de meeste gevallen heeft pgvector gelijk en zat de oude engine ernaast, maar je hebt een mens nodig om dat te bevestigen. De gevallen waar pgvector wél fout zit, zijn bijna altijd ontbrekende synoniemen in je embedded tekst, geen modelfout.

Operationele notities

Een paar dingen waarvan we wilden dat we ze eerder hadden geweten.

Reindex incrementeel, niet nachtelijk. Het event catalog_product_save_after in Magento vuurt per save. Zet de SKU in een queue, debounce 5 seconden, re-embed, upsert. Een nachtelijke volledige reindex is op 12k SKU's acceptabel, maar hij verbergt datakwaliteitsproblemen totdat ze pijn doen.

Let op maintenance_work_mem als je de HNSW-index herbouwt. De default van 64MB is prima voor een kleine index, maar op 1024 dimensies en een miljoen rijen wil je minstens 2GB.

Cache de query-embedding, niet de resultaatset. De vector voor "schroef m8x40 rvs" is elke keer identiek. De resultaatset verandert zodra de voorraad verandert.

Houd de oude engine een volledig kwartaal warm. Terugschakelen is één regel config. De cluster weggooien is onomkeerbaar.

Wat je vanmiddag kunt doen

Toen we de pgvector-swap bouwden voor de bovengenoemde catalogus met bevestigingsmateriaal, was niet de SQL of de Magento-module het tijdrovendste deel. Het tijdrovendste was met het magazijnteam gaan zitten en de twintig Nederlandse synoniemen opschrijven die hun klanten daadwerkelijk intypen. Dat werk betaalt zich uit, of je nu pgvector, Typesense of een afgestelde OpenSearch uitrolt. Wil je hulp om dezelfde swap end-to-end te draaien, dan dekt onze migratiepraktijk voor verouderde stacks de Magento-adapter, de indexer en de shadow-traffic-gate.

Open vandaag je search-logs, sorteer de queries op frequentie en lees de top vijftig hardop voor. Het patroon vertelt je of je zoekprobleem lexicaal is, semantisch, of een voorraadprobleem in zoek-vermomming.

Kern

De search-adapter van Magento 2 is het schoonste swap-punt in de stack. Vervang hem door een pgvector-index en de checkout merkt nooit dat je verhuisd bent.

FAQ

Breekt dit de layered navigation of facetten van Magento?

Nee. De adapter retourneert nog steeds aggregations. Je berekent facetten in SQL over de catalog_search_doc-tabel, gegroepeerd per attribuut, en Magento rendert ze via z'n bestaande layered-nav-UI.

Wat gebeurt er met de bestaande Elasticsearch- of externe engine tijdens de overstap?

Draai ze twee weken parallel in shadow-modus. Elke query gaat alleen voor logging naar pgvector, terwijl de oude engine nog gebruikers bedient. De overstap is een wijziging van één regel config zodra de divergentie op de topqueries acceptabel is.

Heb ik een GPU nodig om de embeddings te draaien?

Niet voor 12.000 SKU's. Het bge-m3-model embedt op CPU de volledige catalogus in ongeveer een uur. Incrementele updates draaien op een paar honderd SKU's per minuut, ruim binnen het budget van een event-driven reindex.

Hoe ga je om met voorraad- en prijswijzigingen?

Abonneer je op de stock- en price-events van Magento en upsert de in_stock- en price_cents-kolommen. Je hoeft niet te re-embedden als alleen voorraad of prijs verandert; de vector blijft hetzelfde.

magentoe-commercemigrationarchitectureintegrations

Iets bouwen?

Start een project