Quivr (quivr-core) is an opinionated Retrieval-Augmented Generation (RAG) library for Python. You give it your files, it builds a searchable "brain" from them, and you ask questions in natural language — Quivr retrieves the relevant passages and has a large language model (LLM) answer using only those passages, with citations.
The project's tagline is "We take care of the RAG so you can focus on your product." You install one package, point it at your documents, and you have a working question-answering system in five lines of code. When you outgrow the defaults, every stage — the LLM, the embedding model, the vector store, the document parsers, and the entire retrieval workflow — is swappable through a single YAML configuration file.
Note
This guide documents
quivr-core, the open-source Python package published to PyPI. It is the engine behind the hosted product at quivr.com, but everything here runs locally in your own Python process — no Quivr account or server is required.
1. Quick Start
1.1 Install
pip install quivr-core
Important
Quivr requires Python 3.11 or newer (the package metadata sets
requires-python = ">= 3.11"). The project README mentions 3.10, but the packagedquivr-coreis built against 3.11+. Use 3.11 or later to avoid install-time resolution errors.
1.2 Set your API key
By default Quivr uses OpenAI for both the chat model (gpt-4o) and embeddings (text-embedding). Export your key before running any code:
macOS / Linux
export OPENAI_API_KEY="sk-..."
Windows (PowerShell)
$env:OPENAI_API_KEY = "sk-..."
In Python
import os
os.environ["OPENAI_API_KEY"] = "sk-..."
1.3 Ask your first question
import tempfile
from quivr_core import Brain
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt") as temp_file:
temp_file.write("Gold is a liquid of blue-like colour.")
temp_file.flush()
brain = Brain.from_files(
name="test_brain",
file_paths=[temp_file.name],
)
answer = brain.ask("what is gold? answer in french")
print("answer:", answer.answer)
Expected output (the model answers strictly from your file, so it reports the made-up fact):
answer: L'or est un liquide de couleur bleutée.
Tip
Quivr is grounded: its default prompt instructs the model to use only the provided context and never its own prior knowledge. That is why the answer above repeats the (false) statement from your file instead of correcting it. This is the intended behaviour for trustworthy document Q&A.
That is the whole loop: create a brain from files → ask questions. The rest of this guide explains how to feed it real documents, stream answers, customize the LLM and retrieval, and save your brain for reuse.
2. Core Concepts
You only need four ideas to use Quivr effectively.
Brain
: The central object. A Brain bundles your documents (in storage), their searchable vector index (in a vector store), an LLM for answering, and an embedder for indexing. You create one with a factory method and then call .ask() on it.
Ingestion
: The one-time process of turning your raw files into searchable knowledge: read each file → parse it to text with the right processor → split the text into overlapping chunks → embed each chunk into a vector → store the vectors. Brain.from_files() does all of this for you.
Retrieval : What happens on every question: your question is embedded, the most similar chunks are fetched from the vector store, optionally re-ranked, and packed into the LLM's prompt as context.
Workflow : The ordered sequence of steps that runs for each question (filter history → rewrite question → retrieve → generate answer). The default workflow is good enough for most uses, but you can redefine it entirely in a YAML file to add web search, tools, or dynamic retrieval.
Ingestion (once) Retrieval (per question)
files ─► parse ─► chunk ─► embed ─► vector ─► question ─► retrieve chunks ─► LLM ─► answer
store
3. Installation in Depth
3.1 The base install
pip install quivr-core
This pulls in the default stack: LangChain + LangGraph (orchestration), langchain-openai (default LLM and embeddings), faiss-cpu (default vector store), tiktoken and transformers (tokenizers), and clients for Anthropic, Mistral, Gemini, Groq, and Cohere so you can switch providers without extra installs.
3.2 Optional parser dependencies
Quivr can read many file types, but some parsers need extra libraries. If you try to ingest a file type whose parser isn't installed, Quivr logs a warning and falls back to the next available parser; if none works it raises an error telling you exactly what to install. Install the heavy document parsers with the unstructured library:
pip install "unstructured[all-docs]"
Note
Plain
.txtfiles always work with the base install (via the built-inSimpleTxtProcessor). PDFs work out of the box only if you run a Tika server (see §6.3) or install MegaParse /unstructured.
3.3 Verify the install
from quivr_core import Brain
print(Brain)
<class 'quivr_core.brain.brain.Brain'>
4. Everyday Usage
4.1 Build a brain from your files
Brain.from_files() is the synchronous, do-everything constructor. Point it at a list of paths:
from quivr_core import Brain
brain = Brain.from_files(
name="my smart brain",
file_paths=["./my_first_doc.pdf", "./my_second_doc.txt"],
)
| Parameter | Type | Default | Purpose |
|---|---|---|---|
name |
str |
(required) | A human-friendly label for the brain. |
file_paths |
list[str | Path] |
(required) | The documents to ingest. |
llm |
LLMEndpoint | None |
None → OpenAI gpt-4o |
The chat model used to answer. |
embedder |
Embeddings | None |
None → OpenAIEmbeddings |
The model used to index chunks. |
vector_db |
VectorStore | None |
None → in-memory FAISS |
Where vectors are stored. |
storage |
StorageBase |
TransparentStorage() |
Where the original files are kept. |
skip_file_error |
bool |
False |
If True, skip files that fail to parse instead of raising. |
processor_kwargs |
dict | None |
None |
Options passed to the parsers (e.g. chunk size). |
Warning
Creating a brain with an empty
file_pathslist and novector_dbraisesValueError: can't initialize brain without documents. Either provide at least one file or pass your ownvector_db.
4.2 Ask a question
brain.ask() returns a response object whose .answer is the text and whose .metadata carries citations, follow-up questions, and sources.
response = brain.ask("What are the key findings of the report?")
print(response.answer)
print("Sources:", response.metadata.sources)
print("Suggested follow-ups:", response.metadata.followup_questions)
The report's key findings are ...
Sources: [Document(...), Document(...)]
Suggested follow-ups: ['How was the data collected?', 'What is the sample size?']
4.3 See what's inside a brain
brain.print_info()
╭───────────────── Brain Info ─────────────────╮
│ 📊 Brain Information │
│ ├── 🆔 ID: 7f3c... │
│ ├── 🧠 Brain Name: my smart brain │
│ ├── 📁 Files │
│ │ ├── Storage Type: transparent_storage │
│ │ └── Number of Files: 2 │
│ ├── 💬 Chats │
│ │ ├── Number of Chats: 1 │
│ │ └── Current Chat History Length: 0 │
│ └── 🤖 LLM │
│ ├── Model: gpt-4o │
│ ├── Temperature: 0.3 │
│ ├── Max Tokens: 4096 │
│ └── Supports Function Calling: True │
╰───────────────────────────────────────────────╯
4.4 Search without asking
If you just want the relevant passages (no LLM answer), use asearch. It returns scored chunks, nearest first:
results = await brain.asearch("Why does everybody love Quivr?", n_results=5)
for r in results:
print(r.distance, r.chunk.page_content[:80])
| Parameter | Default | Meaning |
|---|---|---|
query |
(required) | The text (or a Document) to search for. |
n_results |
5 |
How many chunks to return. |
filter |
None |
A metadata filter (dict or callable) to restrict the search. |
fetch_n_neighbors |
20 |
How many candidates to fetch before filtering. |
Each result is a SearchResult with .chunk (a LangChain Document) and .distance (lower = more similar).
5. Streaming and Chat
5.1 Stream the answer token by token
For responsive UIs, stream chunks as the model produces them. ask_streaming is an async generator:
from uuid import uuid4
async for chunk in brain.ask_streaming("Summarize chapter 3.", run_id=uuid4()):
print(chunk.answer, end="", flush=True)
Each yielded ParsedRAGChunkResponse has an incremental .answer string and a .metadata object. The final chunk has .last_chunk == True and carries the complete metadata (citations, sources, the Langfuse trace URL if observability is enabled).
Tip
run_idis auuid4()you generate per question. It ties together logs and observability traces for that one answer — handy when debugging which retrieval led to which response.
5.2 Conversations remember context
A brain keeps a default chat history. Each call to ask / aask / ask_streaming appends the question and answer, so follow-up questions understand earlier turns automatically:
brain.ask("Who is the author?") # answered from the docs
brain.ask("What else did they write?") # "they" is resolved from history
Quivr filters history to fit the model's context window and, in the default workflow, rewrites each follow-up into a standalone question before retrieval.
5.3 Async vs. sync
Quivr is async-first. Every operation has an a-prefixed coroutine and a synchronous wrapper:
| Synchronous | Asynchronous | Notes |
|---|---|---|
Brain.from_files(...) |
await Brain.afrom_files(...) |
Build a brain. |
brain.ask(...) |
await brain.aask(...) |
One-shot answer. |
brain.ask(...) |
async for ... in brain.ask_streaming(...) |
Streamed answer. |
| — | await brain.asearch(...) |
Search is async-only. |
Caution
The synchronous wrappers call
asyncio.get_event_loop().run_until_complete(...). Inside an environment that already has a running event loop — a Jupyter notebook, anasync def, or an ASGI web handler — this raisesRuntimeError: There is no current event looporThis event loop is already running(community issue #3650). In those contexts always use the async methods (afrom_files,aask,ask_streaming).
6. Working With Documents
6.1 Supported file types
Quivr maps file extensions to processors. Out of the box it knows how to handle:
| Category | Extensions |
|---|---|
| Text & markup | .txt, .md, .mdx, .markdown, .html, .bib |
| Office | .pdf, .docx, .doc, .pptx, .xls, .xlsx, .csv, .odt, .epub |
| Code & notebooks | .py, .ipynb |
Note
Most non-
.txtformats are read through theunstructuredlibrary or MegaParse, so install those extras (§3.2) to enable them. If a file's extension isn't recognized, Quivr warns "extension isn't recognized — make sure you have registered a parser" and skips or errors depending onskip_file_error.
6.2 Control how documents are chunked
Chunking decides how your text is sliced before embedding. Smaller chunks give more precise retrieval; larger chunks give more context per hit. Configure it with a YAML ingestion file:
# basic_ingestion_workflow.yaml
parser_config:
megaparse_config:
strategy: "auto" # auto | fast | hi_res
pdf_parser: "unstructured"
splitter_config:
chunk_size: 400 # in tokens
chunk_overlap: 100 # in tokens
from quivr_core import Brain
from quivr_core.config import IngestionConfig
ingestion_config = IngestionConfig.from_yaml("./basic_ingestion_workflow.yaml")
processor_kwargs = {
"megaparse_config": ingestion_config.parser_config.megaparse_config,
"splitter_config": ingestion_config.parser_config.splitter_config,
}
brain = Brain.from_files(
name="my smart brain",
file_paths=["./my_first_doc.pdf", "./my_second_doc.txt"],
processor_kwargs=processor_kwargs,
)
The default split is 400 tokens per chunk with 100 tokens of overlap.
6.3 PDFs via a Tika server
The default PDF processor talks to an Apache Tika server. The quickest way to get one:
docker run -d -p 9998:9998 apache/tika
Unable to find image 'apache/tika:latest' locally
latest: Pulling from apache/tika
...
Status: Downloaded newer image for apache/tika:latest
Quivr looks for the server at http://localhost:9998/tika by default; override it with the TIKA_SERVER_URL environment variable.
6.4 Build from LangChain documents
If you already have langchain_core.documents.Document objects (e.g. from your own loader), skip file parsing entirely:
from langchain_core.documents import Document
from quivr_core import Brain
documents = [Document(page_content="Hello, world!")]
brain = await Brain.afrom_langchain_documents(
name="My Brain",
langchain_documents=documents,
)
7. Customizing the LLM
7.1 Switch providers
Quivr supports OpenAI, Azure OpenAI, Anthropic, Mistral, Google Gemini, and Groq, plus any OpenAI-compatible local server (such as Ollama). Build an LLMEndpoint from an LLMEndpointConfig and hand it to the brain:
from quivr_core import Brain
from quivr_core.llm import LLMEndpoint
from quivr_core.rag.entities.config import LLMEndpointConfig
llm = LLMEndpoint.from_config(
LLMEndpointConfig(model="claude-3-5-sonnet", supplier="anthropic")
)
brain = Brain.from_files(name="brain", file_paths=["doc.pdf"], llm=llm)
Set the matching API key in the environment. Quivr derives the variable name from the supplier:
| Supplier | Environment variable |
|---|---|
openai |
OPENAI_API_KEY |
anthropic |
ANTHROPIC_API_KEY |
mistral |
MISTRAL_API_KEY |
gemini |
GEMINI_API_KEY |
groq |
GROQ_API_KEY |
azure |
AZURE_API_KEY |
Tip
You usually don't need to set
supplier— Quivr infers it from the model name (e.g. anyclaude-*model → Anthropic, anygpt-*/o1/o3→ OpenAI). Setting it explicitly is only required for ambiguous or custom model names.
7.2 Use a local model (Ollama)
Point Quivr at any OpenAI-compatible endpoint with llm_base_url:
from quivr_core.rag.entities.config import LLMEndpointConfig
config = LLMEndpointConfig(
model="llama2",
llm_base_url="http://localhost:8441",
llm_api_key="not-needed",
)
Note
Some models —
llama2,ollama3, and the test model — are treated as not supporting function calling. Quivr automatically falls back to a plain (non-tool) answering path for them, so citations may be unavailable with those models.
7.3 Key LLM options
| Option | Default | Meaning |
|---|---|---|
model |
"gpt-4o" |
Model name. |
supplier |
"openai" |
Provider (usually auto-detected). |
temperature |
0.3 |
Creativity; lower = more deterministic. |
max_context_tokens |
20000 |
Token budget for the prompt + retrieved context. |
max_output_tokens |
4096 |
Maximum length of the generated answer. |
streaming |
True |
Whether the model streams tokens. |
llm_base_url |
None |
Custom endpoint (Ollama, proxies, Azure). |
Important
Quivr automatically caps
max_context_tokensandmax_output_tokensto the real limits of the chosen model. If you ask for more than the model allows, Quivr logs a warning and lowers the value. If you set them below 4096, it raisesValueError: max_context_tokens is too low— the minimum for both is 4096.
8. Customizing Retrieval With Workflows
The real power of Quivr is the workflow YAML: a description of the steps each question goes through, expressed as a graph of named nodes and edges. Load it once and pass it to ask.
8.1 The standard RAG workflow
# basic_rag_workflow.yaml
workflow_config:
name: "standard RAG"
nodes:
- name: "START"
edges: ["filter_history"]
- name: "filter_history"
edges: ["rewrite"]
- name: "rewrite"
edges: ["retrieve"]
- name: "retrieve"
edges: ["generate_rag"]
- name: "generate_rag" # last node — its output is streamed to the user
edges: ["END"]
tools:
- name: "cited_answer"
max_history: 10 # conversation turns kept in context
llm_config:
max_output_tokens: 4000
temperature: 0.7
from quivr_core import Brain
from quivr_core.rag.entities.config import RetrievalConfig
brain = Brain.from_files(name="my brain", file_paths=["./doc.pdf"])
retrieval_config = RetrievalConfig.from_yaml("./basic_rag_workflow.yaml")
answer = brain.ask("What is the conclusion?", retrieval_config=retrieval_config)
print(answer.answer)
Caution
Use
max_output_tokens(and/ormax_context_tokens) in thellm_configblock. Some examples in the project's own README showmax_input_tokens— that key does not exist and Quivr will reject it with a Pydantic validation error because the config models forbid unknown fields. When in doubt, copy the YAML from thecore/example_workflows/folder in the repository, which uses the correct keys.
8.2 What the standard nodes do
| Node | What it does |
|---|---|
filter_history |
Trims chat history to fit the context window. |
rewrite |
Rephrases your (possibly context-dependent) question into a standalone query. |
retrieve |
Fetches the most relevant chunks from the vector store. |
dynamic_retrieve |
Like retrieve, but grows the number of chunks until enough relevant ones are found. |
generate_rag |
Builds the final prompt and produces the answer (the node you stream from). |
8.3 Tune retrieval breadth and history
These top-level keys live in the same YAML file:
| Key | Default | Meaning |
|---|---|---|
k |
40 |
How many chunks the retriever pulls before re-ranking. |
max_history |
10 |
Conversation turns kept as context. |
max_files |
20 |
Maximum number of source files listed in the prompt. |
9. Advanced Features
9.1 Re-rank retrieved chunks for accuracy
A re-ranker re-scores retrieved chunks so the most relevant ones reach the LLM. Quivr supports Cohere and Jina:
reranker_config:
supplier: "cohere" # or "jina"
model: "rerank-multilingual-v3.0"
top_n: 5 # chunks kept after re-ranking
relevance_score_threshold: 0.01 # drop chunks below this score (0–1)
Set COHERE_API_KEY (or JINA_API_KEY) in the environment. If the supplier is set but the key is missing, Quivr raises a clear ValueError naming the variable to set.
Tip
A
relevance_score_thresholdof0.01works well with both Cohere and Jina. Combined with thedynamic_retrievenode, this lets Quivr automatically decide how many chunks are actually relevant instead of always sending a fixed number.
9.2 Add web search
Give the agent a web-search tool (powered by Tavily) so it can answer questions your documents don't cover:
workflow_config:
name: "RAG with web search"
available_tools:
- "web search"
nodes:
- name: "START"
conditional_edge:
routing_function: "routing_split"
conditions: ["edit_system_prompt", "filter_history"]
- name: "edit_system_prompt"
edges: ["filter_history"]
- name: "filter_history"
edges: ["dynamic_retrieve"]
- name: "dynamic_retrieve"
conditional_edge:
routing_function: "tool_routing"
conditions: ["run_tool", "generate_rag"]
- name: "run_tool"
edges: ["generate_rag"]
- name: "generate_rag"
edges: ["END"]
tools:
- name: "cited_answer"
Set TAVILY_API_KEY in your environment. With this workflow the agent detects when the documents are insufficient and searches the web automatically.
9.3 Save and reload a brain
Building a brain re-parses and re-embeds every file, which costs time and API calls. Save the finished brain (vectors + config) and reload it instantly:
# Save (async)
await brain.save("~/.local/quivr")
# Reload later (sync) — no re-ingestion, no embedding calls
from quivr_core import Brain
brain = Brain.load("~/.local/quivr/brain_<id>")
brain.print_info()
save writes a config.json plus the FAISS index. load reconstructs the brain from them.
Caution
Loading a saved brain deserializes the FAISS index with Python pickle (
allow_dangerous_deserialization=True). Only load brains you created yourself or fully trust — a malicious saved brain could execute arbitrary code. Never load a brain downloaded from an untrusted source.
Note
Saving currently supports the FAISS vector store and OpenAI embedder only. Other backends raise "can't serialize ... for now". Storage backends supported for save/load are
TransparentStorageandLocalStorage.
9.4 Keep files on disk with LocalStorage
The default TransparentStorage holds file references in memory. To copy files into a managed directory, use LocalStorage:
from quivr_core import Brain
from quivr_core.storage.local_storage import LocalStorage
brain = Brain.from_files(
name="brain",
file_paths=["doc.pdf"],
storage=LocalStorage(),
)
By default LocalStorage writes to ~/.cache/quivr/files (override with the QUIVR_LOCAL_STORAGE environment variable) and copies files; pass copy_flag=False to symlink instead.
10. Example Applications
The repository ships runnable examples under examples/. They are the fastest way to see Quivr in a real app.
| Example | What it shows | Stack |
|---|---|---|
simple_question/ |
Minimal script: build a brain, ask, stream. | Plain Python |
chatbot/ |
Interactive chat UI over your files. | Chainlit |
chatbot_voice/ |
Voice-driven chat (speech in, speech out). | Chainlit + audio |
quivr-whisper/ |
Web app: ask by voice, hear the answer. | Flask + OpenAI Whisper/TTS |
Run the Chainlit chatbot:
cd examples/chatbot
rye sync
rye run chainlit run main.py
Your app is available at http://localhost:8000
Tip
Each example folder has its own
basic_rag_workflow.yaml. Edit it to experiment with different retrieval strategies without touching any Python code.
11. Troubleshooting
Error: There is no current event loop in thread 'MainThread'
Cause: You called a synchronous method (Brain.from_files, brain.ask) from inside an environment that already runs an event loop — a Jupyter notebook, an async def, or a web framework handler. (Community issue #3650.)
Solution: Use the async variants and await them:
brain = await Brain.afrom_files(name="b", file_paths=["doc.txt"])
answer = await brain.aask("question", run_id=uuid4())
Prevention: In any async or notebook context, default to afrom_files / aask / ask_streaming.
Error: can't initialize brain without documents
Cause: You called from_files with an empty file_paths list and did not supply your own vector_db.
Solution: Provide at least one readable file, or pass a pre-built vector_db.
Prevention: Validate that your file list is non-empty before constructing the brain.
Error: max_context_tokens is too low / max_output_tokens is too low
Cause: You set a token limit below the minimum of 4096 in llm_config.
Solution: Set both max_context_tokens and max_output_tokens to 4096 or higher.
Prevention: Leave them at their defaults (20000 / 4096) unless you have a specific reason to change them.
Error: Pydantic validation error on an unknown YAML key (e.g. max_input_tokens)
Cause: The config models reject unknown fields. max_input_tokens is not a valid key.
Solution: Use max_output_tokens and/or max_context_tokens in the llm_config block.
Prevention: Base your YAML on the files in core/example_workflows/, which use the correct keys.
Error: The API key for supplier '<x>' is not set
Cause: The environment variable for your LLM or re-ranker provider is missing.
Solution: Export the matching key — OPENAI_API_KEY, ANTHROPIC_API_KEY, COHERE_API_KEY, TAVILY_API_KEY, etc.
Prevention: Keep a .env file and load it (e.g. with python-dotenv) at startup.
Error: can't send parse request to tika server (PDFs)
Cause: No Apache Tika server is reachable for PDF parsing.
Solution: Start one with docker run -d -p 9998:9998 apache/tika, or set TIKA_SERVER_URL to your server. Alternatively install MegaParse / unstructured to use a different PDF path.
Prevention: Ensure your PDF parsing backend is running before ingesting PDFs.
Error: file ... already uploaded (FileExistsError)
Cause: With LocalStorage, you tried to ingest a file whose content (SHA-1) was already added.
Solution: Remove the duplicate, or pass exists_ok=True when uploading.
Prevention: De-duplicate your file list before ingestion.
Answers are empty or "I don't have the answer"
Cause: The retrieved context didn't contain the answer — Quivr is grounded and refuses to answer from outside the documents.
Solution: Ingest more relevant documents, increase k / top_n, lower relevance_score_threshold, or add the web-search tool (§9.2).
Prevention: Verify your documents actually cover the questions you expect, and tune retrieval breadth.
12. FAQ
Q: Do I need a Quivr account or the quivr.com service?
No. quivr-core runs entirely in your Python process. The hosted service is separate.
Q: Which LLM providers are supported?
OpenAI, Azure OpenAI, Anthropic, Mistral, Google Gemini, and Groq out of the box, plus any OpenAI-compatible endpoint (e.g. Ollama) via llm_base_url.
Q: What is the default model and vector store?
gpt-4o for answering, OpenAIEmbeddings for indexing, and an in-memory FAISS index for storage.
Q: Can I use my own vector store (Postgres/pgvector, etc.)?
Yes — pass any LangChain VectorStore as vector_db. Note that save()/load() currently only persist FAISS.
Q: How do I make answers cite their sources?
Keep the cited_answer tool in your workflow's final node and use a model that supports function calling (most OpenAI/Anthropic models do). Citations appear in response.metadata.citations and .sources.
Q: How do I get follow-up question suggestions?
They're produced automatically by the cited_answer tool and returned in response.metadata.followup_questions (up to three).
Q: Can Quivr search the internet?
Yes, via the Tavily web-search tool in a custom workflow (§9.2). Set TAVILY_API_KEY.
Q: How do I avoid re-embedding my files every run?
Call await brain.save(path) once, then Brain.load(path) afterwards. Reloading skips ingestion and embedding entirely.
Q: Does Quivr keep conversation context? Yes. Each brain has a chat history; follow-up questions are interpreted in light of earlier turns and rewritten into standalone queries before retrieval.
Q: What languages are supported? Document language is auto-detected (≈170 languages) and the default prompt answers in the user's language unless told otherwise.
Q: Is the project still maintained?
Activity has slowed (see community discussion #3681). quivr-core remains usable and is the supported, documented package. Pin a version in production.