Terug naar blog
searchfull-text-searchpostgresqlelasticsearchtypesensesaas

Zoekfunctionaliteit in je SaaS: van simpele filters tot full-text search

Door SaaS Masters19 maart 202613 min leestijd

Zoekfunctionaliteit in je SaaS: van simpele filters tot full-text search

Gebruikers verwachten dat ze in je applicatie alles razendsnel kunnen vinden. Of het nu gaat om producten, documenten, klanten of berichten — een goede zoekervaring is het verschil tussen een tool die mensen dagelijks gebruiken en eentje die ze na een week alweer vergeten. In dit artikel duiken we diep in de verschillende opties voor zoekfunctionaliteit in je SaaS, van database-level queries tot dedicated search engines.

Waarom zoek zo belangrijk is

Onderzoek laat keer op keer zien dat gebruikers die de zoekfunctie gebruiken 2-3x meer converteren dan gebruikers die alleen browsen. In een SaaS-context betekent dat:

  • Hogere retentie: gebruikers vinden sneller wat ze nodig hebben
  • Lagere support-load: minder "waar vind ik X?"-tickets
  • Betere data-discovery: gebruikers ontdekken features die ze anders zouden missen

Toch behandelen veel SaaS-founders zoekfunctionaliteit als een afterthought. Laten we dat veranderen.

Niveau 1: SQL LIKE en ILIKE

De simpelste vorm van zoeken doe je rechtstreeks in je database:

SELECT * FROM products 
WHERE name ILIKE '%zoekterm%' 
   OR description ILIKE '%zoekterm%';

Wanneer dit prima werkt

  • Kleine datasets (< 10.000 records)
  • Eenvoudige exacte matches
  • Admin-panels waar snelheid minder kritiek is

Beperkingen

  • Geen relevantie-ranking: alle resultaten zijn "gelijk"
  • Geen fuzzy matching: een typo = geen resultaten
  • Performance: LIKE '%term%' kan geen index gebruiken en wordt trager naarmate je tabel groeit
  • Geen synoniemen of stemming: "fietsen" matcht niet met "fiets"

Niveau 2: PostgreSQL Full-Text Search

Als je al PostgreSQL gebruikt (en dat doen de meeste moderne SaaS-applicaties), heb je een verrassend krachtige zoekengine ingebouwd:

-- Voeg een tsvector-kolom toe
ALTER TABLE products ADD COLUMN search_vector tsvector;

-- Vul de vector
UPDATE products SET search_vector = 
  setweight(to_tsvector('dutch', coalesce(name, '')), 'A') ||
  setweight(to_tsvector('dutch', coalesce(description, '')), 'B');

-- Maak een GIN-index
CREATE INDEX idx_products_search ON products USING GIN(search_vector);

-- Zoek met ranking
SELECT name, ts_rank(search_vector, query) AS rank
FROM products, plainto_tsquery('dutch', 'cloud hosting') AS query
WHERE search_vector @@ query
ORDER BY rank DESC;

Voordelen

  • Geen extra infrastructuur: het zit al in je database
  • Relevantie-ranking: gewogen scores op basis van waar de match voorkomt
  • Taalondersteuning: stemming voor Nederlands, Engels en 20+ talen
  • Trigram-extensie: fuzzy matching via pg_trgm

Trigram matching voor typo-tolerantie

CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_products_name_trgm ON products USING GIN(name gin_trgm_ops);

-- Zoek met typo-tolerantie
SELECT name, similarity(name, 'clou hotsing') AS sim
FROM products
WHERE name % 'clou hotsing'
ORDER BY sim DESC;

Wanneer PostgreSQL FTS genoeg is

  • Tot ~1 miljoen records
  • Wanneer je geen apart systeem wilt beheren
  • Standaard zoekfunctionaliteit zonder geavanceerde features
  • Je wilt transactionele consistentie (zoekindex is altijd up-to-date)

Niveau 3: Dedicated Search Engines

Wanneer je zoekeisen groeien, is het tijd voor een dedicated oplossing. De drie grote spelers:

Elasticsearch / OpenSearch

De industriestandaard voor full-text search:

// Indexeer een document
await client.index({
  index: 'products',
  body: {
    name: 'Cloud Hosting Pro',
    description: 'Schaalbare hosting voor SaaS-applicaties',
    tags: ['hosting', 'cloud', 'saas'],
    price: 49.99,
    createdAt: new Date()
  }
});

// Zoek met boosting en fuzzy matching
const result = await client.search({
  index: 'products',
  body: {
    query: {
      bool: {
        should: [
          { match: { name: { query: 'cloud hosting', boost: 2, fuzziness: 'AUTO' } } },
          { match: { description: { query: 'cloud hosting', fuzziness: 'AUTO' } } },
          { match: { tags: { query: 'cloud hosting', boost: 1.5 } } }
        ]
      }
    },
    highlight: {
      fields: { name: {}, description: {} }
    }
  }
});

Voordelen: extreem krachtig, schaalbaar tot miljarden documenten, rijke query-DSL, aggregaties. Nadelen: resource-intensief (RAM-hongerig), complexe ops, sync met primaire database vereist.

Typesense

Een modernere, developer-friendly alternatief:

// Schema definiëren
await typesense.collections().create({
  name: 'products',
  fields: [
    { name: 'name', type: 'string' },
    { name: 'description', type: 'string' },
    { name: 'price', type: 'float', facet: true },
    { name: 'tags', type: 'string[]', facet: true }
  ],
  default_sorting_field: 'price'
});

// Zoek met typo-tolerantie (standaard aan!)
const results = await typesense
  .collections('products')
  .documents()
  .search({
    q: 'clod hostnig',  // typos worden automatisch gecorrigeerd
    query_by: 'name,description,tags',
    query_by_weights: '3,1,2',
    filter_by: 'price:<100',
    facet_by: 'tags',
    sort_by: '_text_match:desc,price:asc'
  });

Voordelen: snelle setup, typo-tolerantie out-of-the-box, laag geheugenverbruik, geo-search ingebouwd. Nadelen: minder geavanceerde query-mogelijkheden dan Elasticsearch, kleinere community.

Meilisearch

Vergelijkbaar met Typesense, met focus op snelheid:

await meili.index('products').addDocuments(products);

const results = await meili.index('products').search('cloud hosting', {
  attributesToHighlight: ['name', 'description'],
  filter: 'price < 100',
  facets: ['tags'],
  limit: 20
});

Voordelen: razendsnelle responses (< 50ms), simpele API, multi-tenancy support. Nadelen: minder geschikt voor analytics/aggregaties, beperktere filtering.

De synchronisatie-uitdaging

Het grootste probleem met een externe search engine is data-synchronisatie. Je primaire database is de bron van waarheid, maar je zoekindex moet up-to-date blijven.

Strategie 1: Synchrone updates

async function createProduct(data) {
  const product = await prisma.product.create({ data });
  
  // Direct indexeren
  await searchClient.index('products').addDocuments([product]);
  
  return product;
}

Probleem: als de search-indexering faalt, heb je inconsistente data. En het maakt je write-path trager.

Strategie 2: Event-driven sync (aanbevolen)

// Bij elke database-mutatie: push event naar queue
async function createProduct(data) {
  const product = await prisma.product.create({ data });
  
  await queue.publish('product.created', { productId: product.id });
  
  return product;
}

// Worker luistert naar events
queue.subscribe('product.*', async (event) => {
  const product = await prisma.product.findUnique({ 
    where: { id: event.productId } 
  });
  
  if (event.type === 'product.deleted') {
    await searchClient.index('products').deleteDocument(event.productId);
  } else {
    await searchClient.index('products').addDocuments([product]);
  }
});

Strategie 3: Periodieke re-index

Voor minder kritieke use cases kun je periodiek je hele index rebuilden:

// Dagelijkse cron job
async function reindexProducts() {
  const products = await prisma.product.findMany();
  await searchClient.index('products').addDocuments(products, { primaryKey: 'id' });
}

Search UX: de details die het verschil maken

Een goede zoekengine is maar de helft van het verhaal. De UX maakt of breekt de ervaring:

1. Instant search (search-as-you-type)

// React component met debounce
function SearchBar() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 200);
  
  const { data: results } = useSWR(
    debouncedQuery ? `/api/search?q=${debouncedQuery}` : null,
    fetcher
  );

  return (
    <div className="relative">
      <input
        type="search"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Zoek producten..."
        className="w-full px-4 py-2 border rounded-lg"
      />
      {results && (
        <div className="absolute top-full mt-1 w-full bg-white shadow-lg rounded-lg">
          {results.hits.map(hit => (
            <SearchResult key={hit.id} hit={hit} />
          ))}
        </div>
      )}
    </div>
  );
}

2. Highlighting van matches

Laat gebruikers zien waarom een resultaat matcht:

function SearchResult({ hit }) {
  return (
    <div className="p-3 hover:bg-gray-50">
      <h3 dangerouslySetInnerHTML={{ 
        __html: hit._highlightResult?.name?.value || hit.name 
      }} />
      <p className="text-sm text-gray-600" dangerouslySetInnerHTML={{ 
        __html: hit._highlightResult?.description?.value || hit.description 
      }} />
    </div>
  );
}

3. Faceted filtering

Combineer zoeken met filters voor een krachtige discovery-ervaring:

  • Categorie-filters
  • Prijsranges
  • Tags en labels
  • Datum-filters

4. Recente en populaire zoekopdrachten

// Sla zoekopdrachten op voor suggesties
async function trackSearch(userId: string, query: string) {
  await prisma.searchLog.create({
    data: { userId, query, timestamp: new Date() }
  });
}

// Toon populaire zoekopdrachten
async function getPopularSearches() {
  return prisma.searchLog.groupBy({
    by: ['query'],
    _count: { query: true },
    orderBy: { _count: { query: 'desc' } },
    take: 10,
    where: {
      timestamp: { gte: subDays(new Date(), 7) }
    }
  });
}

Multi-tenant zoeken

In een multi-tenant SaaS moet je zoekresultaten isoleren per tenant:

// Typesense: gebruik scoped API keys
const scopedKey = typesense.keys().generateScopedSearchKey(
  searchOnlyApiKey,
  { filter_by: `tenant_id:=${tenantId}` }
);

// Elasticsearch: filter op tenant
const results = await client.search({
  index: 'documents',
  body: {
    query: {
      bool: {
        must: [
          { match: { content: userQuery } }
        ],
        filter: [
          { term: { tenant_id: tenantId } }
        ]
      }
    }
  }
});

Belangrijk: test altijd of een gebruiker écht geen data van andere tenants kan zien. Dit is een security-kritieke feature.

Welke oplossing kies je?

SituatieAanbeveling
MVP / < 10K recordsSQL LIKE/ILIKE
< 1M records, PostgreSQLPostgreSQL FTS + pg_trgm
> 1M records, complexe queriesElasticsearch/OpenSearch
Developer-friendly, snelle setupTypesense of Meilisearch
Hosting willen vermijdenAlgolia (managed, maar duur)

Praktische checklist

Voordat je zoekfunctionaliteit bouwt, loop deze checklist door:

  • Definieer je zoek-use-cases: wat zoeken gebruikers precies?
  • Kies je stack: PostgreSQL FTS is vaak genoeg voor v1
  • Bouw een sync-strategie: hoe blijft je index up-to-date?
  • Implementeer debouncing: voorkom onnodige API-calls
  • Voeg highlighting toe: toon waarom iets matcht
  • Test met echte data: synthetische data verbergt performance-problemen
  • Monitor zoek-performance: track p95 latency en zero-result queries
  • Isoleer tenant data: verifieer multi-tenant security

Conclusie

Zoekfunctionaliteit is een van die features die simpel lijkt maar diep kan gaan. Begin met PostgreSQL full-text search — het is verrassend krachtig en je hoeft geen extra infrastructuur te beheren. Groei je eruitgroeit? Dan is Typesense of Meilisearch een uitstekende volgende stap die je in een middag kunt opzetten.

Het belangrijkste: meet wat je gebruikers zoeken. Zero-result queries zijn goud waard — ze vertellen je precies wat er mist in je product of je zoekconfiguratie. Bouw analytics in vanaf dag één, en je zoekfunctionaliteit wordt niet alleen een feature, maar een strategisch wapen.