RAG Pipeline
ask-rag gives you everything you need to build Retrieval-Augmented Generation applications — load documents from files, split them into chunks, store embeddings in a vector store, and retrieve relevant context to ground your LLM’s answers.
gem "ask-rag"
Quick Start
Here’s the full pipeline end-to-end:
require "ask-rag"
# 1. Load a document
loader = Ask::RAG::Loader::Text.new
docs = loader.load("README.md")
# 2. Split into chunks
splitter = Ask::RAG::TextSplitter::RecursiveCharacter.new(
chunk_size: 500, chunk_overlap: 50
)
chunks = splitter.split_documents(docs)
# 3. Embed and store
store = Ask::RAG::VectorStore::InMemory.new
store.add(chunks, model: "text-embedding-3-small")
# 4. Search
results = store.similarity_search("How do I install this?", limit: 3)
results.each do |doc|
puts "[#{doc.metadata[:score].round(3)}] #{doc.content[0..80]}..."
end
Documents
The fundamental unit in ask-rag is the Ask::Document. It’s a frozen value object with three fields:
doc = Ask::Document.new(
content: "Ruby was created by Matz in 1995.",
metadata: { source: "history.pdf", page: 3, author: "Matz" },
id: "doc_abc123"
)
doc.content # => "Ruby was created by Matz in 1995."
doc.metadata # => { source: "history.pdf", page: 3, author: "Matz" }
doc.id # => "doc_abc123"
doc.to_h # => { content: "...", metadata: {...}, id: "doc_abc123" }
Documents are immutable — once created, they cannot be modified. Equality is based on content and metadata (the id is ignored for equality).
Documents flow through every stage of the pipeline:
Loader → Document → Splitter → [Document, Document, ...] → Embedder → VectorStore
↓
Similarity Search
↓
[Document, ...]
Loaders
Loaders read files and return arrays of Ask::Document. Each loader handles a specific file format.
Built-in Loaders
| Loader | File type | Requirement |
|---|---|---|
Ask::RAG::Loader::Text | Plain text (.txt) | None |
Ask::RAG::Loader::Markdown | Markdown (.md) | None |
Ask::RAG::Loader::CSV | Comma-separated values (.csv) | csv gem |
Ask::RAG::Loader::JSON | JSON documents (.json) | json gem |
Ask::RAG::Loader::HTML | HTML pages (.html) | nokogiri gem |
Ask::RAG::Loader::PDF | PDF documents (.pdf) — one doc per page | pdf-reader gem |
Ask::RAG::Loader::Directory | Recursive directory walk | None (uses other loaders) |
Loaders that need an external gem are skipped automatically if the gem isn’t installed — no errors, just a missing capability.
Text Loader
loader = Ask::RAG::Loader::Text.new
docs = loader.load("notes.txt")
# => [Ask::Document(content: "...", metadata: { source: "notes.txt" })]
Markdown Loader
loader = Ask::RAG::Loader::Markdown.new
docs = loader.load("documentation.md")
# Each .md file becomes one document
# metadata[:format] is "markdown"
CSV Loader
# All columns become content
loader = Ask::RAG::Loader::CSV.new
docs = loader.load("data.csv")
# One document per row, all fields joined with newlines
# Only specific columns become content
loader = Ask::RAG::Loader::CSV.new(content_columns: %w[title body])
docs = loader.load("articles.csv")
# Separate metadata columns
loader = Ask::RAG::Loader::CSV.new(
content_columns: %w[body],
metadata_columns: %w[author date]
)
docs = loader.load("posts.csv")
docs.first.metadata # => { source: "posts.csv", author: "Alice", date: "2026-01-01" }
JSON Loader
# Array of objects — one document per object
loader = Ask::RAG::Loader::JSON.new(content_key: "body")
docs = loader.load("articles.json")
# Each object's "body" field becomes content
# All other fields become metadata
# With explicit metadata keys
loader = Ask::RAG::Loader::JSON.new(
content_key: "content",
metadata_keys: %w[title date]
)
docs = loader.load("posts.json")
docs.first.metadata # => { source: "posts.json", title: "Hello", date: "2026-01-01" }
HTML Loader
Requires nokogiri. Strips <script>, <style>, <nav>, <footer>, and <header> elements by default.
loader = Ask::RAG::Loader::HTML.new
docs = loader.load("page.html")
# Customize elements to remove
loader = Ask::RAG::Loader::HTML.new(
elements_to_remove: %w[script style nav footer header aside]
)
PDF Loader
Requires pdf-reader. Produces one document per page.
loader = Ask::RAG::Loader::PDF.new
docs = loader.load("manual.pdf")
docs.length # => number of pages
docs[0].metadata # => { source: "manual.pdf", page: 1, format: "pdf" }
Directory Loader
Auto-detects file types by extension and dispatches to the appropriate loader. Skips .git, node_modules, .svn, and other non-source directories by default.
loader = Ask::RAG::Loader::Directory.new
docs = loader.load("path/to/docs/")
# Loads all .md, .txt, .html, .csv, .json, .pdf files recursively
# With glob pattern — only Markdown files
loader = Ask::RAG::Loader::Directory.new(pattern: "**/*.md")
docs = loader.load("docs/")
# Custom skip directories
loader = Ask::RAG::Loader::Directory.new(
skip_dirs: %w[.git node_modules vendor tmp]
)
Lazy Loading
All loaders support lazy enumeration via lazy_load:
loader = Ask::RAG::Loader::Text.new
loader.lazy_load("big_file.txt").each do |doc|
# Process one document at a time — no need to hold all in memory
end
Text Splitters
Splitters break large documents into smaller, semantically coherent chunks. This is essential for RAG because LLMs have context windows — you can only fit so much text in a prompt.
RecursiveCharacter Splitter
The workhorse. Splits text by trying separators in order: "\n\n" (paragraphs), "\n" (lines), ". " (sentences), " " (words), then characters. Each chunk fits within chunk_size.
splitter = Ask::RAG::TextSplitter::RecursiveCharacter.new(
chunk_size: 1000, # Target chunk size in characters
chunk_overlap: 200 # Overlap between consecutive chunks
)
# Split raw text
chunks = splitter.split_text(long_text)
# => ["chunk1...", "chunk2...", ...]
# Split documents (preserves metadata)
doc = Ask::Document.new(content: long_text, metadata: { source: "doc.md" })
chunks = splitter.split_documents([doc])
# => [Document(content: "chunk0...", metadata: { source: "doc.md", chunk: 0 }), ...]
When to use it: General-purpose text, articles, documentation, any prose.
Markdown Splitter
Splits by markdown headers (#, ##, ###, etc.) and preserves the header hierarchy.
splitter = Ask::RAG::TextSplitter::Markdown.new
chunks = splitter.split_text(markdown_content)
# Each chunk starts at a header and includes all content
# until the next header at the same or higher level
When to use it: Documentation, README files, wiki pages — anything with headers.
Custom Splitters
Subclass Ask::RAG::TextSplitter::Base and implement split_text(text):
class SentenceSplitter < Ask::RAG::TextSplitter::Base
def split_text(text)
text.split(/(?<=[.!?])\s+/).each_slice(3).map { |s| s.join(" ") }
end
end
Vector Stores
Vector stores hold document embeddings and provide similarity search. When you add documents, they’re embedded using an LLM provider’s embedding model. When you search, your query is embedded the same way and compared against stored vectors.
InMemory (Built-in)
Pure Ruby, zero dependencies. Uses cosine similarity. Perfect for development, testing, and small-scale prototypes.
store = Ask::RAG::VectorStore::InMemory.new
# Add documents (embeds them using ask-llm-providers)
store.add(chunks, model: "text-embedding-3-small")
# Search
results = store.similarity_search("How do I configure auth?", limit: 5)
Pros: No database needed, works immediately, thread-safe. Cons: All data is lost on process restart, slow for >10,000 documents.
PGVector (PostgreSQL)
Production-grade vector store using PostgreSQL’s pgvector extension. Requires the pgvector gem and ActiveRecord.
# Migration first:
# create_table :embeddings do |t|
# t.text :content
# t.jsonb :metadata
# t.vector :embedding, limit: 1536
# end
# add_index :embeddings, :embedding, using: :ivfflat, opclass: :vector_cosine_ops
store = Ask::RAG::VectorStore::PGVector.new(
table_name: :embeddings,
embedding_column: :embedding
)
store.add(chunks, model: "text-embedding-3-small")
results = store.similarity_search("query", limit: 5)
The PGVector adapter auto-loads when the pgvector gem is installed. If the gem isn’t available, the adapter is skipped silently.
Similarity Search Options
All vector stores support these search parameters:
# Basic search
results = store.similarity_search("query text")
# Limit results
results = store.similarity_search("query", limit: 3)
# Metadata filtering — only return documents whose metadata matches
results = store.similarity_search(
"query",
filter: { source: "api_docs.md", version: "2.0" }
)
# MMR (Max Marginal Relevance) — diversify results
results = store.similarity_search(
"query",
limit: 5,
mmr: true,
diversity_bonus: 0.3
)
# With MMR, results include both :score and :mmr_score in metadata
Search by Vector
If you’ve already embedded the query yourself, use similarity_search_by_vector:
results = store.similarity_search_by_vector(
[0.1, 0.2, 0.3, ...], # pre-computed query vector
limit: 5,
filter: { category: "ruby" }
)
How Scores Work
Search results have their similarity score in metadata[:score]:
results = store.similarity_search("authentication")
results.first.metadata[:score] # => 0.923
Scores range from -1 to 1 for cosine similarity. Higher values mean more similar.
High-Level RAG Query
The Ask::RAG::Query.query method combines retrieval and generation in one call — it searches the vector store, builds a prompt with the retrieved context, and asks an LLM for an answer.
answer = Ask::RAG::Query.query(
store: store,
question: "What is the default timeout configuration?",
model: "gpt-4o" # LLM model to answer with
)
puts answer.content # The LLM's answer
puts answer.metadata[:sources] # ["config.md", "settings.md"]
puts answer.metadata[:model] # "gpt-4o"
If the store has no relevant documents, query returns nil — the LLM is never called.
Complete Example: Documentation Q&A Bot
require "ask-rag"
# Index phase — run once
puts "Indexing documentation..."
store = Ask::RAG::VectorStore::InMemory.new
loader = Ask::RAG::Loader::Directory.new(pattern: "**/*.md")
splitter = Ask::RAG::TextSplitter::RecursiveCharacter.new(
chunk_size: 1000,
chunk_overlap: 200
)
docs = splitter.split_documents(loader.load("docs/"))
puts "Indexed #{store.add(docs, model: "text-embedding-3-small").length} chunks"
puts "Done indexing. Ready for questions."
# Query phase — run for each user question
loop do
print "\nAsk a question (or 'quit'): "
question = gets.chomp
break if question == "quit"
answer = Ask::RAG::Query.query(
store: store,
question: question,
model: "gpt-4o",
limit: 3
)
if answer
puts "\n#{answer.content}"
puts "\nSources: #{answer.metadata[:sources].join(', ')}"
else
puts "\nNo relevant documents found."
end
end
Integration with ask-agent
Use ask-rag as a tool inside an ask-agent session:
require "ask-rag"
require "ask-agent"
# Set up your vector store (run once)
store = Ask::RAG::VectorStore::InMemory.new
loader = Ask::RAG::Loader::Markdown.new
splitter = Ask::RAG::TextSplitter::RecursiveCharacter.new
chunks = splitter.split_documents(loader.load("docs/"))
store.add(chunks, model: "text-embedding-3-small")
# Define a search tool your agent can use
class SearchDocs < Ask::Tool
description "Search documentation for relevant information"
param :query, type: :string, desc: "Search query"
def execute(query:)
store.similarity_search(query, limit: 3).map do |doc|
{ content: doc.content[0..500], score: doc.metadata[:score] }
end
end
end
# Now your agent can search docs automatically
session = Ask::Agent::Session.new(
model: "gpt-4o",
tools: [SearchDocs]
)
session.run("How do I configure authentication?")
Design Notes
Why Documents?
A document is the universal currency of the RAG pipeline. Every step transforms documents:
- Load — a file becomes one or more documents
- Split — one document becomes many smaller documents (chunks)
- Embed — document content is converted to a vector (the document object is stored alongside)
- Search — a query returns documents ranked by similarity
Without a document abstraction, every step would need its own data type and pipeline composition would require glue code.
Why Separate Documents from Vectors?
Documents and vectors have different lifetimes:
- A document changes when its content is edited
- A vector changes when the embedding model changes
If you store documents inside the vector store, migrating to a new embedding model means re-inserting everything — even the text hasn’t changed. With decoupled documents, you just re-embed and update the vector column.
Documents are also human-readable and debuggable. Vectors are opaque arrays of floats.
Embedding Model Tracking
Both InMemory and PGVector stores track which embedding model was used during add(). When you call similarity_search(), the same model is used for query embedding — no dimension mismatches.
If an embedding call fails, Ask::RAG::EmbeddingError is raised with a clear message rather than silently returning a zero vector.
Installation
# Gemfile
gem "ask-rag"
# Optional: for HTML loading
gem "nokogiri"
# Optional: for PDF loading
gem "pdf-reader"
# Optional: for PostgreSQL vector storage
gem "pgvector"