35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English 35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English
Rails RAG: Bouw een productie-klare Retrieval Augmented Generation-pijplijn met Claude en pgvector

Rails RAG: Bouw een productie-klare Retrieval Augmented Generation-pijplijn met Claude en pgvector

Roger Heykoop
AI in Rails, Ruby on Rails
Rails RAG-gids: bouw een productie retrieval augmented generation-pijplijn met pgvector, Claude en streaming. Echte code, echte chunking, echte tradeoffs.

Een fintech-klant belde me in januari omdat hun supportteam verzoop. Ze hadden zes jaar aan policy-PDFs, interne runbooks, compliance-memo’s en Confluence-pagina’s — en elke nieuwe medewerker besteedde drie weken aan leren waar dingen stonden. “Kan ChatGPT niet gewoon vragen beantwoorden op basis van onze docs?” vroegen ze. Dat is de zin waarmee elk RAG-project begint. En na negentien jaar Rails kan ik je zeggen: Rails RAG dat in productie écht werkt gaat minder over het model en meer over de plumbing eromheen.

Dit bericht is het draaiboek dat ik had willen hebben toen ik mijn eerste systeem bouwde. We gaan een volledige Rails RAG-pijplijn opzetten met pgvector voor opslag, Claude voor generatie, en de productiezorgen waar niemand je voor waarschuwt tot je om twee uur ‘s nachts gepiept wordt.

Wat Rails RAG eigenlijk is

RAG — Retrieval Augmented Generation — is het patroon waarbij je een model niet fine-tunet op je documenten. In plaats daarvan haal je bij een vraag de relevante passages uit je eigen data op en voeg je die toe aan de prompt zodat het model kan antwoorden met jouw content als basis.

Een Rails RAG-systeem heeft drie fasen:

  1. Ingestie. Chunk je documenten, embed elke chunk, sla de vectoren op.
  2. Retrieval. Als er een vraag binnenkomt: embed hem, zoek de dichtstbijzijnde chunks, bouw een context op.
  3. Generatie. Stuur de vraag plus opgehaalde context naar een LLM zoals Claude en stream het antwoord.

Elk van die stappen heeft een productievalkuil. Laten we ze langslopen.

Waarom Rails een uitstekende match is voor Retrieval Augmented Generation

De meeste RAG-tutorials zijn geschreven in Python met LangChain en gaan uit van een data-science-team. Rails-teams zien die stack en denken dat RAG niets voor hen is. Dat is precies andersom. Postgres met pgvector handelt embeddings prachtig af, Active Job doet de ingestie, Active Record beheert chunk-metadata, en Action Controller::Live doet de streaming. Je hebt geen nieuwe service nodig. Je hebt een nieuwe tabel nodig.

Ik heb inmiddels drie productie-RAG-systemen in Rails gebouwd. Geen van alle introduceerde een nieuwe runtime. Alle drie draaiden op de bestaande Postgres die de app al had.

Ingestie: chunken, embedden, opslaan

De beslissing met de grootste hefboom in Rails RAG is hoe je je documenten chunkt. Chunks die te groot zijn verdunnen het relevante signaal. Chunks die te klein zijn verliezen context. Mijn default: 800 tokens met 100 tokens overlap, gemeten met een echte tokenizer, niet met karakteraantallen.

Hier is het schema. Ervan uitgaande dat pgvector al is opgezet — zo niet, begin dan met de pgvector en semantic search gids.

class CreateDocumentChunks < ActiveRecord::Migration[8.0]
  def change
    create_table :document_chunks do |t|
      t.references :document, null: false, foreign_key: true
      t.text :content, null: false
      t.integer :position, null: false
      t.integer :token_count, null: false
      t.jsonb :metadata, default: {}
      t.column :embedding, :vector, limit: 1536
      t.timestamps
    end

    add_index :document_chunks,
              :embedding,
              using: :hnsw,
              opclass: :vector_cosine_ops,
              name: "index_chunks_on_embedding_hnsw"
  end
end

De chunker. Ik gebruik de tiktoken_ruby gem omdat OpenAI’s text-embedding-3-small nog steeds de beste prijs-kwaliteit embedding op de markt is, en zijn tokenizer is degene waar we rekening mee moeten houden.

class Chunker
  CHUNK_TOKENS = 800
  OVERLAP_TOKENS = 100

  def initialize(text)
    @text = text
    @encoder = Tiktoken.encoding_for_model("text-embedding-3-small")
  end

  def call
    tokens = @encoder.encode(@text)
    stride = CHUNK_TOKENS - OVERLAP_TOKENS
    chunks = []

    (0...tokens.length).step(stride) do |start|
      window = tokens[start, CHUNK_TOKENS]
      break if window.nil? || window.empty?
      chunks << {
        content: @encoder.decode(window),
        token_count: window.length,
        position: chunks.length
      }
      break if start + CHUNK_TOKENS >= tokens.length
    end

    chunks
  end
end

Doe geen ingestie in de web-request. Embedding-API-calls zijn traag, rate-limited en af en toe instabiel. Schuif ingestie naar een background job — Solid Queue of Sidekiq werken beide prima.

class IngestDocumentJob < ApplicationJob
  queue_as :ingest

  def perform(document_id)
    document = Document.find(document_id)
    chunks = Chunker.new(document.body).call

    embeddings = EmbeddingClient.new.embed_batch(chunks.map { _1[:content] })

    DocumentChunk.transaction do
      document.document_chunks.delete_all
      chunks.each_with_index do |chunk, i|
        document.document_chunks.create!(
          chunk.merge(embedding: embeddings[i])
        )
      end
    end
  end
end

Twee productie-opmerkingen die ertoe doen. Eén: batch je embedding-calls. OpenAI accepteert tot 2048 inputs per request. Eén API-call per chunk brandt door je rate limit én je budget. Twee: wikkel de delete-en-recreate in een transactie, anders laat een crash tijdens de ingestie je achter met een half document, en je merkt het pas als een gebruiker naar de ontbrekende helft vraagt.

Retrieval: de query-pijplijn

Retrieval is waar naïeve RAG-implementaties uit elkaar vallen. De ruwe vraag embedden en de top vijf chunks ophalen werkt prima voor demo’s. Het faalt zodra een echte gebruiker typt “hoe zat het ook alweer met die refund-dingen die we vorig jaar hebben aangepast”, want die vraag heeft geen semantische overlap met de eigenlijke policy-tekst.

Het patroon dat in productie stand houdt is: rewrite, retrieve, rerank.

class RagRetriever
  def initialize(question, user:)
    @question = question
    @user = user
  end

  def call
    rewritten = rewrite_question
    candidates = vector_search(rewritten, limit: 20)
    rerank(candidates, original: @question)
  end

  private

  def rewrite_question
    ClaudeClient.new.complete(
      system: "Rewrite the user question as a standalone search query. No preamble.",
      user: @question,
      max_tokens: 120
    )
  end

  def vector_search(text, limit:)
    vector = EmbeddingClient.new.embed(text)
    DocumentChunk
      .joins(:document)
      .where(documents: { tenant_id: @user.tenant_id })
      .order(Arel.sql("embedding <=> '#{vector}'"))
      .limit(limit)
  end

  def rerank(candidates, original:)
    pairs = candidates.map { |c| [original, c.content] }
    scores = RerankerClient.new.score(pairs)
    candidates.zip(scores).sort_by { -_2 }.first(6).map(&:first)
  end
end

Drie punten verdienen nadruk. De tenant-scope in vector_search is niet optioneel — elke productie-RAG-bug die ik heb gedebugd liep uiteindelijk terug op iemand die chunks van een andere tenant ophaalde. De reranker (Cohere, Jina, of een klein lokaal cross-encoder-model) verdubbelt bijna de kwaliteit van de top drie resultaten en is de extra 80ms waard. En de rewrite-stap verandert conversationele follow-ups in echte zoekvragen, wat de grootste winst oplevert ten opzichte van naïeve retrieval.

Generatie: context naar Claude voeren

Ik gebruik standaard Claude Sonnet 4.6 voor de generatiestap in Rails RAG. Het is het best-opgevoede model dat ik heb gebruikt als je zegt “antwoord alleen uit de meegegeven context”. Opus is overkill voor de meeste RAG. Haiku is snel maar hallucineert meer als de context ambigu is.

class RagAnswerer
  SYSTEM_PROMPT = <<~PROMPT
    You are a support assistant. Answer ONLY using the provided context.
    If the context does not contain the answer, say "I don't have that in
    my knowledge base" and stop. Cite the source document ID for every claim
    using the format [doc:123].
  PROMPT

  def initialize(question:, chunks:)
    @question = question
    @chunks = chunks
  end

  def call(&block)
    context = @chunks.map { |c|
      "[doc:#{c.document_id}] #{c.content}"
    }.join("\n\n---\n\n")

    ClaudeClient.new.stream(
      system: SYSTEM_PROMPT,
      user: "Context:\n\n#{context}\n\nQuestion: #{@question}",
      max_tokens: 1024,
      &block
    )
  end
end

Stream de output direct naar de browser. Ik heb dit patroon in detail behandeld in streaming LLM-responses met ActionController::Live, maar de korte versie is: RAG zonder streaming voelt kapot, want het model denkt drie seconden na vóór het eerste token. Met streaming ziet de gebruiker onder de 400ms al een antwoord ontstaan.

Hallucinaties onder controle houden in Rails RAG

De gevaarlijkste failure mode van retrieval augmented generation in Rails is zelfverzekerd foute antwoorden. Het model verzint vrolijk een policy die niet bestaat als je retrieval irrelevante chunks heeft opgehaald. Drie dingen houden dit in toom.

Één: verlaag de temperatuur. Ik gebruik 0.2 voor RAG. Creativiteit is hier geen feature.

Twee: forceer citaties. Zie het [doc:123]-formaat in de system prompt hierboven. In productie verwerk ik het gestreamde antwoord achteraf en verifieer ik dat elk geciteerd document-ID daadwerkelijk in de opgehaalde chunks voorkomt. Als het model een citatie verzint, log ik het en markeer het antwoord.

Drie: zorg voor een weigerantwoord. Het “I don’t have that in my knowledge base”-antwoord is meer waard dan welke slimme prompt-tweak dan ook. Gebruikers accepteren “ik weet het niet”. Ze accepteren geen verzonnen refund-policy.

class HallucinationGuard
  CITATION_PATTERN = /\[doc:(\d+)\]/

  def initialize(answer:, retrieved_ids:)
    @answer = answer
    @retrieved_ids = retrieved_ids.to_set
  end

  def verified?
    cited = @answer.scan(CITATION_PATTERN).flatten.map(&:to_i).to_set
    cited.subset?(@retrieved_ids)
  end
end

Caching: het deel waar niemand het over heeft

Een productie Rails RAG-pijplijn doet drie LLM-calls per antwoord — rewrite, embed, generate — plus een reranker-round-trip. Op schaal wordt dat snel duur. Drie caches veranderen de economie:

  1. Embedding-cache gesleuteld op een SHA256 van de tekst. Embeddings zijn deterministisch voor een gegeven model; cache ze voor altijd.
  2. Rewrite-cache gesleuteld op de vraag. Korte TTL — misschien een uur — omdat mensen dezelfde vraag op veel manieren formuleren.
  3. Answer-cache gesleuteld op vraag plus opgehaalde chunk-IDs. Ook korte TTL. Dit is degene die zichzelf binnen een week terugverdient.

Alle drie passen comfortabel in Rails.cache, gebacked door Solid Cache of Redis.

Observability voor RAG

Je kunt een RAG-systeem niet debuggen zonder logs. Elke request zou de herschreven query, de opgehaalde chunk-IDs, de reranker-scores, het aantal tokens in de uiteindelijke prompt en de output moeten vastleggen. Ik sla dit alles op in een rag_traces-tabel en bouw daar een klein intern dashboard bovenop. Als een gebruiker klaagt “de bot gaf me het verkeerde antwoord”, moet ik exact zien wat retrieval teruggaf. Zonder die tabel zit je te gokken.

FAQ

Wat is het verschil tussen RAG en fine-tuning voor een Rails-app?

Fine-tuning bakt kennis in de modelgewichten en is duur, traag om te updaten en koppelt je aan een modelversie. Rails RAG houdt kennis in je database waar je het direct kunt aanpassen, per tenant kunt scopen en het onderliggende model kunt vervangen zonder te hertrainen. Voor 95% van de business-use-cases is retrieval augmented generation de juiste keuze.

Heb ik een aparte vectordatabase nodig naast Postgres?

Nee. pgvector op Postgres behandelt tientallen miljoenen chunks comfortabel met een HNSW-index. Ik ben nog nooit op een schaal gekomen waar het binnenhalen van Pinecone, Weaviate of Qdrant gerechtvaardigd was voor een Rails-app. Houd het in Postgres tot je gemeten bewijs hebt dat je meer nodig hebt.

Hoe groot moeten mijn chunks zijn voor Rails RAG?

Begin bij 800 tokens met 100 tokens overlap en meet. Kortere chunks (200–400 tokens) verbeteren de precisie voor opzoekvragen. Langere chunks (1200–1500 tokens) werken beter voor uitlegvragen waar context ertoe doet. Als je er maar één getal uit mag kiezen, is 800 een goede default.

Welke LLM moet ik gebruiken voor generatie in een Rails RAG-pijplijn?

Claude Sonnet 4.6 is mijn default omdat het de “antwoord alleen uit context”-instructie betrouwbaarder volgt dan elk ander model dat ik heb getest. GPT-4o zit er dichtbij. Als latentie belangrijker is dan accuraatheid, is Claude Haiku 4.5 goedkoper en sneller, maar het heeft strakkere prompting nodig om hallucinatie te voorkomen.


Hulp nodig bij het opleveren van een productie-RAG-systeem in Rails? TTB Software specialiseert zich in AI-augmented Rails-platformen — embeddings, retrieval-pijplijnen en Claude-integraties die standhouden onder echte gebruikersbelasting. We bouwen al negentien jaar Rails-systemen.

#rails-rag #retrieval-augmented-generation-rails #rails-claude-api #pgvector-rag #anthropic-claude-ruby #llm-rails-production #ai-rails
R

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 Touch

Share this article

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