RUBY ON RAILS · 18 MIN READ ·

Rails Hybrid Search: pgvector Semantisch Zoeken Combineren met Postgres Full-Text voor Betere Recall

Rails hybrid search combineert pgvector semantische vectoren met Postgres full-text zoeken en Reciprocal Rank Fusion voor significant betere zoekresultaten.

Zes maanden nadat we semantisch zoeken hadden uitgerold op de kennisbank van een klant, kwamen hun supportmedewerkers terug met een merkwaardige klacht. Gebruikers die zochten op “404 error” kregen resultaten over HTTP-statuscodes, REST API’s en webinfrastructuur — technisch verwant, maar volstrekt nutteloos voor iemand die het artikel zocht getiteld “Wat te doen als je integratie een 404 teruggeeft.” Gewoon zoeken op trefwoorden had dat artikel als eerste resultaat gegeven. Onze vectoren deden iets heel anders.

Dit is het faalpatroon waar niemand je voor waarschuwt als je zoeken vervangt door embeddings. Semantisch zoeken is uitstekend in het vinden van conceptueel verwante inhoud wanneer gebruikers hun intentie in gewone taal uitdrukken. Het gaat mis bij exacte zoekopdrachten: productcodes, foutcodes, eigennamen, technische afkortingen — alles waarbij de precieze tekens meer tellen dan de betekenis. Trefwoordzoeken heeft het omgekeerde probleem. “Machine learning model deployment” vindt documenten met precies die woorden, maar mist het artikel dat het heeft over “je ML-pipeline uitrollen naar productie” zonder ooit “model deployment” te gebruiken.

Rails hybrid search lost beide problemen op. Je voert semantisch zoeken en full-text zoeken parallel uit en combineert vervolgens de rangschikkingen met een klein algoritme genaamd Reciprocal Rank Fusion. Het resultaat overtreft beide benaderingen consequent. Na dit meerdere keren in productie te hebben geïmplementeerd, stuur ik geen puur semantisch zoeken meer de deur uit. Hybrid is de standaard.

Waarom Puur Semantisch Zoeken in Productie Faalt

Semantisch zoeken werkt door zoekopdrachten en documenten om te zetten in embedding-vectoren — arrays van drijvende-kommagetalletjes die betekenis coderen. Documenten met vergelijkbare betekenis hebben vectoren die dicht bij elkaar liggen in de vectorruimte. Bij een zoekopdracht embed je de query en zoek je naar de dichtstbijzijnde documentvectoren.

De zwakte zit hem in precisie bij exacte termen. Je embedding-model heeft “ERR_INVALID_RESPONSE” nooit in een betekenisvolle trainingscontext gezien, dus de embedding voor die string is willekeurig. Een gebruiker die een foutcode invult, krijgt resultaten die semantisch verwant zijn — artikelen over browserfouten, netwerkproblemen, debuggen — maar niet het specifieke artikel met precies die foutcode in de titel. Hetzelfde geldt voor productnummers, versienummers, namen en bedrijfsspecifiek jargon dat je model nooit heeft gezien.

Het andere probleem is documentlengte. Embedding-modellen hebben doorgaans een contextvenster van 512 tot 8192 tokens. Lange documenten worden afgekapt of in stukken verdeeld. Een zoekopdracht die alleen aan het einde van een lang document voorkomt, wordt misschien helemaal niet gevonden.

Waarom Full-Text Zoeken Ook Faalt

Full-text zoeken splitst documenten op in woorden, bouwt een omgekeerde index en scoort op basis van termfrequentie en inverse documentfrequentie (TF-IDF). Postgres doet dit ingebouwd met tsvector en tsquery. De pg_search-gem biedt een nette Rails-wrapper.

De zwakte is vocabulaireverschil. “Hoe maak ik mijn app sneller?” matcht niet met een artikel getiteld “Rails-performance optimaliseren voor drukbezochte applicaties” tenzij die exacte woorden erin staan. Full-text zoeken heeft geen begrip van synoniemen, gerelateerde termen of parafrasen.

Het heeft ook moeite met vragen in gewone taal. Gebruikers typen zoekopdrachten steeds meer zoals ze praten — zeker in AI-gerelateerde apps. Trefwoordzoeken geeft niets zinvols terug op “waarom verwerkt mijn achtergrondtaak niet” tenzij een document precies die woorden bevat.

Wat Hybrid Search Is: De Architectuur

Hybrid search voert beide zoekopdrachten uit en combineert de ranglijsten. Het algoritme daarvoor heet Reciprocal Rank Fusion (RRF). Het is verrassend eenvoudig:

score(document) = Σ 1 / (k + rank_i(document))

Waarbij rank_i(document) de positie van het document in de i-de resultatenlijst is (1-gebaseerd), en k een smoothingconstante is — doorgaans 60. Documenten die in beide lijsten hoog scoren, krijgen de hoogste gecombineerde score. Documenten die in één lijst ontbreken, dragen alsnog bij via hun score uit de andere lijst. Geen per-corpus gewichtsafstemming nodig, geen scorenormalisatie vereist.

De volledige implementatie in Rails voert twee queries uit — één vectorsimilariteitquery via pgvector, één full-text query via tsvector — en combineert ze in één SQL-CTE.

pgvector Instellen voor de Semantische Arm

Als je pgvector nog niet hebt ingericht, behandelt het pgvector en embeddings artikel de volledige setup. Kort samengevat: voeg de pgvector-gem toe, activeer de extensie, voeg een vector-kolom toe, genereer embeddings met OpenAI of een ander model, en sla ze op.

# Migratie
class AddEmbeddingToDocuments < ActiveRecord::Migration[8.0]
  def change
    add_column :documents, :embedding, :vector, limit: 1536
    add_index :documents, :embedding,
              using: :hnsw,
              opclass: :vector_cosine_ops,
              name: "index_documents_embedding_hnsw"
  end
end

limit: 1536 komt overeen met de dimensies van OpenAI’s text-embedding-3-small. De HNSW-index maakt benaderde nearest-neighbour-zoekopdrachten snel genoeg voor productie — op een corpus van een miljoen documenten onder de 20ms op een warme Postgres-cache.

Voor het genereren van embeddings bij opslaan:

class Document < ApplicationRecord
  after_save :schedule_embedding, if: :body_previously_changed?

  private

  def schedule_embedding
    GenerateDocumentEmbeddingJob.perform_later(id)
  end
end

class GenerateDocumentEmbeddingJob < ApplicationJob
  def perform(document_id)
    document = Document.find(document_id)
    client = OpenAI::Client.new
    response = client.embeddings(
      parameters: {
        model: "text-embedding-3-small",
        input: document.body.truncate(8000)
      }
    )
    embedding = response.dig("data", 0, "embedding")
    document.update_column(:embedding, embedding)
  end
end

tsvector Instellen voor de Trefwoordarm

Voor de trefwoordarm integreren ruwe tsvector-kolommen strakker in de hybride CTE dan pg_search doet. Het pg_search artikel behandelt de gem-aanpak als je die voorkeur geeft.

class AddSearchVectorToDocuments < ActiveRecord::Migration[8.0]
  def up
    add_column :documents, :search_vector, :tsvector
    add_index :documents, :search_vector, using: :gin

    execute <<~SQL
      UPDATE documents
      SET search_vector =
        setweight(to_tsvector('dutch', coalesce(title, '')), 'A') ||
        setweight(to_tsvector('dutch', coalesce(body, '')), 'B')
    SQL

    execute <<~SQL
      CREATE FUNCTION documents_search_vector_update() RETURNS trigger AS $$
      BEGIN
        NEW.search_vector :=
          setweight(to_tsvector('dutch', coalesce(NEW.title, '')), 'A') ||
          setweight(to_tsvector('dutch', coalesce(NEW.body, '')), 'B');
        RETURN NEW;
      END
      $$ LANGUAGE plpgsql;

      CREATE TRIGGER documents_search_vector_update
      BEFORE INSERT OR UPDATE ON documents
      FOR EACH ROW EXECUTE FUNCTION documents_search_vector_update();
    SQL
  end

  def down
    execute "DROP TRIGGER IF EXISTS documents_search_vector_update ON documents"
    execute "DROP FUNCTION IF EXISTS documents_search_vector_update"
    remove_column :documents, :search_vector
  end
end

setweight met 'A' voor titel en 'B' voor body zorgt ervoor dat overeenkomsten in de titel zwaarder wegen dan overeenkomsten alleen in de body. De GIN-index maakt de @@-operator snel. Beide zijn niet-onderhandelbaar voor productie.

De Hybride Query: Reciprocal Rank Fusion in SQL

Hier is het volledige service-object. Het voert beide queries uit in één SQL-CTE en past RRF toe in de database, zodat je niet twee volledige resultatensets in Ruby hoeft te laden om ze samen te voegen:

class Document::HybridSearch
  K = 60
  DEFAULT_LIMIT = 20

  def initialize(query:, limit: DEFAULT_LIMIT)
    @query = query
    @limit = limit
    @embedding = embed(query)
  end

  def call
    Document.find_by_sql([<<~SQL, embedding: @embedding, tsquery: tsquery, limit: @limit, k: K])
      WITH semantic AS (
        SELECT
          id,
          ROW_NUMBER() OVER (ORDER BY embedding <=> :embedding::vector) AS rank
        FROM documents
        WHERE embedding IS NOT NULL
        ORDER BY embedding <=> :embedding::vector
        LIMIT 60
      ),
      keyword AS (
        SELECT
          id,
          ROW_NUMBER() OVER (ORDER BY ts_rank_cd(search_vector, query) DESC) AS rank
        FROM documents,
             to_tsquery('dutch', :tsquery) query
        WHERE search_vector @@ query
        ORDER BY ts_rank_cd(search_vector, query) DESC
        LIMIT 60
      ),
      rrf AS (
        SELECT
          COALESCE(s.id, k.id)                            AS id,
          COALESCE(1.0 / (:k + s.rank), 0.0)
            + COALESCE(1.0 / (:k + k.rank), 0.0)         AS score
        FROM semantic s
        FULL OUTER JOIN keyword k ON s.id = k.id
      )
      SELECT documents.*
      FROM documents
      JOIN rrf ON documents.id = rrf.id
      ORDER BY rrf.score DESC
      LIMIT :limit
    SQL
  end

  private

  def embed(text)
    client = OpenAI::Client.new
    response = client.embeddings(
      parameters: { model: "text-embedding-3-small", input: text.truncate(8000) }
    )
    response.dig("data", 0, "embedding")
  end

  def tsquery
    @query
      .gsub(/[^a-z0-9\s]/i, " ")
      .split
      .reject(&:empty?)
      .map { |term| "#{term}:*" }
      .join(" & ")
  end
end

Gebruik vanuit een controller:

class SearchController < ApplicationController
  def show
    @results = Document::HybridSearch.new(query: params[:q], limit: 10).call
  end
end

Een paar details die de moeite waard zijn om uit te leggen:

De LIMIT 60 op elke subquery bepaalt de kandidatenpool per arm. Groter betekent betere recall ten koste van querytijd. Zestig kandidaten is voldoende voor de meeste apps. Op een corpus van een miljoen documenten met de juiste HNSW- en GIN-indexen loopt deze query in 50–100ms koud. Warm, onder de 20ms.

De tsquery-methode zet vrije invoer om naar een Postgres-tsquery. Het :*-achtervoegsel schakelt prefixmatching in — “deploy” matcht “deployment” en “deploying”. De & tussen termen vereist dat alle termen voorkomen. Wijzig dit naar | voor OR-semantiek als je queries neigen naar meerdere optionele termen.

De FULL OUTER JOIN in de RRF-CTE zorgt ervoor dat documenten die slechts in één arm voorkomen, toch een score krijgen van die arm. Een document dat eerste staat in semantisch zoeken maar helemaal niet in de trefwoordresultaten scoort 1 / (60 + 1) ≈ 0.016. Eén die in beide bovenaan staat scoort 1/61 + 1/61 ≈ 0.033. De gecombineerde volgorde bevordert op natuurlijke wijze documenten die zowel semantisch verwant als trefwoordrelevant zijn.

Rails Hybrid Search: Gewichten Aanpassen

Standaard-RRF behandelt beide armen gelijkwaardig. In de praktijk wil je ze misschien anders wegen — geef semantisch zoeken meer invloed op een algemene kennisbank, geef full-text meer invloed op een technische documentatiesite waar foutcodes en exacte zoekopdrachten domineren.

De schoonste aanpak is een per-arm vermenigvuldiger, niet het RRF-formule zelf aanpassen:

SEMANTIC_WEIGHT = 1.0
KEYWORD_WEIGHT  = 1.5

# In de RRF-CTE, vervang de scoreberekening door:
# COALESCE(:semantic_weight / (:k + s.rank), 0.0)
#   + COALESCE(:keyword_weight / (:k + k.rank), 0.0)  AS score

Maak deze waarden configureerbaar als parameters op het service-object en log ze naast queryresultaten. Na een week productieverkeer zie je welke arm de relevante resultaten oplevert op je slechtst presterende queries en kun je van daaruit bijsturen. In de praktijk pas ik zelden aan — gelijke gewichten werken voor de meeste corpora — maar het hebben van de hendel heeft me meer dan eens gered.

Prestaties en Indexering

Beide armen hebben hun eigen indexen nodig, anders degradeer je bij schaal naar volledige tabelscans.

Voor de vectorarm verzorgt de HNSW-index de benaderde nearest-neighbour-zoekopdrachten. De standaardwaarden werken voor enkele honderdduizenden documenten. Bij miljoenen rijen schakel je ef_construction omhoog (langere indexbouw, betere recall) en benchmark je met SET hnsw.ef_search = 100 in een sessie om de recall/latency-afweging te zien.

Voor de trefwoordarm is de GIN-index op search_vector verplicht. Zorg dat je Postgres work_mem hoog genoeg is voor GIN-builds op grote corpora — 2–4GB per verbinding is redelijk. Zonder voldoende werkgeheugen degradeert Postgres naar een lossy index en gaan query-prestaties achteruit.

Eén ding dat stille prestatieproblemen veroorzaakt: de vectorarm uitvoeren zonder WHERE embedding IS NOT NULL. Documenten die wachten op embedding-generatie hebben een NULL-embedding. Postgres moet die rijen scannen, de vergelijking proberen, NULL teruggeven en filteren. Voeg altijd het filter toe.

Profileer met EXPLAIN (ANALYZE, BUFFERS) voor en na elke indexwijziging. Op Postgres 16+ is de optimizer goed, maar hybride queries hebben genoeg bewegende onderdelen dat het plan je kan verrassen. Voor CTE-scans met FULL OUTER JOIN kan het soms helpen om gematerialiseerde CTE’s te forceren:

WITH semantic AS MATERIALIZED (...),
     keyword  AS MATERIALIZED (...),
     rrf      AS (...)
SELECT ...

Hybrid Search Testen in Rails

Het lastige aan het testen van zoekkwaliteit is dat “beter” subjectief is. De aanpak die ik gebruik is een gouden set: vaste zoekopdrachten gekoppeld aan verwachte topresultaten, gecontroleerd in CI als regressiesuite.

RSpec.describe Document::HybridSearch do
  fixtures :documents

  GOLDEN_QUERIES = [
    { query: "404 error not found",       expected_id: :http_404_article },
    { query: "rails app uitrollen",       expected_id: :kamal_deployment_guide },
    { query: "ERR_INVALID_RESPONSE",      expected_id: :chrome_errors_reference },
  ].freeze

  it "geeft het verwachte topresultaat terug voor gouden queries" do
    GOLDEN_QUERIES.each do |pair|
      results = described_class.new(query: pair[:query], limit: 5).call
      expect(results.first).to eq(documents(pair[:expected_id])),
        "Query '#{pair[:query]}' gaf #{results.first&.title}, verwacht #{documents(pair[:expected_id]).title}"
    end
  end
end

Sla echte embeddings op in de fixtures. Genereer ze eenmalig en commit de YAML naar de repo. Tests draaien dan zonder enige API-aanroep. Het RAG met pgvector artikel behandelt de embedding-fixturestrategie in detail.

Voor het benchmarken van de recall-verbetering gebruik ik Mean Reciprocal Rank (MRR) op de gouden set:

def mean_reciprocal_rank(query_pairs, k: 10)
  scores = query_pairs.map do |query, relevant_id|
    results = Document::HybridSearch.new(query: query, limit: k).call
    rank = results.index { |r| r.id == relevant_id }
    rank ? 1.0 / (rank + 1) : 0.0
  end
  scores.sum / scores.size
end

Op elk corpus dat ik heb gemeten, verslaat hybrid semantisch-alleen met 8–25% op MRR en trefwoord-alleen met 15–40%. De exacte getallen hangen volledig af van je querydistributie. Apps met veel technische exacte zoekopdrachten profiteren meer van de trefwoordarm. Algemene kennisbanken profiteren meer van de semantische arm. Hybrid wint het in alle gevallen die ik heb gezien.

Wanneer Hybrid, Semantisch of Trefwoord Gebruiken

Gebruik semantisch alleen wanneer zoekopdrachten bijna altijd in gewone taal zijn, het corpus homogeen is (geen eigennamen of codes), en recall-precisie minder belangrijk is dan dekking van vage queries. Ontdekken van marketingteksten. Blogaanbevelingen. Conceptuele similariteitszoekopdrachten.

Gebruik trefwoord alleen wanneer het corpus veel exacte termen heeft — juridische verwijzingen, artikelnummers, code-identifiers — gebruikers technisch zijn en precieze zoekopdrachten typen, en latency een harde grens is. Zoeken in een developerportal. Juridische documentopzoeking op wetsartikel.

Gebruik hybrid voor al het andere in productie. Support-kennisbanken. Documentatiesites. E-commerce productzoeken gecombineerd met attribuutfiltering. Interne wikis. Alles waarbij gebruikers gemengde zoektypen hebben — sommigen typen precieze technische termen, anderen stellen conversationele vragen. De latency-overhead ten opzichte van puur semantisch of puur trefwoord is één extra SQL-subquery. De recall-verbetering is consistent.

Na negentien jaar Rails bouwen en de laatste jaren AI-gedreven zoeken aan veel applicaties toevoegen, is hybrid de architectuur die ik standaard gebruik. Ik ben gestopt met vragen “hebben we hybrid nodig?” en stel nu “is er een specifieke reden om één van de armen over te slaan?”

Veelgestelde Vragen

Hoe verhoudt Rails hybrid search zich tot Elasticsearch?

Elasticsearch’s multi_match doet trefwoordzoeken goed. De knn-query doet dense vectorzoeken. Hybrid search-ondersteuning in versie 8.x is minder geïntegreerd dan in Postgres en vereist een aparte infrastructuurcomponent. Voor Rails-apps die al op Postgres met pgvector draaien, vermijdt de hier beschreven aanpak die afhankelijkheid met vergelijkbare kwaliteit. Elasticsearch is zinvol als je het al nodig hebt om andere redenen — enorme schaal, complexe aggregaties, documentstreaming. Voeg het niet toe alleen voor hybrid search als Postgres al voldoet.

Voor Engelstalige inhoud geeft OpenAI’s text-embedding-3-small op 1536 dimensies uitstekende resultaten tegen lage kosten. Voor meertalige inhoud, text-embedding-3-large of een meertalig sentence-transformers-model. Voor on-premise of latency-beperkte omgevingen werkt een lokaal gehost model via Ollama met dezelfde code — verwissel gewoon de embedding-aanroep. Het specifieke model maakt minder uit dan consistentie: gebruik altijd hetzelfde model bij indexeren én bij zoeken, anders worden je similariteitsscores betekenisloos.

Werkt Reciprocal Rank Fusion beter dan lineaire scorecombinatie?

RRF is robuuster dan lineaire combinatie. Lineaire combinatie vereist het normaliseren van scores van beide armen naar dezelfde schaal — cosinussimilariteit van pgvector zit in [-1, 1], ts_rank_cd van Postgres full-text in [0, 1] met verschillende distributies. Normalisatie is fragiel wanneer de corpusdistributie verandert. RRF gebruikt rangposities, die schaal-onafhankelijk zijn en geen kalibratie vereisen. Het praktische kwaliteitsverschil is klein, maar RRF heeft nul afstemmingswerk nodig. Ik gebruik het standaard en schakel over naar gewogen lineaire combinatie alleen bij een specifieke reden, zoals het zwaar bevorderen van documentfrisheid.

Hoe ga ik om met documenten zonder embeddings in de hybride query?

De CTE handelt dit automatisch af. Documenten zonder embeddings worden uitgesloten van de semantische arm via WHERE embedding IS NOT NULL, maar verschijnen nog steeds in trefwoordresultaten als ze overeenkomen. Vers opgeslagen documenten die nog niet zijn verwerkt door de embedding-job nemen direct deel aan trefwoordzoeken. Zodra de achtergrondtaak de embedding genereert, sluiten ze aan bij de semantische arm bij de volgende query. Geen speciale behandeling nodig — de FULL OUTER JOIN in de RRF-CTE dekt de asymmetrie.


Bouw je zoekfunctionaliteit in je Rails-app en wil je het de eerste keer goed doen — hybrid retrieval, correcte pgvector-indexering en gemeten recall? TTB Software bouwt productie-waardige zoek- en AI-integraties op Rails. Dit doen we al negentien jaar.

#rails-hybrid-search #pgvector-rails #semantic-search-rails #reciprocal-rank-fusion #rails-full-text-search #vector-search-rails

Related Articles

Laatste sectie. Bel dan alsjeblieft.

Het is een telefoongesprek. Erger dan dat kan het niet worden.

Geen discovery-deck. Geen 45-minuten "kwalificatiegesprek." 30 minuten, jouw probleem, mijn mening. Als we een fit zijn weet je dat in minuut 12.

Directe lijn — Roger neemt zelf op
+31 6 5123 6132
Ma–vr, 09:00–18:00 CET · Nu beschikbaar

OF
info@ttb.software