ragfoundry
Production RAG pipelines from a single configuration. ragfoundry wires LangChain components into LangGraph workflows, covering everything from document loading to chat, evaluation and tracing, so you choose the techniques instead of writing the plumbing.
from ragfoundry import RAGConfig, RAGPipeline
rag = RAGPipeline(RAGConfig.model_validate({
"embeddings": {"provider": "huggingface", "model": "sentence-transformers/all-MiniLM-L6-v2"},
"vector_store": {"provider": "chroma"},
"llm": {"provider": "groq", "model": "llama-3.3-70b-versatile"},
}))
rag.ingest("./docs")
print(rag.query("What is the refund policy?").answer)
| Stage | Options |
|---|---|
| Sources | PDF, DOCX, TXT, Markdown, CSV, XLSX/XLS, web pages (with optional same-site crawling) |
| Chunking | fixed, recursive, semantic, llm; size in characters or tokens; custom strategies |
| Embeddings | Hugging Face, OpenAI, Gemini |
| Vector stores | Chroma, FAISS, Pinecone, Qdrant, Weaviate, Milvus, PGVector |
| LLMs | Groq, OpenAI, Gemini, Claude, Ollama |
| Retrieval | Hybrid BM25 + vector search, reranking, query rewrite / multi-query / HyDE, corrective retrieval loop, metadata filters |
| Chat | Conversation sessions, follow-up question rewriting, history window |
| Context window | Token budget for retrieved passages and for chat history |
| Evaluation | Retrieval: hit rate, precision, recall, MRR, MAP, nDCG. Generation: faithfulness, answer relevance, answer correctness, context relevance, context recall. Optional RAGAS backend |
| Observability | LangSmith or Langfuse |
Contents
- Installation
- API keys and local models
- Quickstart
- Configuration: Python or YAML
- Loading documents
- Chunking
- Embeddings
- Vector stores
- LLMs
- Asking questions
- Chat and conversation history
- Context window limits
- Improving retrieval
- Generation settings
- Keeping the index up to date
- Evaluation
- Tracing with LangSmith or Langfuse
- Using ragfoundry in an application
- Extending ragfoundry
- Examples
- Configuration reference
- How a query runs
- API reference
- Known limitations
- Troubleshooting
- Development and releases
1. Installation
ragfoundry needs Python 3.10 or newer. Use a virtual environment:
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
The core package is small. Each provider is an optional extra, so you install only what you use. Pick one item from each row:
| You need | Extra |
|---|---|
| Document types | loaders (all of PDF, DOCX, Excel, web, token counting) or individually pdf, docx, excel, web, tokens. TXT, MD and CSV need nothing. |
| Embeddings | huggingface, openai or gemini |
| Vector store | chroma, faiss, pinecone, qdrant, weaviate, milvus or pgvector |
| LLM | groq, openai, gemini, claude or ollama |
| Reranking (optional) | huggingface for a local cross-encoder, cohere for Cohere Rerank |
| Exact token counting (optional) | tokens |
| Evaluation with RAGAS (optional) | ragas |
| Tracing (optional) | langsmith or langfuse |
# Local embeddings, Chroma and Groq, reading PDFs
pip install "ragfoundry[pdf,huggingface,chroma,groq]"
# Fully local: Hugging Face embeddings, Chroma, Ollama
pip install "ragfoundry[loaders,huggingface,chroma,ollama]"
# Hosted: OpenAI embeddings and LLM, Qdrant, Langfuse tracing
pip install "ragfoundry[loaders,openai,qdrant,langfuse]"
# Everything
pip install "ragfoundry[all]"
If you select a provider whose extra is missing, the error tells you the exact pip install
command to run.
2. API keys and local models
Hosted providers need an API key. Export it as an environment variable; each SDK reads its own variable, so keys don't have to be in your code or config.
| Provider | Environment variable | Where to get a key |
|---|---|---|
| Groq | GROQ_API_KEY |
console.groq.com |
| OpenAI (embeddings and LLM) | OPENAI_API_KEY |
platform.openai.com |
| Gemini (embeddings and LLM) | GOOGLE_API_KEY |
aistudio.google.com |
| Claude | ANTHROPIC_API_KEY |
console.anthropic.com |
| Pinecone | PINECONE_API_KEY |
app.pinecone.io |
| Cohere Rerank | COHERE_API_KEY |
dashboard.cohere.com |
export GROQ_API_KEY="gsk_..."
You can also pass a key in the config ("api_key": ...), which is useful in notebooks:
from google.colab import userdata # in Google Colab
llm = {"provider": "groq", "model": "llama-3.3-70b-versatile",
"api_key": userdata.get("GROQ_API_KEY")}
Keep keys in a .env file or a secret manager, never in source control.
Local providers need no key:
-
Hugging Face models download on first use, for example
sentence-transformers/all-MiniLM-L6-v2. SetHF_TOKENonly for gated models. -
Ollama: install it from ollama.com, pull a model, and keep the server running:
ollama pull llama3.2 ollama serve # skip if the Ollama app is already running
Model names change often. The ones in this README are examples; use any model your provider currently offers.
3. Quickstart
Put a few documents in a docs/ folder, then:
from ragfoundry import RAGConfig, RAGPipeline
config = RAGConfig.model_validate({
"embeddings": {"provider": "huggingface", "model": "sentence-transformers/all-MiniLM-L6-v2"},
"vector_store": {"provider": "chroma", "collection_name": "my_docs"},
"llm": {"provider": "groq", "model": "llama-3.3-70b-versatile", "temperature": 0},
})
with RAGPipeline(config) as rag:
report = rag.ingest("./docs")
print(report.to_dict()) # documents_loaded, chunks_added, ...
result = rag.query("What is the refund policy?")
print(result.answer) # cites passages as [1], [2] ...
print(result.sources) # files or URLs used, best first
chat = rag.chat() # a conversation with memory
chat.ask("Who approves refunds?")
print(chat.ask("And how long does it take?").answer)
Run the pipeline as a with block, or call rag.close() when you are done, so connections close
and traces are flushed.
4. Configuration: Python or YAML
Every setting lives in one RAGConfig. Only three sections are needed: embeddings,
vector_store and, for answers, llm. Everything else has a sensible default. Unknown keys are
rejected, so a typo such as temprature fails at once instead of being ignored.
In Python, pass a dict:
from ragfoundry import RAGConfig, RAGPipeline
config = RAGConfig.model_validate({
"embeddings": {"provider": "openai", "model": "text-embedding-3-small"},
"vector_store": {"provider": "qdrant", "url": "http://localhost:6333", "collection_name": "kb"},
"llm": {"provider": "claude", "model": "claude-sonnet-5-5", "max_tokens": 1024},
"retrieval": {"top_k": 5, "hybrid": {"enabled": True}},
})
rag = RAGPipeline(config)
In YAML, write the same structure to a file:
# rag.yaml
embeddings:
provider: openai
model: text-embedding-3-small
vector_store:
provider: qdrant
url: ${QDRANT_URL:-http://localhost:6333}
collection_name: kb
llm:
provider: claude
model: claude-sonnet-5-5
max_tokens: 1024
rag = RAGPipeline.from_yaml("rag.yaml")
In YAML, ${VAR} reads an environment variable and ${VAR:-default} adds a fallback. A variable
that is unset and has no default stops with an error rather than becoming an empty string.
examples/rag.yaml lists every section with comments, and
section 21 lists every setting.
5. Loading documents
rag.ingest() accepts a file, a folder, a URL, or a list of any of these. Folders are searched
recursively for supported files.
rag.ingest("./docs")
rag.ingest("./contracts/master.pdf")
rag.ingest(["./policies", "./faq.md", "https://example.com/handbook"])
| Type | Extensions | Extra | Metadata added |
|---|---|---|---|
.pdf |
pdf |
page |
|
| Word | .docx |
docx |
|
| Text | .txt |
none | |
| Markdown | .md, .markdown |
none | |
| CSV | .csv |
none | row; each row becomes a column: value passage |
| Excel | .xlsx, .xlsm, .xls |
excel |
sheet, row |
| Web page | http://, https:// |
web |
title |
Every chunk also gets source (the absolute file path, or the URL), file_type, start_index
and a stable chunk_id.
Choosing files in a folder. loading.glob controls which files are taken, for example
"**/*.pdf" for PDFs only.
Unreadable files. By default one bad file stops the ingest. With on_error: skip it is
skipped and listed in the report:
config = RAGConfig.model_validate({..., "loading": {"on_error": "skip"}})
report = rag.ingest("./docs")
for failure in report.failures:
print(failure.source, failure.error)
Crawling a website. A URL fetches only that page unless you set a link depth:
"loading": {
"web": {
"max_depth": 2, # follow links two levels deep
"max_pages": 100,
"same_domain": True, # stay on the starting site
"respect_robots": True, # obey robots.txt
"delay_seconds": 0.5, # pause between requests
}
}
Loading without indexing. To inspect what a loader produces:
from ragfoundry import load_sources
from ragfoundry.config import LoadingConfig
result = load_sources(["./docs"], LoadingConfig())
print(len(result.documents), result.failures)
6. Chunking
Choose a strategy before your first ingest. recursive is a good default.
"chunking": {
"strategy": "recursive", # fixed | recursive | semantic | llm
"chunk_size": 1000,
"chunk_overlap": 200,
"length_unit": "characters", # or "tokens" (needs the "tokens" extra)
}
| Strategy | How it splits | Cost |
|---|---|---|
fixed |
Exact windows of chunk_size, overlapping by chunk_overlap. Ignores structure. |
None |
recursive |
Paragraphs, then lines, then words, keeping each piece under chunk_size. Set separators to change the order. |
None |
semantic |
Embeds each sentence with buffer_size neighbours and breaks where similarity drops past semantic.breakpoint_percentile. |
Embedding calls at ingest |
llm |
The LLM picks topic boundaries between numbered sentences, one call per llm.window_chars of text. A reply it can't parse falls back to recursive splitting for that window, with a warning. |
LLM calls at ingest |
chunk_size is an upper bound for every strategy. With length_unit: tokens, sizes are counted
with the tiktoken encoding in encoding_name.
To try a strategy without a vector store:
from langchain_core.documents import Document
from ragfoundry import chunk_documents
from ragfoundry.config import ChunkingConfig
chunks = chunk_documents(
[Document(page_content=open("doc.md").read(), metadata={"source": "doc.md"})],
ChunkingConfig(strategy="fixed", chunk_size=300, chunk_overlap=50),
)
7. Embeddings
# Local, free
"embeddings": {"provider": "huggingface", "model": "sentence-transformers/all-MiniLM-L6-v2"}
# OpenAI, or any OpenAI-compatible server through base_url
"embeddings": {"provider": "openai", "model": "text-embedding-3-small"}
# Gemini
"embeddings": {"provider": "gemini", "model": "models/text-embedding-004"}
options is passed to the LangChain embeddings class, for example
{"model_kwargs": {"device": "cuda"}} for Hugging Face on a GPU.
A collection remembers which embedding model filled it. Switching models on an existing
collection raises an error, because mixing them silently breaks search; use a new
collection_name instead.
8. Vector stores
Choose one with vector_store.provider. collection_name (default ragfoundry) separates
datasets in the same store.
No server needed. Data is kept under persist_dir (default .ragfoundry/):
"vector_store": {"provider": "chroma", "collection_name": "kb"}
"vector_store": {"provider": "faiss", "collection_name": "kb"}
"vector_store": {"provider": "qdrant", "path": "./qdrant_data"} # Qdrant local mode
"vector_store": {"provider": "milvus"} # Milvus Lite file
Servers:
"vector_store": {"provider": "chroma", "host": "localhost", "port": 8000}
"vector_store": {"provider": "qdrant", "url": "http://localhost:6333", "api_key": None}
"vector_store": {"provider": "weaviate", "url": "http://localhost:8080", "grpc_port": 50051}
"vector_store": {"provider": "milvus", "uri": "http://localhost:19530", "token": None}
"vector_store": {"provider": "pgvector",
"connection": "postgresql+psycopg://rag:rag@localhost:5432/rag"}
"vector_store": {"provider": "pinecone", "index_name": "my-index"} # collection_name = namespace
Start a server with Docker:
docker run -d -p 6333:6333 qdrant/qdrant # Qdrant
docker run -d -p 8080:8080 -p 50051:50051 \
-e AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED=true -e DEFAULT_VECTORIZER_MODULE=none \
cr.weaviate.io/semitechnologies/weaviate:1.30.0 # Weaviate
docker run -d -p 5432:5432 -e POSTGRES_USER=rag -e POSTGRES_PASSWORD=rag \
-e POSTGRES_DB=rag pgvector/pgvector:pg16 # PGVector
For a Milvus server, follow the Milvus standalone install guide.
Pinecone needs its index created in the Pinecone console first, with the cosine metric and
the same dimension as your embedding model (384 for all-MiniLM-L6-v2, 1536 for OpenAI
text-embedding-3-small). The other stores create their collection on first use.
9. LLMs
"llm": {"provider": "groq", "model": "llama-3.3-70b-versatile"}
"llm": {"provider": "openai", "model": "gpt-4o-mini"}
"llm": {"provider": "gemini", "model": "gemini-2.5-flash"}
"llm": {"provider": "claude", "model": "claude-sonnet-5-5", "max_tokens": 1024}
"llm": {"provider": "ollama", "model": "llama3.2", "base_url": "http://localhost:11434"}
| Setting | Meaning |
|---|---|
temperature |
Sent only when set; some models reject it. 0 gives the most repeatable answers. |
max_tokens |
Maximum answer length. |
api_key |
Overrides the provider's environment variable. |
base_url |
OpenAI-compatible servers, a Claude proxy, or a remote Ollama. |
options |
Passed to the LangChain chat model class, for example {"reasoning": False} for Ollama reasoning models. |
The LLM is created the first time it's needed, so ingest-only scripts and plain retrieval don't
need an llm section at all.
10. Asking questions
result = rag.query("What is the refund policy?")
query returns a RAGResult:
| Field | Contents |
|---|---|
answer |
The generated answer, citing passages as [1], [2] ... |
sources |
Distinct sources of the passages, best first |
documents |
The passages sent to the LLM, as LangChain Documents with metadata |
queries |
Every search query used (more than one with query transforms or retries) |
retries |
Rounds of the corrective loop |
abstained |
True when nothing relevant was found and the abstain message was returned |
standalone_question |
The rewritten question used for search, when chat history changed it |
context_tokens |
Tokens of retrieved context sent to the LLM |
result.to_dict() gives all of it as JSON-ready data.
Options on every query call:
# Only search chunks whose metadata matches exactly
rag.query("Summarise the policy", where={"file_type": "pdf"})
rag.query("What changed?", where={"source": "/abs/path/to/policy.pdf", "page": 3})
# Use more or fewer passages than retrieval.top_k
rag.query("Give me every detail", top_k=10)
source values are absolute paths (or URLs); rag.sources() lists them.
Streaming yields the answer as it is generated:
for token in rag.stream("Summarise the security section"):
print(token, end="", flush=True)
Retrieval only, without calling the LLM:
for doc in rag.retrieve("refund timelines", top_k=3):
print(doc.metadata["source"], doc.page_content[:80])
Async versions for web servers: await rag.aquery(...) and async for token in rag.astream(...).
If no relevant passages are found, the answer is generation.abstain_message, abstained is
True, and the LLM is not asked to answer from nothing.
11. Chat and conversation history
rag.chat() starts a conversation that remembers what was said. Each new question is answered with
the recent conversation in view, so follow-ups like "how long does that take?" work.
chat = rag.chat()
chat.ask("What does the refund policy say?")
result = chat.ask("And how long does it take?")
print(result.standalone_question) # "How long does a refund take under the refund policy?"
print(result.answer)
Two things happen for each follow-up:
- Question rewriting. The LLM rewrites the follow-up into a standalone question
(
result.standalone_question), and that is what gets searched. Searching for "and how long does it take?" alone would find nothing useful. Turn it off withcontext_window.condense_question: falseto save one LLM call per question. - History in the prompt. The most recent turns are sent to the LLM along with the retrieved passages, so the answer can refer back to the conversation.
ChatSession methods:
chat.ask(question, where=None, top_k=None) # -> RAGResult
chat.stream(question) # yields tokens; turn recorded when finished
await chat.aask(question)
async for token in chat.astream(question): ...
len(chat) # completed turns
chat.history # list of LangChain messages
chat.reset() # forget everything
Saving and resuming a conversation. Sessions live in memory. To keep a conversation across
restarts or server processes, store to_dicts() and pass it back:
import json
saved = chat.to_dicts() # [{"role": "user", "content": ...}, {"role": "assistant", ...}]
json.dump(saved, open("chat.json", "w"))
chat = rag.chat(json.load(open("chat.json")))
Managing history yourself. query, stream and their async versions accept history
directly, as LangChain messages, {"role", "content"} dicts or (role, content) tuples. Roles
can be user/human and assistant/ai:
history = [
{"role": "user", "content": "What does the refund policy say?"},
{"role": "assistant", "content": "Refunds are issued within five business days [1]."},
]
result = rag.query("Who approves them?", history=history)
How much history is sent is controlled by context_window, described next.
12. Context window limits
Every LLM has a maximum context size, and long prompts cost more and can lower answer quality.
context_window limits what is put into each prompt:
"context_window": {
"max_context_tokens": 4000, # retrieved passages; unset = send all top_k passages
"truncate_last_passage": True, # shorten the passage that crosses the budget
"max_history_turns": 5, # recent chat turns sent with each question
"max_history_tokens": 2000, # also cap history by tokens; unset = no token cap
"condense_question": True, # rewrite follow-ups before searching
"token_counter": "approximate", # approximate | tiktoken
}
Passage budget. Passages are added in rank order until max_context_tokens is reached, so
the most relevant ones always make it in. The passage that would cross the limit is shortened to
fill the remaining space, ending in … and marked with metadata["truncated"] = True. Set
truncate_last_passage: false to drop it instead. Passages after it are left out.
result.context_tokens reports what was actually sent, and result.documents holds exactly the
passages the LLM saw.
History window. Only the last max_history_turns questions, each with its answer, are sent. If
max_history_tokens is set, the oldest turns are dropped until the rest fit. Whole turns are
always kept together, so the model never sees an answer without its question. ChatSession keeps
the full history; the window only affects what each request sends. max_history_turns: 0 sends no
history and skips question rewriting.
Counting tokens.
-
approximate(default) counts about four characters per token, needs nothing extra, and is close enough for budgeting with most models. -
tiktokencounts exactly with the OpenAI encoding inencoding_name. It needs thetokensextra. -
Any function can be used, for example your model's own tokenizer:
from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-3.1-8B-Instruct") rag = RAGPipeline(config, token_counter=lambda text: len(tokenizer.encode(text)))
Choosing budgets. Leave room for the system prompt, the question and the answer
(llm.max_tokens). For an 8,000-token model, max_context_tokens: 4000 with
max_history_tokens: 1500 is a reasonable start. Watch result.context_tokens and the
truncated flag while you tune.
Both functions are available for your own code:
from ragfoundry import pack_documents, window_history
from ragfoundry.context import approximate_tokens
packed = pack_documents(docs, 1000, approximate_tokens) # .documents, .tokens, .dropped
recent = window_history(messages, 3, None, approximate_tokens)
13. Improving retrieval
Turn these on one at a time, and measure each change with evaluation.
"retrieval": {
"top_k": 5, # passages given to the LLM
"fetch_k": 30, # candidates gathered before fusion and reranking
# Keyword + vector search; helps with names, codes and part numbers
"hybrid": {"enabled": True, "vector_weight": 1.0, "bm25_weight": 1.0},
# Re-score candidates with a stronger model
"rerank": {"provider": "huggingface", "model": "cross-encoder/ms-marco-MiniLM-L-6-v2"},
# Rephrase the question before searching; each strategy adds an LLM call
"query_transform": {"strategies": ["multi_query"], "num_queries": 3},
# Grade passages with the LLM; retry with a new query when too few are relevant
"corrective": {"enabled": True, "max_retries": 1, "min_relevant_docs": 1},
}
| Technique | What it does | Cost |
|---|---|---|
| Hybrid search | Runs BM25 keyword search alongside vector search and merges the rankings with reciprocal rank fusion (rrf_k). The weights tilt the merge. |
Small, in memory |
| Reranking | Re-scores fetch_k candidates against the question and keeps top_k. huggingface runs a cross-encoder locally; cohere uses Cohere Rerank (cohere extra, COHERE_API_KEY). |
One model pass |
rewrite |
Replaces the question with a clearer search query. | 1 LLM call |
multi_query |
Adds num_queries alternative phrasings and searches them all. |
1 LLM call |
hyde |
Writes a hypothetical answer passage and uses it for vector search only. | 1 LLM call |
| Corrective loop | Asks the LLM whether each passage is useful. If fewer than min_relevant_docs pass, it writes a new query and searches again, up to max_retries times. Irrelevant passages are removed before generation. |
1 LLM call per passage, per round |
A sensible order is hybrid, then reranking, then query transforms, then the corrective loop. The first two add little latency; the last two add LLM calls to every query.
14. Generation settings
"generation": {
"system_prompt": "You are a support assistant for Acme. Answer in two sentences. Cite [n].",
"abstain_message": "Sorry, I couldn't find that in our documentation.",
}
The default system prompt tells the model to answer only from the numbered passages, cite them as
[n], say so when the answer isn't there, and treat passage text as data rather than
instructions. A custom system_prompt replaces it completely, so keep those rules in yours.
15. Keeping the index up to date
Run the same ingest again whenever documents change:
report = rag.ingest("./docs")
print(report.chunks_added, report.chunks_unchanged, report.chunks_removed)
Only new or edited chunks are embedded, and chunks that disappeared from a re-read file are removed. If a run is interrupted, run it again and it carries on.
rag.sources() # {"/abs/path/policy.pdf": 12, ...}
rag.delete_source("./docs/old-policy.pdf") # returns the number of chunks removed
rag.delete_source("https://example.com/old-page")
delete_source accepts relative or absolute paths, as ingest does; URLs must match exactly.
Every process that serves the same collection must use the same persist_dir. It holds the chunk
record that hybrid search and incremental ingestion rely on.
16. Evaluation
Write a test set as JSON Lines, one question per line, labelling what should be found and, optionally, a reference answer:
{"question": "How long do refunds take?", "reference_answer": "Five business days.", "relevant_sources": ["refunds.md"]}
{"question": "Who approves travel?", "reference_contexts": ["Travel must be approved by a line manager"]}
| Field | Meaning |
|---|---|
question |
Required |
relevant_sources |
File names, path endings or URLs that should be retrieved |
reference_contexts |
Passages a correct result should overlap |
relevant_ids |
Exact chunk_id values |
reference_answer |
Needed for answer_correctness and context_recall |
Retrieval metrics need at least one of the three relevance fields. A .json file holding a list
works too, as does a list of EvalSample objects.
report = rag.evaluate("eval.jsonl", mode="retrieval") # fast, no LLM judging
report = rag.evaluate("eval.jsonl", mode="generation") # LLM-judged answer quality
report = rag.evaluate("eval.jsonl", mode="all") # both (default)
for metric, stats in report.summary.items():
print(f"{metric:<20} {stats['mean']:.3f} (n={stats['count']})")
report.save("report.json") # per-question rows and errors
from ragfoundry import EvalSample
samples = [EvalSample(question="How long do refunds take?",
reference_answer="Five business days.",
relevant_sources=["refunds.md"])]
report = rag.evaluate(samples)
Retrieval metrics, each at every k in evaluation.k_values: hit_rate@k, precision@k,
recall@k, ndcg@k, plus mrr and map. When several chunks come from one relevant document,
each counts towards precision, but the document counts once towards recall, MAP and nDCG.
Generation metrics, judged by an LLM:
| Metric | Measures | Needs |
|---|---|---|
faithfulness |
Share of the answer's claims supported by the retrieved passages | retrieved passages |
answer_relevance |
How directly the answer addresses the question | |
context_relevance |
Share of retrieved passages useful for the question | retrieved passages |
answer_correctness |
Agreement with the reference answer | reference_answer |
context_recall |
Share of the reference answer's claims found in the passages | reference_answer |
count is the number of samples actually scored; a metric is skipped where it doesn't apply.
Judge model. The judge defaults to the pipeline LLM. A different model gives fairer scores, because it isn't grading its own answers:
"evaluation": {
"llm": {"provider": "openai", "model": "gpt-4o-mini"},
"k_values": [1, 3, 5],
"max_concurrency": 4, # questions evaluated in parallel
}
RAGAS. Install the ragas extra and pass backend="ragas" to score generation with RAGAS
metrics instead of the built-in ones.
The metric functions also work on their own:
from ragfoundry.evaluation import recall_at_k, ndcg_at_k
recall_at_k(["a", "x", "b"], {"a", "b"}, k=3) # 1.0
17. Tracing with LangSmith or Langfuse
# LangSmith (langsmith extra)
"observability": {"provider": "langsmith", "api_key": "...", "project": "my-rag"}
# Langfuse (langfuse extra)
"observability": {
"provider": "langfuse",
"public_key": "pk-lf-...",
"secret_key": "sk-lf-...",
"endpoint": "https://cloud.langfuse.com", # or your self-hosted URL
}
In YAML, read keys from the environment, for example api_key: ${LANGSMITH_API_KEY}.
Each ingest, query, chat turn and evaluation appears as one trace (ragfoundry.query and so on),
with every graph step and LLM call nested inside. tags adds labels to every trace. Traces are
sent in the background, so close the pipeline (or call rag.flush()) before your program exits.
18. Using ragfoundry in an application
Create one pipeline when the application starts and share it; it holds model clients and
connections. Create one ChatSession per conversation.
from contextlib import asynccontextmanager
from fastapi import FastAPI
from pydantic import BaseModel
from ragfoundry import ChatSession, RAGPipeline
rag = RAGPipeline.from_yaml("rag.yaml")
sessions: dict[str, ChatSession] = {} # use Redis or a database across processes
@asynccontextmanager
async def lifespan(app):
yield
rag.close()
app = FastAPI(lifespan=lifespan)
class ChatRequest(BaseModel):
session_id: str
question: str
@app.post("/chat")
async def chat(request: ChatRequest):
session = sessions.setdefault(request.session_id, rag.chat())
result = await session.aask(request.question)
return {"answer": result.answer, "sources": result.sources}
To share conversations between several server processes, store session.to_dicts() in your
database after each turn and rebuild with rag.chat(saved), or call
rag.aquery(question, history=saved) directly.
19. Extending ragfoundry
Bring your own components. Any LangChain object replaces the configured one:
rag = RAGPipeline(
config,
llm=my_chat_model, # any BaseChatModel
embeddings=my_embeddings, # any Embeddings
vector_store=my_vector_store, # any VectorStore
reranker=my_reranker, # any object with rerank(query, documents, top_n)
token_counter=my_count_function, # any str -> int function
)
Add a file type:
from ragfoundry import register_loader
@register_loader(".pptx")
def pptx_loader(path, loading_config):
return MyPptxLoader(path) # any LangChain BaseLoader
Add a chunking strategy:
from ragfoundry import register_chunker
@register_chunker("by_heading")
def heading_chunker(chunking_config, resources):
return MyHeadingSplitter(chunk_size=chunking_config.chunk_size) # any TextSplitter
Then set "chunking": {"strategy": "by_heading"}. resources.get_embeddings() and
resources.get_llm() give your splitter the pipeline's models.
20. Examples
The examples/ folder has runnable scripts using the sample documents in
examples/sample_docs/:
| File | Shows |
|---|---|
01_quickstart.py |
Smallest complete pipeline, fully local with Ollama |
02_groq_pdf_chat.py |
Chat with a PDF in the terminal using Groq, with streaming and follow-ups |
03_advanced_retrieval.py |
Hybrid search, reranking, multi-query, corrective loop, context budget, metadata filters |
04_evaluation.py |
Retrieval and generation metrics from a file and from code |
05_fastapi_chat.py |
A chat API with one conversation per session |
06_yaml_config.py |
Loading rag.yaml, chat, and removing a source |
rag.yaml |
Every configuration section, commented |
eval.jsonl |
A labelled test set for the sample documents |
colab_quickstart.ipynb |
The full tour in Google Colab |
Run them from the repository root, for example python examples/02_groq_pdf_chat.py report.pdf.
Recipe: chat with a PDF in one Colab cell
%pip install -q "ragfoundry[huggingface,chroma,groq,pdf]"
from google.colab import userdata
from ragfoundry import RAGConfig, RAGPipeline
rag = RAGPipeline(RAGConfig.model_validate({
"embeddings": {"provider": "huggingface", "model": "sentence-transformers/all-MiniLM-L6-v2"},
"vector_store": {"provider": "chroma", "collection_name": "my_pdf"},
"llm": {"provider": "groq", "model": "llama-3.3-70b-versatile",
"api_key": userdata.get("GROQ_API_KEY"), "temperature": 0},
"retrieval": {"top_k": 4, "hybrid": {"enabled": True}},
"context_window": {"max_context_tokens": 3000},
}))
rag.ingest("/content/your_file.pdf")
chat = rag.chat()
while question := input("You: ").strip():
print("Assistant:", chat.ask(question).answer)
Recipe: answer only from one document
from pathlib import Path
policy = str(Path("docs/policy.pdf").resolve())
rag.query("What is the notice period?", where={"source": policy})
Recipe: show citations with page numbers
result = rag.query("What is the notice period?")
print(result.answer)
for number, doc in enumerate(result.documents, start=1):
print(f"[{number}] {Path(doc.metadata['source']).name}, page {doc.metadata.get('page', '-')}")
Recipe: compare two configurations
base = {"embeddings": {...}, "vector_store": {"provider": "chroma", "collection_name": "kb"},
"llm": {...}}
for name, retrieval in {"vector": {}, "hybrid": {"hybrid": {"enabled": True}}}.items():
with RAGPipeline(RAGConfig.model_validate({**base, "retrieval": retrieval})) as rag:
rag.ingest("./docs")
report = rag.evaluate("eval.jsonl", mode="retrieval")
print(name, round(report.summary["recall@5"]["mean"], 3))
21. Configuration reference
All settings with their defaults.
persist_dir: .ragfoundry # local state: chunk record, and Chroma/FAISS/Qdrant/Milvus Lite data
batch_size: 64 # chunks written to the vector store per batch
loading:
on_error: raise # raise | skip
encoding: utf-8
glob: "**/*" # which files to take from a folder
web:
max_depth: 0 # 0 fetches only the given URL
max_pages: 50
same_domain: true
respect_robots: true
delay_seconds: 0.5
timeout_seconds: 30
max_bytes: 10000000
user_agent: ragfoundry/0.2
chunking:
strategy: recursive # fixed | recursive | semantic | llm | a registered name
chunk_size: 1000
chunk_overlap: 200
length_unit: characters # characters | tokens
encoding_name: cl100k_base # tokenizer used when length_unit is tokens
separators: null # custom separators for recursive
semantic:
breakpoint_percentile: 95
buffer_size: 1
llm:
window_chars: 8000
embeddings: # required for ingest and search
provider: huggingface # huggingface | openai | gemini
model: sentence-transformers/all-MiniLM-L6-v2
api_key: null # default: the provider's environment variable
base_url: null # OpenAI-compatible endpoints
options: {} # passed to the LangChain embeddings class
vector_store: # required; one provider block
provider: chroma
collection_name: ragfoundry
# chroma: persist_directory, or host and port
# faiss: path
# pinecone: index_name (required), api_key; collection_name is used as the namespace
# qdrant: url and api_key, or path for local mode
# weaviate: url, grpc_port, api_key (cloud)
# milvus: uri and token; omit uri for Milvus Lite
# pgvector: connection (required)
llm: # required for answers
provider: ollama # groq | openai | gemini | claude | ollama
model: llama3.2
api_key: null
base_url: null
temperature: null # sent only when set
max_tokens: null
options: {} # passed to the LangChain chat model class
retrieval:
top_k: 5
fetch_k: 20 # must be >= top_k
hybrid: {enabled: false, rrf_k: 60, vector_weight: 1.0, bm25_weight: 1.0}
rerank: null # {provider: huggingface | cohere, model, api_key}
query_transform: {strategies: [], num_queries: 3} # rewrite | multi_query | hyde
corrective: {enabled: false, max_retries: 1, min_relevant_docs: 1}
context_window:
max_context_tokens: null # token budget for retrieved passages; null = no limit
truncate_last_passage: true
max_history_turns: 5 # 0 disables chat history
max_history_tokens: null
condense_question: true
token_counter: approximate # approximate | tiktoken
encoding_name: cl100k_base
generation:
system_prompt: null # replaces the default prompt
abstain_message: "I don't have enough information in the provided documents to answer."
observability:
provider: none # none | langsmith | langfuse
project: ragfoundry
api_key: null # LangSmith
public_key: null # Langfuse
secret_key: null # Langfuse
endpoint: null
tags: []
evaluation:
llm: null # judge model; defaults to the pipeline llm
k_values: [1, 3, 5]
max_concurrency: 1
22. How a query runs
The query graph contains only the stages you enable:
prepare → [condense] → [transform] → retrieve → [rerank] → [grade ⇄ rewrite] → generate | abstain
- prepare applies the history window.
- condense rewrites a follow-up into a standalone question, when there is chat history.
- transform applies
rewrite,multi_queryandhyde. - retrieve searches the vector store, and BM25 when hybrid is on, merging rankings with reciprocal rank fusion.
- rerank re-scores candidates and keeps
top_k. - grade / rewrite is the corrective loop.
- generate packs passages into the context budget and sends the system prompt, the history window, the passages and the original question to the LLM.
- abstain returns the abstain message without calling the LLM when nothing relevant was found.
Search stages use the standalone question; generation uses the user's own wording.
23. API reference
| Object | Members |
|---|---|
RAGConfig |
model_validate(dict), from_yaml(path) |
RAGPipeline(config, *, llm, embeddings, vector_store, reranker, token_counter) |
from_yaml(path, **components), ingest(sources), sources(), delete_source(source), query(...), aquery(...), stream(...), astream(...), retrieve(...), chat(history=None), evaluate(dataset, *, mode, backend, k_values), flush(), close(); also a context manager |
RAGResult |
question, answer, documents, sources, queries, retries, abstained, standalone_question, context_tokens, to_dict() |
ChatSession |
ask, aask, stream, astream, history, reset(), to_dicts(), len() |
IngestReport |
documents_loaded, chunks_total, chunks_added, chunks_unchanged, chunks_removed, failures, to_dict() |
EvalSample |
question, reference_answer, relevant_sources, reference_contexts, relevant_ids |
EvalReport |
summary, rows, to_dict(), save(path) |
| Functions | load_sources, chunk_documents, load_dataset, pack_documents, window_history, register_loader, register_chunker |
| Errors | RAGFoundryError (base), ConfigurationError, IngestionError, MissingDependencyError |
query, aquery, stream, astream and retrieve all take
(question, *, where=None, top_k=None, history=None).
24. Known limitations
- macOS with FAISS and Hugging Face models: the
faiss-cpuandtorchwheels each bundle an OpenMP runtime, and using both in one process can abort it (OMP: Error #15). ragfoundry warns about this combination. Use Chroma or Qdrant with Hugging Face models on macOS. - Legacy
.docfiles are not supported; convert them to.docx. - Scanned PDFs need OCR first. A PDF with no extractable text is reported as a failure.
- BM25 is held in memory and scores every chunk on each query, which suits collections up to a few hundred thousand chunks.
- Web crawling stays within one site by default and respects
robots.txt. Pages that vanish from a site stay indexed until you remove them withdelete_source. - Chat sessions are kept in memory; persist
to_dicts()yourself to share them across processes. - The approximate token counter can be off by 20% or more for code, non-English text or
unusual tokenizers. Use
tiktokenor your model's tokenizer when budgets are tight. - RAGAS 0.4 imports a module that current
langchain-communityhas removed. ragfoundry provides a placeholder for that module, and only when it is genuinely absent.
25. Troubleshooting
| Problem | Fix |
|---|---|
'X' is required for this feature. Install it with: pip install 'ragfoundry[...]' |
Run the command shown. |
Environment variable 'X' is referenced but not set |
Export the variable, or give a default: ${X:-value}. |
No LLM configured |
Add an llm section, or pass llm=.... Retrieval alone needs no LLM. |
This collection was indexed with embeddings '...' |
You changed the embedding model. Use a new collection_name, or delete persist_dir and the old collection, then ingest again. |
No extractable text in file.pdf |
The PDF is scanned. Run OCR on it first. |
robots.txt disallows fetching ... |
The site forbids crawlers. Set loading.web.respect_robots: false only if you have permission. |
| Model not found or decommissioned | Model names change; pick a current one from your provider's model list. |
| Answers are always the abstain message | Check rag.sources() to confirm documents are indexed. With corrective on, the grader may be too strict for a small model. |
A where filter returns nothing |
Values must match exactly. source is an absolute path; copy it from rag.sources(). |
| Follow-up questions retrieve the wrong documents | Check result.standalone_question. A small LLM may rewrite poorly; try a stronger model, or raise max_history_turns. |
| Answers miss details that are in the documents | max_context_tokens may be too small. Check result.context_tokens and whether the last passage has truncated set. |
OMP: Error #15 on macOS |
See the FAISS limitation above. |
26. Development and releases
git clone https://github.com/rithvikkatpelly/ragfoundry && cd ragfoundry
uv venv && uv pip install -e ".[all,dev]"
pytest # unit tests, no network needed
pytest -m integration # real services; see tests/test_integration.py for setup
ruff check src tests && mypy src
Releasing a new version:
-
Raise
versioninpyproject.tomland__version__insrc/ragfoundry/__init__.py, and add an entry to CHANGELOG.md. PyPI never accepts the same version twice. -
Build and check:
rm -rf dist && uv build uvx twine check dist/*
-
Upload with a PyPI API token scoped to this project, passed through the environment so it isn't saved in your shell history or files:
read -rs UV_PUBLISH_TOKEN && export UV_PUBLISH_TOKEN # paste the pypi-... token uv publish unset UV_PUBLISH_TOKEN
-
Verify in a clean environment:
pip install "ragfoundry==<version>".
License
MIT. See LICENSE.
Metadata
Release files for ragfoundry 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| ragfoundry-0.2.0.tar.gz | 91.9 kB | Without provenance | Details for ragfoundry-0.2.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| ragfoundry-0.2.0-py3-none-any.whl | Python 3 | none | any | Details for ragfoundry-0.2.0-py3-none-any.whl |
Total release size: 162.6 kB
Release files / ragfoundry-0.2.0.tar.gz
| Download URL | ragfoundry-0.2.0.tar.gz |
|---|---|
| Size | 91.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
dcf228e2a61799b6e4b96eab491be73838df4fa7007bcb0a31a8c03777cba27f
|
|
BLAKE2b-256 checksum How to use checksums |
a63aef980f929bdde3f18867f801073058f8c8625b726e822153c9ffdcdaeeb6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.13.6 {"installer":{"name":"uv","version":"0.13.6","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / ragfoundry-0.2.0-py3-none-any.whl
| Download URL | ragfoundry-0.2.0-py3-none-any.whl |
|---|---|
| Size | 70.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
623fb634a0d844cef75d54c369682c3c098eef6929e4edaff254eb13716d91ad
|
|
BLAKE2b-256 checksum How to use checksums |
dc0379ae998274ba26fed74e571d0d095b07eb969e7dba6b8af02001a9fed31c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.13.6 {"installer":{"name":"uv","version":"0.13.6","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Security for ragfoundry 0.2.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.