Rails pgvector: Semantische Zoek en RAG met PostgreSQL voor LLM-Applicaties
Rails pgvector gids: bouw semantische zoek en RAG op PostgreSQL met embeddings, HNSW-indexen en hybride retrieval. Productieopzet voor Rails 8 LLM-apps.
Een founder belde me in juni met een bekend probleem: hun supportteam verzoop. Ze hadden zes jaar aan afgehandelde Zendesk-tickets, een Notion knowledge base, en productdocumentatie in drie verschillende formaten, en hun nieuwe medewerkers besteedden hun eerste twee weken vooral aan uitzoeken waar dingen stonden. “Kunnen we er een LLM op loslaten?” vroegen ze. Ik opende hun Rails-console, controleerde de Postgres-versie — 16.2, al op RDS met pgvector beschikbaar — en beloofde in drie weken een werkende antwoordbot. We shippten hem in vier. De retrieval-laag was Rails pgvector, direct gebouwd op de Postgres die ze al draaiden.
Dat project is de reden dat Rails pgvector in 2026 de retrieval-stack is die ik standaard aanraad. Na negentien jaar Rails heb ik veel “voeg een gespecialiseerde database toe”-verhalen slecht zien verouderen, en het patroon dat ik in productie-RAG-systemen keer op keer terugzie is dat een goed getunede Postgres het wint van een aparte Pinecone- of Weaviate-cluster voor de overgrote meerderheid van teams. Deze post is de exacte Rails pgvector-opzet die ik ship: schema, indexingstrategie, embedding-pipeline, en de hybride retrieval die resultaten daadwerkelijk bruikbaar maakt.
Waarom Rails pgvector een Aparte Vector Database Verslaat
Rails pgvector is de pgvector PostgreSQL-extensie, ontsloten voor Rails via de neighbor-gem (of platte ActiveRecord als je graag typt). Het geeft je een vector-kolomtype, cosine-/L2-/inner-product-afstandsoperators, en HNSW- / IVFFlat-approximate-nearest-neighbor-indexen — allemaal binnen dezelfde Postgres die al je users, tenants en access controls opslaat.
De rekensom voor overstappen naar een aparte vector-database ziet er op bijna elke as die ik heb gemeten slecht uit:
- Je draaide Postgres al. pgvector toevoegen is een
CREATE EXTENSIONop managed services (RDS, Aurora, Supabase, Neon, GCP Cloud SQL, DigitalOcean Managed Postgres ondersteunen het allemaal). Pinecone toevoegen is een nieuw vendor-contract, een nieuwe SDK, een nieuw outage-domein en een nieuwe factuurregel. - Joins zijn gratis. Je embeddings staan naast de rij die ze beschrijven, dus een zoekactie die filtert op “documenten die deze user mag zien” is één query met een
WHERE tenant_id = ?en een ORDER BY de vector-afstand. In een aparte vector-DB haal je top-K op, doet dan een round-trip naar Postgres om te filteren, en gaat dan re-ranken wat over is. Die extra hop is waar recall sterft. - Transacties zijn consistent. Wanneer je een document bijwerkt, kun je de embedding in dezelfde transactie updaten. Systemen die embeddings asynchroon naar een aparte store pushen leveren op elke write verouderde resultaten.
- Backups, replicas en observability zijn die je al hebt. Je Postgres-backup dekt je vectoren. Je read replica bedient vector-queries. Je Datadog Postgres-dashboard toont de langzame queries. Geen nieuw runbook.
Het falen-scenario waar dedicated vector-databases echt winnen is wanneer je voorbij ~50M vectoren komt met hoge write-throughput en horizontaal gesharde ANN nodig hebt — denk aan semantische productzoek op Etsy of Airbnb. Onder die drempel is Rails pgvector sneller te shippen, goedkoper te draaien en makkelijker om over na te denken. Mijn grootste productie-Rails pgvector-deployment zit op 8M vectoren verspreid over 2.400 tenants en haalt p95 zoeklatentie van 40 ms op een enkele db.r6g.2xlarge.
pgvector en de neighbor-gem Installeren
De extensie moet op je Postgres-server bestaan. Op RDS is het een vinkje; op self-hosted is het een apt install postgresql-16-pgvector of de officiële installatiedocs. Zodra de binary aanwezig is, activeer je hem in Rails via een migratie:
class EnablePgvector < ActiveRecord::Migration[8.0]
def change
enable_extension "vector"
end
end
Aan de Rails-kant gebruik ik de neighbor-gem, die ActiveRecord over het vector-type leert en je nette scopes geeft:
# Gemfile
gem "neighbor", "~> 0.5"
gem "ruby-openai", "~> 7.0" # of anthropic, of bedrock — welk embedding-model je ook gebruikt
Neighbor regelt de vervelende dingen: Ruby-arrays serialiseren naar pgvectors [0.1,0.2,...]-stringformaat, de juiste SQL genereren voor cosine vs L2 vs inner-product-afstand, en HNSW- / IVFFlat-indexen bouwen met de juiste operator class. Zonder de gem eindig je met handgerolde Arel.sql voor elke query, wat precies het soort code is waar je toekomstige zelf een hekel aan zal hebben.
Schemaontwerp voor Rails pgvector
Het schema dat in elk Rails pgvector-project dat ik heb geshipt overeind is gebleven zet de embedding op een aparte tabel, niet op de source-rij. Het klinkt als premature normalisatie; dat is het niet. De reden is dat embedding-modellen veranderen — je gaat je corpus minstens één keer opnieuw embedden — en je wilt de vrijheid om een content_embeddings_v2-tabel toe te voegen zonder je source-rijen of je applicatiecode aan te raken.
class CreateContentEmbeddings < ActiveRecord::Migration[8.0]
def change
create_table :content_embeddings do |t|
t.references :tenant, null: false, foreign_key: true, index: true
t.references :embeddable, polymorphic: true, null: false, index: true
t.string :model, null: false # "text-embedding-3-small"
t.integer :chunk_index, null: false, default: 0
t.text :chunk_text, null: false
t.vector :embedding, limit: 1536, null: false
t.jsonb :metadata, null: false, default: {}
t.timestamps
end
add_index :content_embeddings,
[:embeddable_type, :embeddable_id, :chunk_index],
unique: true,
name: "idx_embeddings_source_chunk"
end
end
Een paar dingen die het waard zijn om te noemen:
limit: 1536komt overeen met OpenAI’stext-embedding-3-small. Als jetext-embedding-3-largegebruikt zet het op 3072, of als je Voyage’svoyage-3gebruikt op 1024. Dit fout doen gooit eenPG::DataExceptionop insert met een verwarrende foutmelding, en ik heb meer dan één team er een middag mee kwijt zien raken.chunk_indexlaat een enkele source-rij (een supportticket, een wiki-pagina) meerdere embeddings produceren — één per chunk van ~500 tokens. Lange documenten hebben chunking nodig; daar is geen manier omheen, omdat zowel context windows als semantische samenhang een plafond hebben.tenant_idop elke rij is het allerbelangrijkste veld voor een multi-tenant SaaS. Het gaat in elke query, en het hoort de leidende kolom van je HNSW-index te zijn — of je gebruikt partial indexes per tenant, als je tenant-aantal klein genoeg is. Ik schreef over het tenant-isolatiemodel dat ik als basislaag onder deze tabel gebruik.
De HNSW-index Bouwen
Rails pgvector ondersteunt twee indextypes: HNSW (hierarchical navigable small world) en IVFFlat. In 2026 raad ik HNSW aan voor bijna elke workload — het heeft betere recall bij dezelfde snelheid, bouwt sneller dan vroeger, en heeft geen trainingsset nodig. Maak hem aan met:
class AddHnswIndexToContentEmbeddings < ActiveRecord::Migration[8.0]
disable_ddl_transaction!
def change
add_index :content_embeddings, :embedding,
using: :hnsw,
opclass: :vector_cosine_ops,
with: { m: 16, ef_construction: 64 },
algorithm: :concurrently,
name: "idx_content_embeddings_hnsw_cosine"
end
end
De twee parameters die ertoe doen:
m: 16is het aantal connecties per graph-node. Hogere recall, hoger geheugengebruik. 16 is de pgvector-default en het juiste startpunt.ef_construction: 64bepaalt de kwaliteit van de indexbouw. Hoger is langzamer bouwen maar geeft betere recall. Zet op 128 als je dataset klein genoeg is dat bouwsnelheid geen probleem is.
Bij querytijd stuurt ef_search de snelheid/recall-afweging. Zet hem per-connectie vlak voor je de zoekopdracht uitvoert:
ActiveRecord::Base.connection.execute("SET LOCAL hnsw.ef_search = 100")
Hogere ef_search geeft nauwkeurigere resultaten maar langzamere queries. 40 is de default; 100 is waar ik meestal zit voor user-facing zoek. Boven 200 betaal je latentie voor verwaarloosbare recall-winst.
Eén valkuil: HNSW-indexen zijn groot in geheugen en langzaam te bouwen op een hete productietabel. Doe de initiële bouw in een onderhoudsvenster, of op een replica die je vervolgens promoveert. Een HNSW-index concurrent bouwen op een 8M-rijentabel duurde bij mij 47 minuten op het bovenstaande klantproject; dezelfde bouw op een db.r6g.4xlarge was 22 minuten. Reken erop.
De Embedding-pipeline
Embeddings moeten ergens gegenereerd worden. Ik zet dit in een background job die getriggerd wordt door een ActiveRecord-callback, met idempotentie en rate-limiting ingebouwd:
class EmbedContentJob < ApplicationJob
queue_as :embeddings
def perform(embeddable_gid, model: "text-embedding-3-small")
embeddable = GlobalID::Locator.locate(embeddable_gid)
return unless embeddable
text = embeddable.embeddable_text
chunks = TextChunker.new(text, max_tokens: 500, overlap: 50).call
ContentEmbedding.transaction do
embeddable.content_embeddings.where(model: model).delete_all
chunks.each_with_index do |chunk, index|
embedding = OpenAI::Client.new.embeddings(
parameters: { model: model, input: chunk }
).dig("data", 0, "embedding")
embeddable.content_embeddings.create!(
tenant_id: embeddable.tenant_id,
model: model,
chunk_index: index,
chunk_text: chunk,
embedding: embedding,
metadata: { char_count: chunk.length }
)
end
end
end
end
class SupportTicket < ApplicationRecord
has_many :content_embeddings, as: :embeddable, dependent: :destroy
after_commit :enqueue_embedding_job, on: [:create, :update], if: :saved_change_to_body?
def embeddable_text
"Subject: #{subject}\n\n#{body}"
end
private
def enqueue_embedding_job
EmbedContentJob.perform_later(to_gid.to_s)
end
end
Drie subtiliteiten die me te lang kostten om uit te vogelen:
- Verwijder bestaande embeddings binnen de transactie. Als een document wordt bijgewerkt, moeten de oude chunks weg voordat de nieuwe binnenkomen, of je stapelt verouderde resultaten op. Het buiten de transactie doen opent een venster waarin het document nul embeddings heeft en zoekopdrachten hem stilletjes missen.
- Batch je embedding-API-calls waar mogelijk. OpenAI’s embedding-endpoint accepteert tot 2.048 inputs per request. Als je 100k documenten aan het backfillen bent, duurt een naïeve één-voor-één-loop een dag; batches van 100 zijn in een uur klaar. De LLM-kostentracking-discipline die ik hier gebruik geldt ook voor embeddings — ze zijn goedkoop per token, maar op schaal makkelijk uit de hand te laten lopen.
- Bewaak je background-queue. Embedding-jobs zijn goedkope CPU maar raken een externe API met rate limits. Geef ze een eigen queue, cap de concurrency, en laat de rest van je background-werk onaangetast als OpenAI hikt.
Queries met Rails pgvector
De zoekactie zelf is waar de meeste tutorials stoppen en waar productie-Rails pgvector-systemen pas beginnen. De naïeve query ziet er zo uit:
class ContentEmbedding < ApplicationRecord
belongs_to :tenant
belongs_to :embeddable, polymorphic: true
has_neighbors :embedding, dimensions: 1536, normalize: true
end
query_embedding = OpenAI::Client.new.embeddings(
parameters: { model: "text-embedding-3-small", input: user_question }
).dig("data", 0, "embedding")
results = ContentEmbedding
.where(tenant_id: Current.tenant.id)
.nearest_neighbors(:embedding, query_embedding, distance: "cosine")
.limit(10)
Dat werkt. Wat dat niet doet is de twee faalmodi van pure vector-search opvangen: het mist documenten die andere woorden gebruiken dan de vraag (“password reset” vs “kan niet inloggen”), en het levert vrolijk semantisch-dichtbije maar feitelijk-verkeerde chunks terug. De oplossing is hybride zoek — combineer vector-similarity met Postgres full-text search en re-rank.
Dit is het patroon dat ik ship:
class HybridSearch
def initialize(tenant:, query:, limit: 10)
@tenant = tenant
@query = query
@limit = limit
end
def call
vector_results = vector_search(k: 40)
text_results = text_search(k: 40)
reciprocal_rank_fusion(vector_results, text_results).first(@limit)
end
private
def vector_search(k:)
embedding = embed(@query)
ContentEmbedding
.where(tenant_id: @tenant.id)
.nearest_neighbors(:embedding, embedding, distance: "cosine")
.limit(k)
.pluck(:id)
end
def text_search(k:)
ContentEmbedding
.where(tenant_id: @tenant.id)
.where("chunk_text_tsv @@ websearch_to_tsquery('english', ?)", @query)
.order(Arel.sql("ts_rank_cd(chunk_text_tsv, websearch_to_tsquery('english', #{ActiveRecord::Base.connection.quote(@query)})) DESC"))
.limit(k)
.pluck(:id)
end
def reciprocal_rank_fusion(*rankings, k: 60)
scores = Hash.new(0.0)
rankings.each do |ranking|
ranking.each_with_index do |id, index|
scores[id] += 1.0 / (k + index + 1)
end
end
scores.sort_by { |_, score| -score }.map(&:first)
end
def embed(text)
OpenAI::Client.new.embeddings(
parameters: { model: "text-embedding-3-small", input: text }
).dig("data", 0, "embedding")
end
end
Reciprocal Rank Fusion (RRF) is het fusie-algoritme dat gewoon werkt — geen tuning-parameters die ertoe doen, geen kalibratie vereist. Het verslaat elke “gewogen combinatie van scores”-aanpak die ik heb gebenchmarkt, omdat ruwe vector-afstanden en text-search-scores op compleet verschillende schalen zitten. RRF geeft alleen om rangpositie, wat schaalvrij is.
De chunk_text_tsv is een gegenereerde tsvector-kolom geïndexeerd met GIN. Voeg hem toe in een migratie:
class AddTsvectorToContentEmbeddings < ActiveRecord::Migration[8.0]
def up
execute <<~SQL
ALTER TABLE content_embeddings
ADD COLUMN chunk_text_tsv tsvector
GENERATED ALWAYS AS (to_tsvector('english', chunk_text)) STORED;
SQL
add_index :content_embeddings, :chunk_text_tsv, using: :gin
end
end
Resultaten aan een LLM Voeden: De RAG-loop
De retrieval-helft van een RAG-systeem voedt een generatie-call. Dit is de vorm die ik ship — token-budget-bewust, tenant-scoped, en geïnstrumenteerd:
class AnswerQuestion
MAX_CONTEXT_TOKENS = 6000
def initialize(tenant:, question:)
@tenant = tenant
@question = question
end
def call
embedding_ids = HybridSearch.new(tenant: @tenant, query: @question, limit: 20).call
chunks = ContentEmbedding.where(id: embedding_ids).index_by(&:id)
ranked = embedding_ids.map { |id| chunks[id] }.compact
context, used_ids = pack_context(ranked)
response = OpenAI::Client.new.chat(
parameters: {
model: "gpt-4o-mini",
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: "Context:\n#{context}\n\nQuestion: #{@question}" }
],
temperature: 0.2
}
)
{ answer: response.dig("choices", 0, "message", "content"), sources: used_ids }
end
private
def pack_context(chunks)
budget = MAX_CONTEXT_TOKENS
used = []
parts = []
chunks.each do |chunk|
tokens = TokenCounter.count(chunk.chunk_text)
break if tokens > budget
parts << "[#{chunk.embeddable_type}##{chunk.embeddable_id}]\n#{chunk.chunk_text}"
used << chunk.id
budget -= tokens
end
[parts.join("\n\n---\n\n"), used]
end
end
Twee productiedisciplines uit die code:
- Geef source-ID’s terug aan de caller. Elk antwoord dat je app rendert hoort de user te tonen welke documenten geciteerd zijn. Dit is wat een LLM-antwoord vertrouwenswaardig maakt — en het is wat je supportteam laat zien wanneer de retrieval fout zit.
- Pak de context greedy binnen een token-budget. Alles wat je hebt opgehaald naar de LLM sturen verspilt geld en voegt latentie toe. Afgekapte troep sturen produceert hallucinaties. Greedy pack-tot-vol is saai, goedkoop en correct.
Wat te Monitoren in Productie
Ship Rails pgvector vanaf dag één met instrumentatie, want retrieval-kwaliteit regresseert stilletjes. De signalen die ik op elk dashboard zet:
- p50- / p95-latentie van vector-queries. Een spike betekent meestal dat iemand een
WHERE-filter heeft toegevoegd die de HNSW-index onbruikbaar maakte, ofshared_buffersis te klein voor de index. Streef naar p95 onder 50 ms. - Recall tegen een gelabelde gold set. Kies 50 echte vragen met bekende-correcte antwoord-chunks, en hertest ze bij elke embedding-model-wisseling. Dit is de enige manier om een model-swap te vangen die stilletjes resultaten verslechtert.
- Cache-hitratio op de embedding-API. Herhaalde queries raken dezelfde cache-lijn; als de hit rate zakt, roept iemand
embed()aan in een hot path. - Sources-geklikt-ratio. Als users nooit doorklikken op de geciteerde bronnen, is óf het antwoord volledig zelfvoorzienend (goed) óf zijn de bronnen irrelevant (slecht). Segmenteer en onderzoek.
Ik pijp alle vier in de LLM-observability-stack waarop ik dit jaar heb gestandaardiseerd, die retrieval-kwaliteit automatisch correleert met downstream antwoordkwaliteit.
Het Rendement
Vier weken na dat eerste founder-gesprek stond de support-antwoordbot live in de Zendesk-sidebar van de klant. Zes weken later maten ze een deflectie-ratio van 43% op tier-1-tickets en een 60% reductie in de inwerktijd van nieuwe medewerkers. De hele retrieval-laag draait op de Postgres waar ze al voor betaalden. Geen nieuwe vendor. Geen nieuw outage-domein. Dat is het rendement dat Rails pgvector oplevert wanneer je het serieus neemt als productiesysteem in plaats van als proof of concept.
FAQ
Moet ik pgvector gebruiken of een dedicated vector-database zoals Pinecone of Weaviate?
Gebruik Rails pgvector tenzij je voorbij ~50M vectoren zit en horizontaal-gesharde ANN nodig hebt met sub-10ms latentie op schaal. Onder die drempel is pgvector sneller te shippen, goedkoper te draaien, en geeft het je gratis joins met je tenant- en access-control-data. De teams die ik heb zien betreuren dat ze Pinecone hebben gekozen noemen bijna zonder uitzondering de round-trip-kosten van “haal top-K uit Pinecone, filter dan in Postgres” als reden.
Welk embedding-model moet ik gebruiken met Rails pgvector?
Voor Engelse tekst is text-embedding-3-small van OpenAI de default die ik aanraad — 1536 dimensies, goedkoop, goede kwaliteit, en goed begrepen. Voor meertalige of gespecialiseerde domeinen: evalueer Voyage’s voyage-3 en Cohere’s embed-multilingual-v3. Welke je ook kiest, behandel de keuze als omkeerbaar: zet de modelnaam op de rij, bewaar de brontekst zodat je opnieuw kunt embedden, en wees klaar om een bake-off te draaien tegen een gold set voordat je switcht.
Hoe re-embed ik een corpus zonder downtime?
Voeg een tweede content_embeddings_v2-tabel toe met het nieuwe model en de nieuwe dimensies, backfill hem in de achtergrond terwijl de app blijft serveren vanaf content_embeddings, en schakel reads over met een feature flag zodra de backfill klaar is en kwaliteitsmetrics er goed uitzien. Verwijder de oude tabel pas als je zeker bent. Dit is precies dezelfde vorm als het zero-downtime-migraties-patroon, toegepast op embeddings.
Werkt Rails pgvector op RDS, Aurora, Supabase en Cloud SQL?
Ja. RDS PostgreSQL 15+ ondersteunt pgvector als managed extensie (aanzetten in de parameter group en CREATE EXTENSION vector draaien). Aurora PostgreSQL 15.5+ ondersteunt het. Supabase ships met pgvector standaard aan. GCP Cloud SQL ondersteunt het op PostgreSQL 15+. DigitalOcean Managed Postgres ondersteunt het. De enige plek waar ik wrijving heb gehad is heel oude Heroku Postgres, waar de extensie niet beschikbaar is op Standard-tier-plannen — Standard-2 en hoger zijn prima.
Semantische zoek of een RAG-systeem bouwen op Rails en er zeker van willen zijn dat de retrieval, de pipeline en de observability in productie overeind blijven? TTB Software helpt teams LLM-gedreven features shippen bovenop de Postgres die ze al draaien. Negentien jaar Rails, drie jaar productie-LLM-applicaties, en pgvector is de retrieval-stack die ik vertrouw.
Related Articles
Rails API-authenticatie: JWT, Sessiecookies en API-sleutels — Wanneer Gebruik Je Wat
Rails API-authenticatie in 2026: JWT, sessiecookies en API-sleutels vergeleken — beveiligingsafwegingen, Rails 8-code...
Rails Passkeys: WebAuthn Passwordless Authenticatie met webauthn-ruby in Rails 8
Rails passkeys en WebAuthn in Rails 8: bouw productie-passwordless authenticatie met webauthn-ruby, van registratiefl...
Rails PostgreSQL Row-Level Security: Multi-Tenant SaaS Isolatie met RLS Policies
Rails PostgreSQL Row-Level Security voor multi-tenant SaaS: hoe je RLS-policies opzet, sessievariabelen inzet en veil...