RAG System

July 30, 2026 · View on GitHub

Python FastAPI Docker License Ruff Mypy Qdrant LangGraph

Table of Contents

Overview

A production-grade RAG (Retrieval-Augmented Generation) system that ingests documents, indexes them for hybrid search, and exposes an AI agent with a chat interface. The agent answers user questions by retrieving relevant document chunks and citing its sources.

It uses BGE-M3 for dense and sparse embeddings, Qdrant for vector storage with Reciprocal Rank Fusion (RRF) hybrid search, bge-reranker-v2-m3 for cross-encoder rescoring, and an OpenAI-compatible LLM (DeepSeek by default) behind a LangGraph agent. The agent connects to the RAG backend through the Model Context Protocol (MCP), giving it access to search, document listing, and markdown retrieval tools.

This project demonstrates intermediate-level patterns: content-addressable document storage, async background ingestion, GPU/CPU profile switching, in-process test infrastructure, and transport-agnostic service design.

Architecture

graph TB
    frontend["Frontend<br/>React + CopilotKit<br/>Port 8012"]
    agent["Agent<br/>LangGraph + LLM<br/>Port 8011"]
    ragapi["RAG API<br/>FastAPI<br/>Port 8003"]
    embedder["Embedder<br/>BGE-M3 via FlagEmbedding<br/>Port 8001"]
    reranker["Reranker<br/>bge-reranker-v2-m3 via TEI<br/>Port 8002"]
    qdrant["Qdrant<br/>Vector Database<br/>Port 6333"]
    minio["MinIO<br/>Object Storage<br/>Port 9000"]

    frontend -->|"AG-UI"| agent
    agent -->|"MCP over HTTP"| ragapi
    ragapi -->|"HTTP"| embedder
    ragapi -->|"HTTP"| reranker
    ragapi -->|"REST"| qdrant
    ragapi -->|"S3 API"| minio
ServicePortStackRole
Frontend8012React 19, Vite 6, @copilotkit/react-core, @copilotkit/react-ui, AG-UI protocolChat interface with multi-thread support
Agent8011LangGraph, ChatOpenAI, langchain-mcp-adaptersAI agent that uses MCP tools to answer questions
RAG API8003FastAPI, pymupdf4llm, LlamaIndex, FastMCPDocument ingestion, hybrid search, MCP server
Embedder8001BGE-M3, FlagEmbedding, FastAPIDense (1024-dim) + sparse vector embeddings
Reranker8002bge-reranker-v2-m3, HuggingFace TEICross-encoder relevance rescoring
Qdrant6333Qdrant (REST)Vector database with dense + sparse named vectors
MinIO9000MinIO, boto3S3-compatible document storage

Request flow. A user sends a question through the chat UI. The frontend streams it to the agent via the AG-UI protocol. The agent calls MCP tools on the RAG API — typically search_documents — which embeds the query, runs a hybrid search in Qdrant, optionally reranks results, and returns them with source citations. The agent composes an answer and streams it back to the frontend.

Quick Start

Prerequisites: Docker and Docker Compose. An OpenAI-compatible API key is required for the agent (DeepSeek, OpenAI, vLLM, or any compatible provider).

# 1. Clone and configure
git clone <repo-url> && cd rag
cp .env.example .env
# Edit .env: set OPENAI_API_KEY=sk-...

# 2. Start all services (CPU mode, default)
docker compose up -d --build

The first build takes a few minutes — the embedder downloads BGE-M3 (~2 GB) and the reranker pulls its ONNX model. Subsequent starts are fast. Wait for rag-api to become healthy (check with docker compose ps).

Upload documents

Documents are uploaded as a ZIP archive through the RAG API. Processing is async — the endpoint returns a batch_id immediately, and documents are parsed, chunked, embedded, and indexed in the background.

Supported formats: PDF, EPUB, XPS, MOBI, FB2, images (PNG, JPG, TIFF, BMP, WebP), plain text (TXT, MD, HTML, CSV, JSON, XML).

# 3. Create a ZIP archive with your documents
zip -r documents.zip /path/to/your/docs/

# 4. Upload — returns {"batch_id": "...", "total": N}
curl -X POST http://localhost:8003/documents/upload-batch/zip \
  -F "file=@documents.zip"

# 5. Check batch progress (optional)
curl "http://localhost:8003/documents/upload-batch/zip?batch_id=<batch_id>"

# 6. List indexed documents
curl http://localhost:8003/documents | python -m json.tool

Each document gets a SHA-256-based ID. Uploading the same file twice is safe — the second upload returns "existed": true without re-processing.

Try the agent

# 7. Open the chat interface
# http://localhost:8012

Ask questions about your documents. The agent searches the knowledge base, cites document names and chunk indices in its answers.

Example queries:

  • "What are the key findings in the report?"
  • "Summarize the main arguments from document X"
  • "Compare the approaches described in the uploaded papers"

GPU mode

The embedder can run on GPU for faster embedding. Activate with the GPU overlay:

docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d --build

This rebuilds the embedder with CUDA 12.4 support and reserves one NVIDIA GPU. The CPU build produces a ~2.7 GB image; the GPU build is larger but embeds faster.

Ingestion Pipeline

flowchart LR
    A["Document<br/>Upload"] --> B["SHA-256<br/>Hash"]
    B --> C["Dedup<br/>Check"]
    C --> D["pymupdf4llm<br/>Parse"]
    D --> E["MinIO<br/>Store"]
    E --> F["Sentence<br/>Chunking"]
    F --> G["BGE-M3<br/>Embed"]
    G --> H["Qdrant<br/>Upsert"]
  1. Upload — documents arrive as a ZIP archive or from a server-side directory.
  2. SHA-256 hashing — the file content is hashed to produce a content-based document_id. Identical files produce the same ID regardless of filename.
  3. Deduplication — if the document ID already exists in MinIO, ingestion returns immediately with existed=True.
  4. Parsing — pymupdf4llm converts PDFs, EPUBs, images, and other formats to Markdown. Plain-text formats (TXT, HTML, CSV, JSON, XML) are read directly.
  5. Storage — three objects are written to MinIO per document: the original file ({id}/original.{ext}), the Markdown conversion ({id}/content.md), and metadata ({id}/meta.json).
  6. Sentence-based chunking — LlamaIndex SentenceSplitter splits the Markdown into fixed-size chunks (512 chars, 50-char overlap) with sentence-boundary awareness.
  7. Embedding — each chunk is sent to the embedder, which returns a dense vector (1024 floats) and a sparse vector (lexical token weights in Qdrant format).
  8. Upsert — chunks are written to Qdrant with both vectors and payload fields (document_id, document_name, chunk_index) for provenance tracking.

Ingestion is async: the batch endpoint returns a batch_id immediately and processes documents in the background. The global concurrency semaphore (default 2) prevents CPU oversubscription during parsing.

Search Pipeline

flowchart LR
    A["Query<br/>String"] --> B["BGE-M3<br/>Embed"]
    B --> C["Qdrant<br/>Hybrid RRF"]
    C --> D{"Rerank?"}
    D -- Yes --> E["Cross-encoder<br/>Reranker"]
    D -- No --> F["Results"]
    E --> F
  1. Embedding — the query is sent to the embedder, producing dense and sparse vectors.
  2. Hybrid search — Qdrant runs both vector searches in parallel and fuses results with Reciprocal Rank Fusion (RRF). Dense search captures semantic similarity; sparse search handles lexical matches (keywords, technical terms, identifiers). The fusion balances both signals.
  3. Optional reranking — if rerank=True, the top results are rescored by the bge-reranker-v2-m3 cross-encoder. Reranking considers the full query-document pair, producing more precise relevance scores than embedding similarity alone.
  4. Results — returned with text, score, document provenance (document_id, document_name, chunk_index), and optional rerank_score.

The search endpoint also supports filtering by document_id to scope searches to a specific document.

API Reference

RAG API (:8003)

MethodPathDescription
GET/healthLiveness check
POST/documents/upload-batch/zipUpload a ZIP archive of documents (returns batch_id, async processing)
GET/documents/upload-batch/zip?batch_id=...Poll batch ingestion progress
GET/documentsList all documents with metadata
GET/documents/{document_id}Document metadata with a presigned download URL
GET/documents/{document_id}/markdownFull Markdown content of a document
POST/searchHybrid search with optional rerank
*/mcpMCP server endpoint (see MCP Tools)

Search request example:

{
  "query": "What is the system architecture?",
  "collection": "default",
  "top_k": 10,
  "rerank": true,
  "document_id": null
}

Embedder (:8001)

MethodPathDescription
GET/healthLiveness check
POST/v1/embeddingsOpenAI-compatible embeddings (dense + sparse)

The /v1/embeddings endpoint accepts {"input": ["text"], "model": "BAAI/bge-m3"} and returns standard dense embeddings plus Qdrant-formatted sparse vectors.

Agent (:8011)

MethodPathDescription
POST/agentAG-UI streaming chat endpoint
GET/agent/healthAgent health check

MCP Tools

The RAG API exposes three tools through the Model Context Protocol at /mcp. The agent consumes these tools via streamable_http transport.

ToolParametersDescription
search_documentsquery, top_k (5), rerank (false), document_idHybrid search with optional rerank
list_documentsList all indexed documents with metadata
get_document_markdowndocument_idRetrieve full document markdown (truncated at 30K chars)

These tools run in the same process as the RAG API — they share the same RAGClient and DocumentStorage instances, with no additional network calls beyond what the search pipeline already does.

Project Structure

rag/
├── docker-compose.yml            # Service definitions (CPU, default)
├── docker-compose.gpu.yml        # GPU overlay for embedder
├── docker-compose.test.yml       # Test isolation overlay
├── .env.example                  # Environment variable template

├── services/
│   ├── rag/                      # RAG API (FastAPI)
│   │   ├── rag/
│   │   │   ├── main.py           # App factory, routes, lifespan
│   │   │   ├── services.py       # RAGService (ingestion), RAGClient (search)
│   │   │   ├── parser.py         # pymupdf4llm parsing, SHA-256
│   │   │   ├── chunker.py        # LlamaIndex sentence splitting
│   │   │   ├── storage.py        # MinIO boto3 document storage
│   │   │   ├── qdrant.py         # Qdrant hybrid search + upsert
│   │   │   ├── clients.py        # Async HTTP to embedder + reranker
│   │   │   ├── config.py         # Environment variable config
│   │   │   ├── schemas.py        # Pydantic models
│   │   │   ├── mcp_server.py     # FastMCP tool definitions
│   │   │   ├── broker.py         # Taskiq broker (in-memory or Redis)
│   │   │   ├── tasks.py          # Background ingestion tasks
│   │   │   ├── batch_service.py  # Batch ingestion orchestrator
│   │   │   └── batch.py          # In-memory batch tracker
│   │   ├── tests/                # pytest suite
│   │   └── Dockerfile
│   │
│   ├── agent/                    # LangGraph agent (LLM + MCP)
│   │   ├── agent/
│   │   │   ├── main.py           # FastAPI + AG-UI protocol
│   │   │   ├── graph.py          # LangGraph agent definition
│   │   │   └── config.py         # LLM + MCP config
│   │   └── Dockerfile
│   │
│   ├── embedder/                 # BGE-M3 embedding service
│   │   ├── embedder/
│   │   │   ├── main.py           # FastAPI, /v1/embeddings
│   │   │   ├── model.py          # BGEM3FlagModel wrapper
│   │   │   └── schemas.py        # Pydantic models
│   │   ├── Dockerfile            # CPU variant
│   │   └── Dockerfile.gpu        # GPU variant (CUDA 12.4)
│   │
│   └── frontend/                 # React chat UI
│       ├── src/
│       │   ├── App.tsx           # Main app with chat + suggestions
│       │   ├── MyRuntimeProvider.tsx  # AG-UI multi-thread runtime
│       │   └── components/
│       ├── Dockerfile            # Multi-stage: Node build + nginx serve
│       └── nginx.conf            # Reverse proxy to agent

└── docs/
    └── screenshots/              # UI screenshots for documentation

Development

Prerequisites

  • uv — Python dependency management
  • Node.js 22+ — frontend build
  • Docker and Docker Compose

Setup

# Python services
cd services/rag && uv sync
cd services/agent && uv sync
cd services/embedder && uv sync

# Frontend
cd services/frontend && npm install

Testing

Tests live in services/rag/tests/. The default mode uses in-process backends — no Docker required, ~15 seconds for the full suite.

Service-level tests (no Docker):

cd services/rag && uv run pytest -v

# Parallel execution
cd services/rag && uv run pytest -n auto -v

The _in_process_backends autouse fixture replaces all external dependencies:

  • Qdrant — real Qdrant engine in :memory: mode
  • MinIO/S3moto library intercepts boto3 calls
  • Embedder — deterministic fake returning [0.1] * 1024 dense + fixed sparse vectors
  • Reranker — fake returning 1.0 / (i + 1) scores
  • Chunker — returns each input text as a single chunk
  • Parser — returns a hardcoded ParsedDocument

Each test gets a uniquely-named Qdrant collection and MinIO bucket — fully isolated, fully parallel-safe.

Integration tests (requires Docker):

docker compose -f docker-compose.yml -f docker-compose.test.yml up -d
cd services/rag && uv run pytest -m integration -v

Integration tests hit real services and are excluded from the default test run via pytest markers.

Linting and type checking

# Each Python service
cd services/rag && uv run ruff check --fix . && uv run ruff check . && uv run mypy rag/
cd services/agent && uv run ruff check . && uv run mypy agent/
cd services/embedder && uv run ruff check . && uv run mypy embedder/

# Frontend
cd services/frontend && npm run lint:check && npm run format:check

Configuration: Ruff with py312 target, 100-char lines, rules E/F/I/N/W/UP/B/SIM/C4. Mypy with check_untyped_defs, strict_equality, and no_implicit_optional.

Key Design Decisions

  • Content-addressable documents. SHA-256 of file content serves as document_id. Re-uploading the same file returns existed=True without re-processing. This makes document identity independent of filename and enables safe idempotent uploads.

  • Hybrid search with RRF fusion. Dense embeddings capture semantic meaning; sparse (lexical) embeddings excel at keywords, identifiers, and technical terms. Reciprocal Rank Fusion balances both signals without requiring score calibration.

  • MCP protocol for agent integration. The agent accesses RAG tools through the Model Context Protocol, decoupling the agent service from the RAG backend. The same RAGClient that powers the REST API also serves MCP tool calls — no code duplication.

  • Stateless RAGClient. The search client holds no state — it receives a SearchRequest and returns a SearchResponse. This lets it be reused across transports (FastAPI dependency injection, MCP server) without modification.

  • CPU-optimized by default. The embedder uses PyTorch CPU packages (~2.7 GB image vs ~9 GB with GPU). A docker-compose.gpu.yml overlay swaps in CUDA 12.4 support when GPU acceleration is available.

  • In-process test infrastructure. Tests run against real Qdrant (in-memory mode) and moto S3 with deterministic fakes for external HTTP services. This gives integration-level confidence with unit-level speed and zero Docker overhead.

  • Thin controllers. FastAPI endpoints validate input, call the service layer, and return a response. All business logic lives in RAGService and RAGClient, making the system testable without HTTP and easy to extend with new transports.

  • Async ingestion with backpressure. Batch document processing runs as background asyncio tasks. A global semaphore (configurable via INGEST_CONCURRENCY) limits concurrent CPU-heavy parse operations, preventing resource exhaustion.

License

MIT