RUBY ON RAILS · 21 MIN READ ·

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.

Rails pgvector: Semantische Zoek en RAG met PostgreSQL voor LLM-Applicaties

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 EXTENSION op 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: 1536 komt overeen met OpenAI’s text-embedding-3-small. Als je text-embedding-3-large gebruikt zet het op 3072, of als je Voyage’s voyage-3 gebruikt op 1024. Dit fout doen gooit een PG::DataException op insert met een verwarrende foutmelding, en ik heb meer dan één team er een middag mee kwijt zien raken.
  • chunk_index laat 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_id op 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: 16 is het aantal connecties per graph-node. Hogere recall, hoger geheugengebruik. 16 is de pgvector-default en het juiste startpunt.
  • ef_construction: 64 bepaalt 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, of shared_buffers is 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.

#rails-pgvector #rails-rag #rails-semantic-search #postgresql-vector-search #rails-embeddings #rails-llm-integration

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