pydantic-rag-agent
A retrieval-augmented generation (RAG) agent built on Pydantic and managed with uv.
Status: M0–M7 are complete and published as 0.3.0; M8 is in progress (citations download their originals). Ingestion, retrieval, the agent, the API, the CLI, the Streamlit UI and the OpenAI-compatible endpoint all work; the suite runs offline at 92% branch coverage. OPS-1 (instrumentation) is deliberately out of scope.
mainis the state described indocs/overview.md; the ordered plan behind it is indocs/features/anddocs/roadmap.md.
Why uv?
Dependency resolution and virtualenv handling live in one fast tool, and uv.lock
pins the exact graph so your CI and your laptop agree on what "it works" means.
Documentation
Full documentation lives in docs/ — start with the
documentation index or the project overview.
Design rationale is recorded as decision records.
Coding agents should read AGENTS.md first.
Requirements
- uv 0.13 or newer
- Python 3.11+ — uv will fetch and manage the interpreter for you if needed
Install
uv add "pydantic-rag-agent" # core: agent, loaders, CLI, in-memory store
uv add "pydantic-rag-agent[chroma]" # plus the default persistent store
uv add "pydantic-rag-agent[chroma,ui]" # plus the Streamlit UI
uv add "pydantic-rag-agent[chroma,api]" # plus the FastAPI integration
uv add "pydantic-rag-agent[chroma,s3]" # plus originals in a bucket
uv add "pydantic-rag-agent[all]" # everything
The core install has no vector database: it runs with the in-memory store, which is fine for a script that ingests and asks in one go. Anything the agent does not need at import time is an extra, and a missing one tells you which to install.
Published as 0.3.0 — see the changelog for what is in it. Before 1.0 the public API may change in a minor release, with a migration note.
The ways to use it
There are six ways in, and they differ in where the agent lives rather than in what it can do. Pick by asking who owns the process:
| Shape | The agent lives… | Reach for it when |
|---|---|---|
| 1. Library | in your Python process | a script or notebook ingests and asks in one go |
| 2. Mounted router | in your own FastAPI app | you already have an API and want the routes, not a second service |
| 3. Standalone server | in serve |
clients in other languages, or a UI, need an HTTP endpoint |
| 4. Embedded Streamlit panel | in your Streamlit app | you have a Streamlit app and want a chat panel inside it |
| 5. OpenAI-compatible endpoint | in serve or your app |
an existing chat UI should talk to your documents |
| 6. Command line | in each command | you want to index and ask without writing code |
All six share one façade, one configuration tree and one set of citation semantics. Nothing below is a different feature set — only a different owner of the event loop.
1. Library
The agent runs in your process. No server, no port.
from pydantic_rag_agent import RagAgent
agent = RagAgent.from_env() # reads PRAGENT_* / OPENROUTER_API_KEY
agent.ingest_sync("./docs")
turn = agent.ask_sync("How long are backups retained?")
print(turn.text)
for citation in turn.citations:
print(citation.title, citation.position, citation.locator)
agent.close_sync()
from_env() is the shortest path. To configure in code instead of by environment — which is
what a test or a library consumer wants — build the settings tree and pass it:
from pydantic_rag_agent import RagAgent, Settings
settings = Settings(
provider={"api_key": "…"},
vector_store={"backend": "memory"},
storage={
"documents": {"backend": "local", "local_path": "./data/documents"},
"images": {"directory": "./data/images"},
},
)
agent = RagAgent(settings)
Async is the native interface; the *_sync helpers exist so a script need not become async
to ask one question. Both call the same code:
async def answer(agent: RagAgent, question: str) -> str:
async for update in agent.stream(question):
... # tokens, tool calls, completion
return (await agent.ask(question)).text
The constructor also takes the seams directly — vector_store=, embeddings=, model=,
document_store= — which is how this package's own tests run with no network. See
public-api.md.
2. Mounted in FastAPI
The router brings routes; your app keeps its lifespan, its auth and its shutdown.
from contextlib import asynccontextmanager
from fastapi import FastAPI
from pydantic_rag_agent import RagAgent
from pydantic_rag_agent.integrations.fastapi import create_router
agent = RagAgent.from_env()
@asynccontextmanager
async def lifespan(app: FastAPI):
await agent.check_index()
try:
yield
finally:
await agent.aclose()
app = FastAPI(lifespan=lifespan)
app.include_router(create_router(agent), prefix="/knowledge")
3. Standalone server and clients
pydantic-rag-agent serve # 127.0.0.1:8000
pydantic-rag-agent serve --host 0.0.0.0 # reachable from elsewhere — see the warning below
Two clients speak to it. RagClient is HTTP; InProcessClient runs an RagAgent in your
own process behind the same KnowledgeClient protocol, so a Streamlit app or a test can
swap one for the other without changing the UI code:
from pydantic_rag_agent.client import RagClient # HTTP
from pydantic_rag_agent.in_process import InProcessClient # in-process
with RagClient("http://127.0.0.1:8000") as client:
for document in client.documents():
print(document["document_id"], document["source_path"])
for event in client.stream(conversation_id, "How long are backups retained?"):
...
with InProcessClient.from_env() as client: # no server, same interface
...
Both return None from download() for an unknown document, so a stale citation is a
normal answer rather than an exception.
4. Embedded Streamlit panel
Requires the ui extra. The host owns the RagAgent and closes it; the panel owns its own
session_state, namespaced by key, and makes no module-level Streamlit calls — so
importing it cannot disturb your page.
import streamlit as st
from pydantic_rag_agent import RagAgent
from pydantic_rag_agent.ui import rag_chat
agent = RagAgent.from_env()
rag_chat(agent, heading="Ask the handbook")
rag_chat_client(client) renders the same panel over any KnowledgeClient instead of an
agent — the shape to use when the UI should not hold a provider credential. See the
embedding guide.
5. Any OpenAI-speaking chat UI
Point an existing client at the agent instead of building a frontend:
from pydantic_rag_agent.integrations.fastapi import create_openai_router
app.include_router(create_openai_router(agent, model_id="acme-knowledge"), prefix="/openai")
Point the client at https://your-host/openai/v1. Requests are stateless (the client sends
the thread), caller-supplied system messages are ignored, and answers carry a Sources:
footer. serve exposes the same route at /v1, so a chat UI can be pointed at a running
server with no code at all — --openai-model-id chooses the name the client shows. See
ADR 0014.
6. Command line
pydantic-rag-agent configure # writes .env
pydantic-rag-agent ingest ./docs # index, copying each original
pydantic-rag-agent ask "How long are backups retained?"
pydantic-rag-agent chat # a loop that keeps context
pydantic-rag-agent documents # the index, with document IDs
pydantic-rag-agent stats # chunk count and embedding identity
pydantic-rag-agent delete <id> # asks first; -y skips
pydantic-rag-agent dev # API + Streamlit UI together
The answer streams and ends with a table of sources. --json on ask prints machine-readable
output, and --api-url (or PRAGENT_API_URL) makes any command talk to a running server
instead of building an agent locally.
Citations download the original
Every answer carries citations, and each one can fetch the document it names. The same capability is reachable from every shape — this is the part that is easy to miss:
turn = agent.ask_sync("How long are backups retained?")
for citation in turn.citations:
download = agent.download_sync(citation.document_id)
if download is not None: # None means no such document
path = f"./out/{download.filename}"
open(path, "wb").write(download.data)
print(download.media_type) # e.g. text/markdown
| Where | How you get the bytes |
|---|---|
| Library | agent.download_sync(id) → DocumentDownload(filename, media_type, data) |
| HTTP client | client.download(id) → (filename, bytes) or None |
| Server | GET /documents/{id}/download, streamed with its filename and content type |
| Streamlit panel | a download control on each citation |
| CLI | a terminal hyperlink where the terminal supports one, with the locator printed as fallback |
download() returns None for an unknown id rather than raising, because a stale citation
is a normal thing to hold. The filename comes from the index, so you get handbook.md, not
a hash.
On S3 the originals are proxied by default: the route reads the object and streams it, so
a citation link is stable and can be re-fetched. Set
PRAGENT_STORAGE__DOCUMENTS__S3_DOWNLOAD_MODE=presign to redirect to a presigned URL
instead — faster and off your bandwidth, but the link expires. See
ADR 0017.
There is no authentication.
serve --host 0.0.0.0exposes every route, including/documents/{id}/download, to anyone who can reach the port, and adocument_idis a content hash rather than a permission. Run it on localhost, or put auth in front of it. Auth is deferred in the backlog with the trigger "deployment beyond localhost".
First run: let the wizard write your .env — provider, models, where documents live, which
vector store — so setup is configuration rather than reading a reference table:
pydantic-rag-agent configure
Use your own model provider
OpenRouter is the default, not a requirement. Chat is built against any OpenAI-compatible endpoint, so the provider is configuration:
PRAGENT_PROVIDER__BASE_URL=https://api.openai.com/v1
PRAGENT_PROVIDER__MODEL=gpt-4.1-mini
PRAGENT_PROVIDER__API_KEY=sk-…
The model id is bare — no openai: or openrouter: prefix — and the /v1 belongs in the
base URL.
| Endpoint | BASE_URL |
Worth knowing |
|---|---|---|
| OpenAI | https://api.openai.com/v1 |
The example above |
| Ollama | http://localhost:11434/v1 |
Any non-empty key, e.g. ollama; use a model with tool support |
| vLLM | http://localhost:8000/v1 |
Start it with auto tool choice and a tool-call parser |
| LM Studio | http://localhost:1234/v1 |
Any non-empty key |
| Groq | https://api.groq.com/openai/v1 |
Provider key; tool support varies by model |
Three things that apply to every provider:
- Embeddings are a separate node. Chat and embeddings can come from different places;
point
PRAGENT_EMBEDDINGS__BASE_URL/__MODEL/__DIMENSIONSat a provider with an OpenAI-compatible embeddings endpoint if your chat provider has none. SetPRAGENT_EMBEDDINGS__API_KEYexplicitly when it differs — otherwise it falls back to the provider key. - Tool calling is required. The agent retrieves by calling
search_knowledge_base. A model or server that ignorestoolswill answer from general knowledge instead of your documents; a question whose answer is only in the corpus is the quickest test. - Changing the embedding model or width means reindexing. The width is written into the
index metadata and checked at startup, so the guard refuses to mix vectors from different
models.
DIMENSIONSmust also match what the model actually emits — 768 fornomic-embed-text, 1024 is a valid Matryoshka width fortext-embedding-3-small.
Images are captioned by PRAGENT_PROVIDER__CAPTION_MODEL (default: the chat model). If you
ingest images and the chat model has no vision, set a vision-capable caption model.
Every variable, its default and the programmatic equivalent are in
docs/reference/configuration.md.
Getting started from the repository
# Clone and enter the project
git clone git@github.com:hanku4u/pydantic-rag-agent.git
cd pydantic-rag-agent
# Create .venv and install the project plus its dependencies
uv sync
# Provide the one credential: chat, captions, and embeddings all use it
cp .env.example .env
# then edit .env and set OPENROUTER_API_KEY
# Index and ask — the agent runs in this process, no server needed
uv run pydantic-rag-agent ingest ./docs
uv run pydantic-rag-agent ask "How long are backups retained?"
# Optional: the API and web UI together
uv run pydantic-rag-agent dev
The terminal answer streams and ends with a table of the sources it cited. The chat UI
from dev is at http://127.0.0.1:8501. --api-url makes any command talk to a running
server instead of building the agent locally.
Day-to-day commands
| Task | Command |
|---|---|
| Install or refresh dependencies | uv sync |
| Add a runtime dependency | uv add <package> |
| Add a dev-only dependency | uv add --dev <package> |
| Remove a dependency | uv remove <package> |
| Run a command inside the venv | uv run <command> |
| Run a script without installing it | uv run path/to/script.py |
Project layout
pydantic-rag-agent/
├── pyproject.toml # project metadata, dependencies, extras, CLI entry point
├── uv.lock # exact resolved versions — commit this
├── .python-version # interpreter uv should use
├── AGENTS.md # orientation for coding agents
├── docs/ # design, backlog, decisions — start at docs/README.md
├── scripts/ # release, docs link check, retrieval evaluation
├── src/
│ └── pydantic_rag_agent/
│ ├── __init__.py # the public surface, re-exported; no import-time side effects
│ ├── agent.py # RagAgent — the façade every interface goes through
│ ├── client.py # KnowledgeClient protocol + RagClient (HTTP)
│ ├── in_process.py # InProcessClient — the same protocol, no server
│ ├── cli.py # the command line (`typer` + `rich`)
│ ├── config.py # the PRAGENT_* settings tree
│ ├── extras.py # MissingExtraError and the extra-availability check
│ ├── domain/ # models and ports; no I/O
│ ├── application/ # ingestion, embedding, retrieval
│ ├── adapters/ # chroma, document stores, loaders, captioning
│ ├── integrations/ # the FastAPI app and routers (the `api` extra)
│ └── ui/ # the Streamlit panel and app (the `ui` extra)
└── README.md
The src/ layout keeps the package importable from an installed build rather
than by accident from the working directory, which is what makes
uv run pytest behave the same as a real install.
Configuration
Settings are a tree of six nodes — provider, embeddings, vector store, storage,
ingestion, agent — read from PRAGENT_* environment variables via pydantic-settings.
Nesting is addressed with a double underscore: PRAGENT_PROVIDER__MODEL,
PRAGENT_AGENT__TOP_K. Each node can also be constructed in code, which is how the
library is used.
Credentials keep their conventional names (OPENROUTER_API_KEY) and are never read from
a committed file. Every variable, its default, and the mapping from the pre-0.1.0 flat
names are in docs/reference/configuration.md.
The .gitignore ignores .env and any .env.* file except .env.example.
Contributing
uv syncto get a working environment.- Pick a feature ID from the backlog and reference it in your commit subject.
- Add tests under
tests/next to the module they cover. uv run pytestbefore opening a pull request.
License
MIT. Use it, fork it, ship it — just keep the copyright notice.
The dependency stack is deliberately permissive end to end; the one AGPL package that
previously blocked this was replaced. See
docs/reference/dependencies.md for the details, and
ADR 0007 for the reasoning.
Metadata
Release files for pydantic-rag-agent 0.3.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 | |
|---|---|---|---|---|
| pydantic_rag_agent-0.3.0.tar.gz | 109.2 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pydantic_rag_agent-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 236.3 kB
Release files / pydantic_rag_agent-0.3.0.tar.gz
| Download URL | pydantic_rag_agent-0.3.0.tar.gz |
|---|---|
| Size | 109.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
3a54d13d3c1dbc7eb26822024d71b44db09be4c5ff0e926ae47f4a8ff7cb079d
|
|
BLAKE2b-256 checksum How to use checksums |
48adda3a1e061a935f6cacc5ea6b9503b002298935c64f29d61990955db26e9b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.13.6 {"installer":{"name":"uv","version":"0.13.6","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / pydantic_rag_agent-0.3.0-py3-none-any.whl
| Download URL | pydantic_rag_agent-0.3.0-py3-none-any.whl |
|---|---|
| Size | 127.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
e463ac7ac206c29beb7d3469c765fdd253c34e0eddfdd1cd2b9bd877e802c683
|
|
BLAKE2b-256 checksum How to use checksums |
46c1af86ae99471cfb98d8813f36a5c49fccdab689d55310d3abef209b6a7131
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.13.6 {"installer":{"name":"uv","version":"0.13.6","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Security for pydantic-rag-agent 0.3.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.