Semantisch Zoeken in Rails met pgvector: Van Nul tot Productie
Een klant stuurde me vorig jaar met een probleem rond hun wachtrij voor supporttickets. Ze hadden vier jaar aan opgeloste tickets in hun Rails-app — meer dan 80.000 stuks — en het supportteam was gemiddeld twintig minuten per nieuw ticket kwijt aan het zoeken naar vergelijkbare oude gevallen. Het zoeken werkte op trefwoorden. Een ticket over “app opent niet” gaf nul resultaten, terwijl soortgelijke oude tickets “applicatie start niet op” vermeldden. Zelfde probleem, andere woorden, onbruikbaar zoeken.
Semantisch zoeken loste het in een middag op. Niet omdat ik zo slim ben, maar omdat pgvector en OpenAI-embeddings inmiddels écht eenvoudig te integreren zijn in een Rails-stack. Hier is precies wat ik bouwde, en hoe jij hetzelfde kunt doen.
Wat Vectorembeddings Eigenlijk Zijn
Vergeet de wiskunde even. Een embedding is een lijst getallen — een vector — die de betekenis van een stuk tekst vastlegt. Twee zinnen met dezelfde betekenis hebben vectoren die dicht bij elkaar liggen in de vectorruimte, ook als ze geen enkel woord delen. “App opent niet” en “applicatie start niet op” eindigen als vrijwel identieke vectoren. “Handleiding databasemigraties” ligt ver weg.
Je genereert deze vectoren door tekst naar een embeddingmodel te sturen (OpenAI’s text-embedding-3-small is snel en goedkoop). Je slaat de vectoren op in Postgres via de pgvector-extensie. Je zoekt op gelijkenis met een dot product of cosinusafstand. Dat is alles.
pgvector Installeren
Eerst de extensie. Op managed Postgres (RDS, Supabase, Render) is pgvector waarschijnlijk al beschikbaar. Voor een verse installatie op Debian/Ubuntu:
sudo apt install postgresql-16-pgvector
Activeer de extensie daarna via een Rails-migratie:
class EnablePgvector < ActiveRecord::Migration[8.0]
def up
execute "CREATE EXTENSION IF NOT EXISTS vector"
end
def down
execute "DROP EXTENSION IF EXISTS vector"
end
end
Voeg de pgvector-gem toe aan je Gemfile:
# Gemfile
gem "pgvector"
Voeg vervolgens een vectorkolom toe aan de tabel die doorzoekbaar moet worden. Voor het ticketvoorbeeld:
class AddEmbeddingToTickets < ActiveRecord::Migration[8.0]
def change
add_column :tickets, :embedding, :vector, limit: 1536
end
end
De limit: 1536 komt overeen met de dimensionaliteit van OpenAI’s text-embedding-3-small. Bij een ander model pas je dit aan (text-embedding-3-large is 3072).
Embeddings Genereren en Opslaan
Koppel het model:
# app/models/ticket.rb
class Ticket < ApplicationRecord
include Pgvector::ActiveRecord
has_neighbors :embedding
after_create_commit :generate_embedding, if: :embeddable?
def embeddable?
subject.present? && body.present?
end
private
def generate_embedding
GenerateEmbeddingJob.perform_later(self)
end
end
De job die OpenAI aanroept:
# app/jobs/generate_embedding_job.rb
class GenerateEmbeddingJob < ApplicationJob
queue_as :embeddings
def perform(record)
text = [record.subject, record.body].compact.join("\n\n")
vector = EmbeddingService.generate(text)
record.update_columns(embedding: vector)
end
end
En de service-wrapper rond de OpenAI-client:
# app/services/embedding_service.rb
class EmbeddingService
MODEL = "text-embedding-3-small"
def self.generate(text)
response = client.embeddings(
parameters: {
model: MODEL,
input: text.truncate(8000) # binnen tokenlimieten blijven
}
)
response.dig("data", 0, "embedding")
end
def self.client
@client ||= OpenAI::Client.new(access_token: Rails.application.credentials.openai_api_key)
end
end
update_columns slaat callbacks bewust over — je wilt niet dat after_create_commit opnieuw activeert en een embeddinglus veroorzaakt.
Zoeken: Vergelijkbare Records Vinden
Met pgvector’s has_neighbors krijg je gratis een scope:
# De 10 tickets die het meest lijken op een gegeven ticket
Ticket.nearest_neighbors(:embedding, ticket.embedding, distance: "cosine").limit(10)
Voor een zoekvak met een vrije zoektekst genereer je eerst een embedding voor die tekst:
# app/services/ticket_search.rb
class TicketSearch
def self.call(query, limit: 10)
return Ticket.none if query.blank?
query_vector = EmbeddingService.generate(query)
Ticket
.nearest_neighbors(:embedding, query_vector, distance: "cosine")
.where.not(embedding: nil)
.limit(limit)
end
end
In de controller:
# app/controllers/tickets_controller.rb
def index
@tickets = if params[:q].present?
TicketSearch.call(params[:q])
else
Ticket.order(created_at: :desc).limit(50)
end
end
Dat is de kern. Negentig regels code en je zoekfunctie begrijpt betekenis in plaats van trefwoorden te matchen.
HNSW-index voor Productie
Een exacte nearest-neighbor-zoekopdracht scant elke rij in de tabel. Prima voor 10.000 records; pijnlijk bij 500.000. pgvector ondersteunt twee typen approximate nearest-neighbor-indexen: IVFFlat en HNSW. HNSW werkt beter in de meeste gevallen — snellere queries ten koste van iets langere indexopbouw.
class AddHnswIndexToTicketsEmbedding < ActiveRecord::Migration[8.0]
def up
execute <<~SQL
CREATE INDEX tickets_embedding_hnsw_idx
ON tickets
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64)
SQL
end
def down
remove_index :tickets, name: :tickets_embedding_hnsw_idx
end
end
De parameters m en ef_construction bepalen de verhouding kwaliteit/snelheid. Voor de meeste productieomgevingen zijn m = 16 en ef_construction = 64 een goed beginpunt. Verhoog ef_construction als recall-kwaliteit zwaarder weegt dan de bouwtijd van de index.
Voer VACUUM ANALYZE tickets uit na het aanmaken van de index om de statistieken van de queryplanner bij te werken.
Bestaande Records Bijvullen
Waarschijnlijk heb je records die al bestonden voordat je de embeddingkolom toevoegde. Probeer ze niet allemaal in één migratie bij te vullen — de API-aanroepen zullen een time-out geven en je houdt locks vast. Gebruik een achtergrondjob met in_batches:
# lib/tasks/embeddings.rake
namespace :embeddings do
desc "Vul embeddings bij voor tickets zonder embedding"
task backfill: :environment do
Ticket.where(embedding: nil).in_batches(of: 100) do |batch|
batch.each do |ticket|
GenerateEmbeddingJob.perform_later(ticket)
end
sleep 0.5 # respecteer OpenAI-snelheidslimieten
end
end
end
Voer dit uit met bundle exec rails embeddings:backfill. Voor 80.000 tickets met text-embedding-3-small-tarieven betaal je in totaal ongeveer $1,20 aan API-kosten. Spotgoedkoop.
Een Minimale RAG-pipeline
Zodra semantisch zoeken werkt, ben je halverwege naar RAG (Retrieval-Augmented Generation) — het patroon waarbij je relevante context uit je database ophaalt voordat je een vraag naar de LLM stuurt. Zo ziet het eruit in het ticketsysteem:
# app/services/support_answer.rb
class SupportAnswer
SYSTEM_PROMPT = <<~PROMPT
Je bent een supportassistent. Gebruik de beschikbare eerdere ticketoplossingen om
een antwoord te suggereren. Wees specifiek en praktisch. Als de eerdere tickets
de vraag niet dekken, zeg dat dan in plaats van te raden.
PROMPT
def self.call(question)
similar = TicketSearch.call(question, limit: 5)
context = similar.map { |t| "V: #{t.subject}\nA: #{t.resolution}" }.join("\n\n---\n\n")
client.chat(
parameters: {
model: "gpt-4o",
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: "Eerdere vergelijkbare tickets:\n\n#{context}\n\nNieuwe vraag: #{question}" }
],
temperature: 0.3
}
)
end
def self.client
@client ||= OpenAI::Client.new(access_token: Rails.application.credentials.openai_api_key)
end
end
temperature: 0.3 houdt de antwoorden gegrond. Je zoekt geen creativiteit — je wilt dat het model eerdere oplossingen samenvat, niet nieuwe verzint.
Valkuilen in Productie
Nil-embeddings breken je sortering. pgvector retourneert nil voor records met null-vectoren. Sluit ze uit: .where.not(embedding: nil).
Cosinus- versus L2-afstand. Cosinusafstand is de juiste keuze voor tekst — het negeert de magnitude van de vector en richt zich op de richting (betekenis). L2-afstand is meer geschikt voor afbeeldingen of numerieke kenmerken. Gebruik cosinus voor taalmodellen.
Houd het embeddingmodel consistent. Als je sommige embeddings genereert met text-embedding-3-small en later overschakelt naar text-embedding-ada-002, zijn vergelijkingen tussen oude en nieuwe vectoren betekenisloos. Kies een model en houd je eraan. Bij een switch is een volledige bijvulling vereist.
Asynchroon of niets. Genereer nooit embeddings synchroon in een webrequest. De OpenAI API voegt 100–400ms latentie toe. Gebruik altijd een achtergrondjob met een eigen queue. Bij hoog volume gebruik je het batch-embedding-eindpunt van OpenAI — 50% goedkoper en ontworpen voor bulkverwerking.
Chunking voor lange documenten. Als je documenten van meer dan ~400 woorden embedt, overweeg dan om ze op te splitsen in overlappende stukken (bijv. chunks van 300 woorden met 50 woorden overlap) en elk stuk als een aparte vector op te slaan. Haal chunks op, dedupleer op bovenliggend document, geef documenten terug. Dit is de standaard chunkingstrategie voor RAG.
Wat Dit Niet Vervangt
Semantisch zoeken vervangt geen full-text search — het is een aanvulling. Exacte trefwoordmatches, zoekopdrachten op zinsdelen en facetfilters werken nog steeds beter met pg_search of Postgres’s native tsvector. De juiste architectuur combineert beide: run semantisch zoeken en trefwoordzoeken parallel, combineer resultaten met een scoreformule, presenteer de samenvoeging aan de gebruiker.
Achttien jaar na mijn eerste Rails-project ben ik nog steeds verrast hoe schoon het ecosysteem nieuwe ideeën absorbeert. pgvector past in ActiveRecord alsof het er altijd al in bedoeld was. De complexiteit zit in de productkeuzes — wat embed je, hoe chunk je het, welke context heeft de LLM daadwerkelijk nodig — niet in de Rails-infrastructuur.
Veelgestelde Vragen
Moet ik OpenAI gebruiken voor embeddings?
Nee. Elk model dat vaste-grootte dense vectoren produceert werkt. Alternatieven zijn Mistral embeddings, Cohere Embed, Google’s text-embedding-004 of een lokaal gehost model via Ollama. De afweging is kwaliteit versus kosten versus latentie. OpenAI’s text-embedding-3-small is een verstandige standaardkeuze.
Wat kost dit bij schaal?
text-embedding-3-small kost $0,02 per miljoen tokens. Bij gemiddeld 200 tokens per ticket kosten 100.000 tickets ongeveer $0,40 om te embedden. Het uitvoeren van de zoekopdracht (het embedden van de query) kost fracties van een cent per zoekopdracht. Kosten zijn niet de beperking — architectuur is dat.
Kan ik dit gebruiken zonder de pgvector-gem?
Technisch gezien wel — je kunt vectoren opslaan als arrays en raw SQL schrijven voor cosinusafstand. In de praktijk geeft de pgvector-gem je has_neighbors en type casting gratis. Gebruik hem gewoon.
Is HNSW beter dan IVFFlat?
Voor de meeste gevallen: ja. HNSW heeft hogere recall bij gelijkwaardige querysnelheid en vereist niet dat je het aantal clusters vooraf definieert (nlist bij IVFFlat). IVFFlat is nuttig als je extreem snelle bouwtijden nodig hebt en een recall-compromis kunt accepteren. Als je geen reden hebt om voor IVFFlat te kiezen, gebruik dan HNSW.
Wat gebeurt er als de OpenAI API onbereikbaar is?
Records die worden aangemaakt terwijl de API niet beschikbaar is, krijgen nil-embeddings. Je achtergrondjob moet Solid Queue of Sidekiq-retries gebruiken met exponentiële backoff. Wanneer de API herstelt, worden de jobs alsnog voltooid. Je zoekfunctie slaat records zonder embedding in de tussentijd netjes over.
Wil je semantisch zoeken of een RAG-pipeline toevoegen aan je Rails-applicatie? TTB Software bouwt al jaren AI-gedreven functionaliteit op Rails. We weten waar de grenzen liggen. Neem contact op.
About the Author
Roger Heykoop is een senior Ruby on Rails ontwikkelaar met 19+ jaar Rails ervaring en 35+ jaar ervaring in softwareontwikkeling. Hij is gespecialiseerd in Rails modernisering, performance optimalisatie, en AI-ondersteunde ontwikkeling.
Get in TouchRelated Articles
Need Expert Rails Development?
Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise
Schedule a Free Consultation