A configurable, multi-tenant RAG (Retrieval-Augmented Generation) backend built with FastAPI. WEC uses this to build independent RAG pipelines for each of their clients — every aspect of the pipeline (chunking strategy, embedding provider, retrieval blend, reranking) is configurable at runtime via API, no redeploys needed.
┌─────────────────────────────────────────────────┐
│ FastAPI Backend │
│ │
┌──────────┐ upload │ ┌──────────────────────────────────────────┐ │
│ WEC UI │──────────►│ │ Ingestion Pipeline │ │
└──────────┘ │ │ LiteParse → Chunker → Embedder ─────┐ │ │
│ └──────────────────────────────────────┼──┘ │
│ │ │
│ ┌──────────▼────┐ │
│ │ │ │
│ ┌────► Qdrant (VDB) │ │
│ │ │ │ │
│ ┌──────────────────┐ │ └───────────────┘ │
┌──────────┐ query │ │ Retrieval Pipeline│ │ │
│ WEC UI │──────────►│ │ │ │ ┌───────────────┐ │
└──────────┘ │ │ QueryTransformer │ │ │ OpenSearch │ │
│ │ Vector + BM25 ───►│───┘ │ (BM25) │ │
│ │ RRF Fusion │ └───────────────┘ │
│ │ Dedup + Reranker │ │
│ └──────────────────┘ ┌───────────────┐ │
│ │ PostgreSQL │ │
│ │ (metadata + │ │
│ │ configs) │ │
│ └───────────────┘ │
│ │
│ ┌───────────────┐ │
│ │ MinIO │ │
│ │ (raw files) │ │
│ └───────────────┘ │
└─────────────────────────────────────────────────┘
All data is scoped to a tenant_id. There is one OpenSearch index (rag_chunks) and per-embedding-dimension Qdrant collections shared across tenants — the default-dim collection keeps the bare QDRANT_COLLECTION name (e.g. rag_chunks) and other dimensions auto-route to {base}_{dim} (e.g. rag_chunks_1536, rag_chunks_3072). Isolation is enforced by filtering on tenant_id in every query. PostgreSQL tables use a foreign-key tenant_id column with cascade deletes. The documents.embedding_dimensions column records which collection each doc lives in so DELETE can route to the right one.
Pipelines are split into two independent rows in PostgreSQL — ingestion_configs (chunking + embedding) and retrieval_configs (query transform, search blend, fusion, dedup, reranker, top_k). A retrieval config links to one or more ingestion configs via a join table; all linked configs must share the same embedding model + dimensions so the query embedder produces vectors of the right shape for every corpus the retrieval searches.
// ingestion_configs.config
{
"chunking": { "strategy": "recursive", "chunk_size": 512, ... },
"embedding": { "provider": "zeroentropy", "model": "zembed-1", "dimensions": 2560, ... }
}
// retrieval_configs.config
{
"query_transformation": {...}, "vector_search": {...}, "keyword_search": {...},
"fusion": {...}, "deduplication": {...}, "reranker": {...}, "top_k": 5
}Configs are addressed by ID at runtime — there is no "active config" concept. Every document upload specifies which ingestion config to use (POST /tenants/{tid}/ingestion-configs/{icid}/documents) and every query specifies which retrieval config (POST /tenants/{tid}/retrieval-configs/{rcid}/query). A tenant can hold any number of ingestion and retrieval configs in parallel; callers pick the one they want per request.
On FastAPI startup the app provisions the data stores it depends on (idempotent, safe to run repeatedly):
- MinIO bucket created if missing (when
STORAGE_BACKEND=minio) - Default Qdrant collection (
QDRANT_COLLECTION, defaultrag_chunks) created withQDRANT_VECTOR_SIZEdimension. Additional per-dim collections are created lazily on first upsert. - OpenSearch index
rag_chunkscreated with the BM25 + English-stemmer mapping
Failures are logged as warnings rather than crashing the process, so partial deploys (e.g. missing MinIO during local tests) don't block boot.
Hot-path latency tuning landed in this release. Knobs to know:
- Singleton Qdrant + OpenSearch clients.
QdrantVectorStoreandOpenSearchBM25instances are cached process-wide (keyed by embedding dimension for Qdrant; single instance for OpenSearch) viaget_qdrant_store()/get_opensearch_store(). Previously everyrun_retrievalcall constructed fresh clients and paid a TLS handshake against Qdrant Cloud + AWS OpenSearch (~100-200ms each). Lifespan shutdown closes them cleanly viaclose_all_qdrant_stores()/close_opensearch_store(). - Long-lived TEI httpx clients.
TEIEmbedderandTEIRerankerhold a per-instancehttpx.AsyncClient(with keep-alive limits) instead ofasync with-ing one per call, so repeated embeds/reranks reuse the same TCP/TLS connection. - Explicit LiteLLM defaults. Every
litellm.acompletion/aembeddingcall site passestimeout=30(andnum_retries=1for completions) so a wedged provider call can't hang the request. - Bounded chat output.
ChatRequest.max_tokensdefaults to 2048 (caller-overridable, range 1-8192). Without this, the JSON/chatendpoint could ramble to the model's max and pin TTFT to full generation. - Parallel citation presigning.
/chatand/chat/streamfan outgenerate_download_urlcalls viaasyncio.gather, so adding more citations doesn't multiply round-trips against MinIO / customer S3. - Streaming chat.
POST .../chat/streamreturns SSE so the UI gets first-token in ~retrieval-latency + ~600ms instead of waiting for the full JSON response. See#### Streaming chatunder the Chat (RAG) section.
For production throughput, the entrypoint should also bump uvicorn workers (--workers $N --loop uvloop); each worker holds its own singleton clients.
Every ingestion and retrieval run writes a row to the executions table — status, timings, and a metrics JSONB with token counts, *_ms phases, and cost. Costs are computed best-effort via LiteLLM get_model_info; providers without published pricing (ZeroEntropy, HuggingFace) leave *_cost_usd null with a warning rather than fabricate zero.
Two cost surfaces complement the executions log:
- Pre-flight prediction —
POST /ingestion-configs/{icid}/preview-costaccepts a sample file orsample_textand returns the cost the config would incur, without any DB writes. Use this at chunking-config-selection time. - Post-flight roll-up —
GET /ingestion-configs/{icid}carries acost_summaryblock aggregating across all executions for that config (total_cost_usd,executions_count,successful_count,failed_count,latest_execution_at,avg_cost_per_document_usd).
- Format allow-list — only PDF, DOCX, TXT, MD, CSV are accepted. Other types return
415 Unsupported Media Typewith a structured error. - 30 MB upload cap — enforced at the HTTP layer (
POST /tenants/{tid}/ingestion-configs/{icid}/documentsreturns413 Content Too Large) and again at parse time in the ingestion pipeline as defense in depth. Configurable viaMAX_UPLOAD_BYTES. - Contextual chunking warning — when an ingestion config uses
chunking.strategy=contextual, every relevant API response (config create/update, document upload) carries awarningsarray explaining the per-chunk Claude cost. - Auto-split for oversize docs — if a document's token count exceeds 85% of the configured LLM's context window, the contextual chunker splits the text at a paragraph boundary into halves and processes each independently. Resulting chunks carry
auto_split: trueandsuggested_models: [...]in their metadata so the UI can recommend a larger-context alternative.
- Python 3.11+
- Node.js ≥ 18 (required by LiteParse for document parsing)
- Docker + Docker Compose
There are two deploy targets, picked by which compose file you use:
| Target | Compose file | What it brings up |
|---|---|---|
| Local-bundled | docker-compose.yml |
Postgres + Qdrant + OpenSearch + MinIO + app (all in containers). Single-command for a from-scratch laptop / VM. |
| Hosted-services | docker-compose.hosted.yml |
Only the app. Talks to externally-hosted Postgres, Qdrant, AWS OpenSearch, and object storage (S3 or hosted MinIO). Use this for production. |
Both compose files build the same Dockerfile; the difference is purely which services are included and which env vars are required.
cp .env.example .env
# Generate the three required secrets and paste them into .env:
python -c "from cryptography.fernet import Fernet; print('ENCRYPTION_KEY=' + Fernet.generate_key().decode())"
python -c "import secrets; print('STATIC_API_KEY=wec_sk_' + secrets.token_hex(32))"
python -c "import secrets; print('ADMIN_API_KEY=wec_admin_' + secrets.token_hex(32))"
docker compose up --build -d
curl http://localhost:8000/healthcp .env.hosted.example .env
# Fill in real connection URLs/creds for each external service, plus the
# three secrets (ENCRYPTION_KEY / STATIC_API_KEY / ADMIN_API_KEY).
docker compose -f docker-compose.hosted.yml up --build -d
curl http://localhost:8000/healthThe entrypoint runs alembic upgrade head against your hosted Postgres before starting uvicorn, so a fresh DB is migrated automatically. AWS OpenSearch (basic auth + TLS) is supported via OPENSEARCH_USE_SSL=true; Qdrant Cloud is supported via QDRANT_API_KEY + QDRANT_USE_HTTPS=true.
docker compose up -d postgres qdrant opensearch minio
cp .env.example .env # fill in secrets as above
pip install -e ".[dev]"
npm install -g @llamaindex/liteparse # LiteParse requires Node.js ≥ 18
# Verify: `liteparse --help` should print usage. Without this CLI, every
# document upload fails at the parse step. For scanned PDFs needing OCR,
# also install Tesseract or point LiteParse at an OCR server (see
# https://developers.llamaindex.ai/liteparse/).
alembic upgrade head
uvicorn app.main:app --reload --port 8000Interactive docs at http://localhost:8000/docs.
# ENCRYPTION_KEY — Fernet base64 key for stored provider API keys
from cryptography.fernet import Fernet
print(Fernet.generate_key().decode())
# STATIC_API_KEY — single-user bearer (authorizes every route, including admin)
import secrets
print("wec_sk_" + secrets.token_hex(32))
# ADMIN_API_KEY — only needed if you want a separate admin token alongside
# the per-tenant auth flow. Optional if STATIC_API_KEY is set.
print("wec_admin_" + secrets.token_hex(32))All variables are read from .env (or the real environment). Every variable has a default so the service starts without a .env in development — only ENCRYPTION_KEY and an auth secret (STATIC_API_KEY or ADMIN_API_KEY) are required.
| Variable | Default | Description |
|---|---|---|
APP_ENV |
development |
Set to production to silence SQL echo |
APP_SECRET_KEY |
change-me |
Application secret |
LOG_LEVEL |
INFO |
Python logging level |
DATABASE_URL |
postgresql+asyncpg://ragstudio:ragstudio@localhost:5432/ragstudio |
Async PostgreSQL DSN |
QDRANT_HOST |
localhost |
Qdrant host |
QDRANT_PORT |
6333 |
Qdrant gRPC/REST port |
QDRANT_COLLECTION |
rag_chunks |
Base name for Qdrant collections. The default-dim collection uses this name verbatim; non-default dims auto-route to {base}_{dim} (e.g. rag_chunks_1536). |
QDRANT_VECTOR_SIZE |
2560 |
Dimension of the default (bare-name) Qdrant collection. Other dimensions are handled automatically — see "Multi-tenancy" above. |
OPENSEARCH_HOST |
localhost |
OpenSearch host |
OPENSEARCH_PORT |
9200 |
OpenSearch port |
OPENSEARCH_INDEX |
rag_chunks |
OpenSearch index name |
OPENSEARCH_USER |
admin |
OpenSearch username |
OPENSEARCH_PASSWORD |
admin |
OpenSearch password |
MINIO_ENDPOINT |
localhost:9000 |
MinIO endpoint (no scheme) |
MINIO_ACCESS_KEY |
minioadmin |
MinIO access key |
MINIO_SECRET_KEY |
minioadmin |
MinIO secret key |
MINIO_BUCKET |
rag-documents |
Bucket for uploaded documents |
MINIO_SECURE |
false |
Set to true for HTTPS |
STORAGE_BACKEND |
minio |
minio or s3 — picks the storage adapter |
AWS_REGION |
us-east-1 |
AWS region (when STORAGE_BACKEND=s3) |
AWS_ACCESS_KEY_ID |
(empty) | AWS access key. Empty → boto3 default credential chain (env / IAM role) |
AWS_SECRET_ACCESS_KEY |
(empty) | AWS secret key |
S3_BUCKET |
rag-documents |
S3 bucket name (when STORAGE_BACKEND=s3) |
MAX_UPLOAD_BYTES |
31457280 (30 MiB) |
Hard cap on upload size; enforced at HTTP and again at parse time |
ENCRYPTION_KEY |
(required) | Fernet base64 key for encrypting stored provider API keys |
STATIC_API_KEY |
(empty) | Single-user mode. One bearer that authorizes every tenant-scoped route AND counts as admin (wildcard tenant). Leave blank to require the dynamic per-tenant key flow. |
ADMIN_API_KEY |
(empty) | Separate admin token for the multi-tenant bootstrap flow (mint per-tenant keys via POST /v1/tenants/{tid}/auth-keys). Optional if STATIC_API_KEY is set. |
OPENSEARCH_USE_SSL |
false |
Use TLS for OpenSearch (set true for AWS OpenSearch Service). |
OPENSEARCH_VERIFY_CERTS |
true |
Verify TLS certs against the system trust store. Only consulted when OPENSEARCH_USE_SSL=true. |
QDRANT_API_KEY |
(empty) | API key for hosted Qdrant (e.g. Qdrant Cloud). Blank = unauthenticated, which is correct for local single-node. |
QDRANT_USE_HTTPS |
false |
Use HTTPS for Qdrant. Set true for Qdrant Cloud / any hosted deployment. |
Started via docker-compose up -d:
| Service | Image | Ports | Purpose |
|---|---|---|---|
| PostgreSQL | postgres:16-alpine |
5432 |
Relational store — tenants, documents, chunks, ingestion_configs, retrieval_configs, API keys |
| Qdrant | qdrant/qdrant:latest |
6333, 6334 |
Vector similarity search |
| OpenSearch | opensearchproject/opensearch:2.17.0 |
9200, 9600 |
BM25 keyword search |
| MinIO | minio/minio:latest |
9000 (API), 9001 (console) |
Object storage for raw document files |
MinIO console: http://localhost:9001 (admin / minioadmin)
Every /v1/tenants/{tid}/... route requires Authorization: Bearer <token>. Open routes (no auth): GET /health, GET /v1/providers.
There are two modes:
Single-user mode (simplest). Set STATIC_API_KEY in .env. That one token authorizes every tenant-scoped route (the request's tenant_id is not checked) AND counts as admin. Use this if you're the only user.
curl http://localhost:8000/v1/tenants/{any-uuid}/documents \
-H "Authorization: Bearer $STATIC_API_KEY"Multi-tenant mode. Set ADMIN_API_KEY (a separate admin token). Admin creates tenants and mints the first auth key for each; tenants use their own key thereafter. Cross-tenant tokens get 403.
# 1. Admin creates a tenant.
curl -X POST http://localhost:8000/v1/tenants \
-H "Authorization: Bearer $ADMIN_API_KEY" -H "Content-Type: application/json" \
-d '{"name":"Acme Corp"}'
# 2. Admin mints the FIRST auth key for that tenant.
curl -X POST http://localhost:8000/v1/tenants/{tid}/auth-keys \
-H "Authorization: Bearer $ADMIN_API_KEY" -H "Content-Type: application/json" \
-d '{"name":"production"}'
# Response: {"key": "wec_pk_<prefix>_<secret>", "metadata": {...}} — raw key shown ONCE.
# 3. Tenant uses that key for everything.
curl http://localhost:8000/v1/tenants/{tid}/documents \
-H "Authorization: Bearer wec_pk_..."GET /health
POST /v1/tenants [admin] Create a tenant
GET /v1/tenants/{tenant_id} Get tenant by ID
POST /v1/tenants/{tenant_id}/api-keys Store (or replace) a named provider API key
(See Pipeline Configs below for ingestion/retrieval config endpoints, and Auth keys for tenant bearer-token management.)
Create tenant (admin token required):
POST /v1/tenants
{ "name": "Acme Corp" }Store API key:
POST /v1/tenants/{tenant_id}/api-keys
{
"provider": "openai", // openai | anthropic | google | voyage | zeroentropy | cohere | huggingface | aws_s3
"api_key": "sk-...", // for aws_s3, this is a JSON string: {"access_key_id":"...","secret_access_key":"...","region":"..."}
"name": "api_key" // optional — defaults to "api_key"; use a custom name to store multiple keys per provider
}The key is AES-256 encrypted before storage. Submitting again with the same (provider, name) pair overwrites the previous key. Different names for the same provider are stored independently.
Open endpoint — no auth required, same posture as /health and /v1/providers. Creates a tenant scoped to an email and mints its first API key in one shot. The admin-gated POST /v1/tenants still works for ops use.
POST /v1/signups Open — create tenant + mint first API key in one request
POST /v1/signups
{
"email": "acme@example.com",
"name": "Acme Corp"
}
201 Created →
{
"tenant_id": "<uuid>",
"email": "acme@example.com",
"name": "Acme Corp",
"api_key": "wec_pk_<prefix>_<secret>", // raw key — shown ONCE
"key_metadata": {...}
}email is validated as RFC-format (Pydantic EmailStr) and is globally unique. Status codes:
| Code | When |
|---|---|
| 201 | Success |
| 409 | Email already registered |
| 422 | Invalid email format, missing name, or extra fields |
POST /v1/tenants/{tenant_id}/auth-keys Mint a new tenant API key (raw key returned ONCE)
GET /v1/tenants/{tenant_id}/auth-keys List this tenant's auth-key metadata (no raw values)
DELETE /v1/tenants/{tenant_id}/auth-keys/{key_id} Revoke (sets revoked_at)
The very first POST for a freshly-created tenant accepts either an admin token (bootstrap path) or any valid tenant key. Subsequent calls require a tenant key. Raw keys are formatted wec_pk_{8-char-prefix}_{32-char-secret}. The hash uses SHA-256 + hmac.compare_digest; raw keys are stored only as their hash — losing the raw value means re-minting.
Customer-bucket pulls authenticate with AWS creds stored per-tenant. Prefer this dedicated entity over the legacy api_keys.provider="aws_s3" flow.
POST /v1/tenants/{tenant_id}/s3-configs Create (encrypts secret_access_key with Fernet)
GET /v1/tenants/{tenant_id}/s3-configs List metadata (no secrets)
GET /v1/tenants/{tenant_id}/s3-configs/{config_id} Get single (no secret)
PUT /v1/tenants/{tenant_id}/s3-configs/{config_id} Partial update; if `secret_access_key` is present, re-encrypts
DELETE /v1/tenants/{tenant_id}/s3-configs/{config_id} Delete
POST /v1/tenants/{tid}/s3-configs
{
"name": "acme-prod",
"access_key_id": "AKIA...",
"secret_access_key": "...",
"region": "us-east-1",
"default_bucket": "acme-rag", // optional
"default_prefix": "docs/" // optional
}secret_access_key is never returned by any GET — only access_key_id, region, and the optional default-bucket/prefix metadata. Multiple named credential sets per tenant are supported (e.g. acme-prod, acme-staging). The new POST /run-from-s3 endpoint references these by name.
Backwards compat: the legacy POST /v1/tenants/{tid}/api-keys with provider="aws_s3" still works; the /documents/from-s3 endpoint resolves credentials by trying s3_configs first and falling back to api_keys.
You can save the credential default directly on the ingestion-config so /run-from-s3 doesn't need s3_config_name in every call. Add a source block to the config:
POST /v1/tenants/{tid}/ingestion-configs
{
"name": "wec-dev-pipeline",
"config": {
"chunking": {"strategy": "basic", "token_size": 512, "token_overlap": 50},
"embedding": {"provider": "zeroentropy", "model": "zembed-1", "dimensions": 2560},
"source": {
"type": "aws_s3",
"s3_config_id": "<s3-config-uuid>"
}
}
}source is a discriminated union with two variants today:
{"type": "upload"}— explicit marker that this pipeline only accepts multipart uploads via/run. (Same effect as omittingsourceentirely.){"type": "aws_s3", "s3_config_id": "<uuid>", "store_files": true}— credential default for/run-from-s3. The s3_config_id must belong to the same tenant; that's validated at POST/PUT time (422 with a clear message if not).store_filescontrols whether files are copied into our backing storage (managed mode, default) or kept in place as references (linked mode) — see Storage modes below.
When /run-from-s3 is called against a config with source.type=aws_s3:
- Body sends
{uri, kind}only → creds come from the saved source. - Body also sends
s3_config_name→ body wins (per-call override; useful for ad-hoc runs without editing the config). - Body has neither
s3_config_namenor a saved source → 400 with the "set one or the other" message. - Saved
s3_config_idwas deleted → 404 telling the caller to update the ingestion-config.
URI stays per-call regardless — the same config feeds many runs against different paths over time.
When the s3-config row has default_bucket set, /run-from-s3 accepts a leaner body that omits the s3://bucket/ part:
POST /run-from-s3
{
"s3_config_name": "wec-rag-dev", // s3-config with default_bucket="wec-rag-dev"
"key": "docs/jan-2026/", // path inside that bucket
"kind": "prefix"
}The server assembles s3://wec-rag-dev/docs/jan-2026/ from the config + body. Rules:
keyanduriare mutually exclusive (422 if both are sent).keyrequiress3_config_name+kind.- The s3-config must have
default_bucketset (422 with a clear message otherwise). - Default-bucket mode only works with new-style s3-configs (not legacy
api_keys.aws_s3).
Reusable named S3 source bundles per tenant. The UI lists them as a dropdown; POST /run-from-s3 resolves a preset_id to its saved spec at run time.
POST /v1/tenants/{tenant_id}/source-presets Create
GET /v1/tenants/{tenant_id}/source-presets List (sorted by last_used_at desc) — drives the UI dropdown
GET /v1/tenants/{tenant_id}/source-presets/{preset_id} Get single
PUT /v1/tenants/{tenant_id}/source-presets/{preset_id} Update name and/or spec
DELETE /v1/tenants/{tenant_id}/source-presets/{preset_id} Delete
spec is a discriminated union — S3 sources only (file upload is direct multipart and doesn't need a preset):
// aws_s3 — saved s3 URI/prefix tied to an s3_config
POST /v1/tenants/{tid}/source-presets
{
"name": "jan-2026-batch",
"spec": {
"type": "aws_s3",
"s3_config_id": "<s3-config-uuid>",
"uri": "s3://acme-rag/jan-2026/",
"kind": "prefix"
}
}
// presigned — saved batch of pre-signed URLs (no creds needed)
POST /v1/tenants/{tid}/source-presets
{
"name": "q1-share",
"spec": {
"type": "presigned",
"urls": ["https://...", "https://..."]
}
}s3_config_id is validated at write time — it must reference a real s3_configs row owned by the same tenant. Each successful /run-from-s3 against a preset bumps its last_used_at so the dropdown can sort by recency.
To run with a saved preset:
curl -X POST .../ingestion-configs/{ic_id}/run-from-s3 \
-H "Content-Type: application/json" \
-d '{"preset_id": "8881fe5f-..."}'preset_id is mutually exclusive with the inline source fields (uri / kind / s3_config_name / presigned_urls) — sending both gets a 422 with a clear message.
# New unified entry points (recommended):
POST /v1/tenants/{tenant_id}/ingestion-configs/{ic_id}/run Multipart: upload file(s) + trigger ingestion
POST /v1/tenants/{tenant_id}/ingestion-configs/{ic_id}/run-from-s3 JSON: pull from s3 URI / prefix / presigned URLs
# Legacy entry points (still supported, identical behavior):
POST /v1/tenants/{tenant_id}/ingestion-configs/{ic_id}/documents Multipart upload
POST /v1/tenants/{tenant_id}/ingestion-configs/{ic_id}/documents/from-s3 S3 pull
# Browse / get / delete (tenant-scoped):
GET /v1/tenants/{tenant_id}/documents List all documents (across configs)
GET /v1/tenants/{tenant_id}/documents/{document_id} Get + ingestion status
DELETE /v1/tenants/{tenant_id}/documents/{document_id} Delete document + chunks + all indexes
POST /run is the new entry point: pick a source per request (upload via multipart, or S3 via JSON), and the same downstream pipeline (size guard → format guard → storage → Document row → background ingestion) runs in both cases. The legacy /documents and /documents/from-s3 continue to work unchanged.
# /run with upload source (multipart)
curl -X POST .../ingestion-configs/{ic_id}/run \
-F "files=@a.pdf" -F "files=@b.docx"
# /run-from-s3 — single object, using stored s3-config
curl -X POST .../ingestion-configs/{ic_id}/run-from-s3 \
-H "Content-Type: application/json" \
-d '{"s3_config_name": "acme-prod",
"uri": "s3://acme-rag/docs/quarterly.pdf",
"kind": "uri"}'
# /run-from-s3 — bulk pull every supported file under a prefix
curl -X POST .../ingestion-configs/{ic_id}/run-from-s3 \
-H "Content-Type: application/json" \
-d '{"s3_config_name": "acme-prod",
"uri": "s3://acme-rag/docs/",
"kind": "prefix"}'
# /run-from-s3 — presigned URLs (no creds needed in our system)
curl -X POST .../ingestion-configs/{ic_id}/run-from-s3 \
-H "Content-Type: application/json" \
-d '{"presigned_urls": ["https://...", "https://..."]}'Both /run and /run-from-s3 return 202 with a list[DocumentResponse]. Each entry's metadata includes a source tag ("upload" / "s3" / "presigned") and, for S3-sourced docs, the original source_key. The response also carries linked: bool and linked_s3_config_id: UUID | None — see Storage modes below.
For S3 sources, files can either be copied into our backing storage (managed mode, default) or kept in place as references (linked mode). Toggle per-call via store_files in the request body, or save the default on the ingestion-config's source.store_files.
| Mode | store_files |
file_key |
linked_s3_config_id |
Re-fetch on parse | Delete touches customer bucket? |
|---|---|---|---|---|---|
| Managed (default) | true |
managed path ({tenant_id}/{uuid}/{filename}) |
NULL | From our storage | n/a (managed) |
| Linked | false |
original s3://bucket/key |
s3-config row id | From customer bucket using stored creds | No — only the local row + index entries are removed |
# Linked mode: keep the file in the customer bucket, never copy.
curl -X POST .../ingestion-configs/{ic_id}/run-from-s3 \
-H "Content-Type: application/json" \
-d '{"s3_config_name": "wec-rag-dev",
"uri": "s3://wec-rag-dev/docs/contract.pdf",
"kind": "uri",
"store_files": false}'Rules:
store_files=false+presigned_urlsis rejected with 422 — presigned URLs have no stable customer-side reference for re-fetch.store_files=falserequires creds from the news3_configstable (not legacyapi_keys.aws_s3).- If the linked s3-config is later deleted, ingestion fails with a "re-link or re-ingest with store_files=true" error.
The legacy endpoints (no behavior change):
Upload is nested under an ingestion config because every document is tied to the specific pipeline used to process it. Browse / get / delete remain at the tenant level so callers can list documents across all their configs.
Multipart uploads use a repeatable files field — send one or more files in a single request. Each file becomes its own Document row with its own background ingestion task (so files are processed in parallel). The 30 MB cap applies per file; if any file in the batch is over the limit the entire request fails with 413 before any storage writes happen. Response is a list[DocumentResponse] in upload order; each entry carries an optional warnings array (e.g. when contextual chunking is in use) plus ingestion_config_id and ingestion_config_name.
Supported file types: PDF, DOCX, TXT, MD, CSV. Other types return 415 Unsupported Media Type with a structured error listing the accepted MIME types and extensions.
Ingestion runs in the background; poll GET /documents/{id} and watch status:
| Status | Meaning |
|---|---|
pending |
Uploaded, queued |
processing |
Parse/chunk/embed in progress |
indexed |
All indexes updated, ready to query |
failed |
Error in error_msg field |
POST /v1/tenants/{tenant_id}/retrieval-configs/{retrieval_config_id}/query
The query is nested under a retrieval config — pick which one to use per request. The retrieval config links to one or more ingestion configs; all linked configs share the same embedding model + dimensions, so the query embedder produces vectors of the right shape for every corpus the retrieval covers. Searches are filtered to chunks belonging to the linked ingestion-config set.
{
"query": "What are the key risks in the contract?",
"retrieval_config_override": { // optional — overrides specific fields for this request
"top_k": 10,
"reranker": { "enabled": true, "provider": "zeroentropy", "model": "zerank-2", "top_k": 10 }
}
}Default response (group_by="chunk"):
{
"query": "What are the key risks in the contract?",
"group_by": "chunk",
"total": 5,
"results": [
{
"chunk_id": "...",
"document_id": "...",
"content": "Clause 14 limits liability to...",
"score": 0.0147,
"metadata": { "page": 3, "source": "contract.pdf" }
}
]
}Grouped-by-document response (group_by="document"):
{
"query": "What are the key risks in the contract?",
"group_by": "document",
"total": 3,
"results": [
{
"document_id": "...",
"document_name": "contract.pdf",
"score": 0.0147,
"chunks": [
{ "chunk_id": "...", "content": "...", "score": 0.0147, "metadata": {...} }
]
}
]
}The retrieval config controls these filters that shape the response:
document_ids— static list of document UUIDs to restrict the search to. Every id must belong to this tenant AND to one of the retrieval config's linked ingestion configs. Empty list rejected (422);null= no filter (default).metadata_filters—dict[str, str|int|bool|list]filter on per-chunk metadata. Keys must be in the union of every linked ingestion config's declaredchunking.metadata_fields(422 with the offending keys otherwise). Scalar values produce a term match; list values produce an any-of match. Chunks from a config that didn't declare the key just won't match — no error.group_by—"chunk"(default, flat list ofRetrievedChunk) or"document"(one entry per source doc with up tochunks_per_docchunks nested).chunks_per_doc— int ≥ 1, default 3. Only consulted whengroup_by = "document".
GET /v1/providers
Returns all supported embedding, reranker, and LLM providers enriched with dimensions, context windows, and pricing pulled live from LiteLLM's model_prices_and_context_window.json. ZeroEntropy is hardcoded since it's not in LiteLLM's catalog. Each entry carries a source: "litellm" | "hardcoded" flag.
{
"embedding": [
{
"provider": "openai", "model": "text-embedding-3-small",
"default_dimensions": 1536,
"supported_dimensions_min": 256, "supported_dimensions_max": 1536,
"max_input_tokens": 8191,
"cost_per_1m_input_tokens": 0.02,
"source": "litellm"
},
{
"provider": "zeroentropy", "model": "zembed-1",
"default_dimensions": 2560,
"supported_dimensions": [40, 80, 160, 320, 640, 1280, 2560],
"max_input_tokens": 8192,
"source": "hardcoded"
}
],
"llm": [
{
"provider": "anthropic", "model": "claude-haiku-4-5",
"context_window": 200000, "max_output_tokens": 8192,
"cost_per_1m_input_tokens": 0.8, "cost_per_1m_output_tokens": 4.0,
"supports_prompt_caching": true,
"source": "litellm"
}
],
"reranker": [
{
"provider": "cohere", "model": "rerank-v3.5",
"max_input_tokens": 4096,
"cost_per_1m_input_tokens": 2.0,
"source": "litellm"
}
]
}The pipeline is split into two resources: an ingestion config (chunking + embedding, used at index time) and a retrieval config (query transformation + search blend + fusion + dedup + reranker + top_k, used at query time). A retrieval config links to one or more ingestion configs through a join table; all linked configs must share the same embedding (provider + model + dimensions), validated at POST/PUT time. A single query embedding then searches every linked corpus in one shot, filtered to chunks whose ingestion_config_id is in the linked set.
There is no "active config" concept — every upload and every query specifies the config to use via the URL. A tenant can hold any number of ingestion and retrieval configs in parallel. GET responses include the full config JSONB blob.
Ingestion configs (chunking + embedding):
GET /v1/tenants/{tenant_id}/ingestion-configs List ingestion configs (each carries `cost_summary`)
GET /v1/tenants/{tenant_id}/ingestion-configs/{config_id} Get single (full `config`, `warnings`, `cost_summary`)
POST /v1/tenants/{tenant_id}/ingestion-configs Create (config nullable)
PUT /v1/tenants/{tenant_id}/ingestion-configs/{config_id} Update name and/or config
POST /v1/tenants/{tenant_id}/ingestion-configs/{config_id}/preview-cost Predict ingestion cost from a sample file or text (no DB writes)
The cost_summary block on GET responses is computed live from the executions table:
"cost_summary": {
"total_cost_usd": 0.0421,
"executions_count": 12,
"successful_count": 11,
"failed_count": 1,
"latest_execution_at": "2026-05-12T10:30:00Z",
"avg_cost_per_document_usd": 0.0035
}preview-cost accepts either a multipart sample_file or a multipart form field sample_text. Returns {estimated_chunks, estimated_tokens, embedding_cost_usd, llm_cost_usd, total_cost_usd, warnings}. Providers without published per-token pricing (ZeroEntropy, HuggingFace) return null for cost fields with a warning, rather than fabricate zero.
Retrieval configs (query-time settings; FK to an ingestion config):
GET /v1/tenants/{tenant_id}/retrieval-configs List retrieval configs
GET /v1/tenants/{tenant_id}/retrieval-configs/{config_id} Get single (full `config`)
POST /v1/tenants/{tenant_id}/retrieval-configs Create (body must include non-empty `ingestion_config_ids: list[UUID]`)
PUT /v1/tenants/{tenant_id}/retrieval-configs/{config_id} Update
Create an empty ingestion slot (no config yet):
POST /v1/tenants/{tenant_id}/ingestion-configs
{ "name": "My Pipeline" }config is null by default. Fill it in later with PUT.
Create a fully-configured ingestion config:
POST /v1/tenants/{tenant_id}/ingestion-configs
{
"name": "Recursive 512 + ZE zembed-1",
"config": {
"chunking": {
"strategy": "recursive",
"chunk_size": 512,
"chunk_overlap": 64,
"separators": ["\n\n", "\n", " ", ""],
"context_window_provider": "anthropic",
"context_window_llm": "claude-haiku-4-5",
"metadata_fields": ["source", "page"],
"semantic": { "breakpoint_percentile": 95, "min_chunk_size": 100, "max_chunk_size": 1500 }
},
"embedding": {
"provider": "zeroentropy",
"model": "zembed-1",
"dimensions": 2560
}
}
}Create a retrieval config linking one or more ingestion configs. Every config in ingestion_config_ids must share the same embedding model + dimensions — the API rejects mismatches with a 422. A single-element list is valid and behaves identically to a single-corpus retrieval; multi-element lists let a single query span N corpora ingested under different parsing / chunking strategies. PUT with a non-empty ingestion_config_ids REPLACES the link set wholesale; omit the field to leave links unchanged.
POST /v1/tenants/{tenant_id}/retrieval-configs
{
"name": "Hybrid + ZE rerank",
"ingestion_config_ids": ["<ingestion-config-uuid-a>", "<ingestion-config-uuid-b>"],
"config": {
"query_transformation": { "enabled": true, "strategy": "rewrite", "llm_model": "gpt-4o-mini" },
"vector_search": { "enabled": true, "top_k": 20 },
"keyword_search": { "enabled": true, "top_k": 20 },
"fusion": { "method": "rrf", "rrf_k": 60, "vector_weight": 0.7, "keyword_weight": 0.3 },
"deduplication": { "enabled": true, "similarity_threshold": 0.95 },
"reranker": { "enabled": true, "provider": "zeroentropy", "model": "zerank-2", "top_k": 10 },
"top_k": 5,
"document_ids": ["doc-uuid-1", "doc-uuid-2"],
"metadata_filters": { "source": "helpdesk", "page": [3, 4, 5] },
"group_by": "document",
"chunks_per_doc": 3
}
}Every ingestion and every retrieval run writes a row to the executions table — status, timings, metrics JSONB, and the FK to whichever config drove the run.
GET /v1/tenants/{tenant_id}/executions List runs (paginated)
GET /v1/tenants/{tenant_id}/executions/{execution_id} Single run with full metrics
POST /v1/tenants/{tenant_id}/executions/{execution_id}/cancel Cooperative cancel (202 — best-effort)
Query params on the list endpoint: pipeline_type (ingestion|retrieval), status (running|succeeded|failed), document_id, retrieval_config_id, limit (1–500, default 50), offset.
Every response carries the config names alongside the IDs — ingestion_config_name for ingestion rows, retrieval_config_name for retrieval rows. Both are null if the FK'd config was deleted (ON DELETE SET NULL).
Metrics JSONB shape (fields conditionally present):
- Ingestion:
chunks_count,embed_tokens,embed_cost_usd,llm_cost_usd(contextual only),total_cost_usd,parser_ms,chunker_ms,embed_ms,index_ms,parsed_pages,parsed_chars,chunking_strategy,embedding_provider,embedding_model,embedding_dimensions. - Retrieval:
top_k,vector_hits,keyword_hits,fused_count,unique_documents(when grouped),query_transform_used,reranker_used. - Either:
cancel_requested: trueandcancel_requested_at: <iso>when a cancel was issued.
Costs are best-effort via LiteLLM get_model_info. Providers without published pricing leave *_cost_usd as null with a warning rather than fabricate zero.
POST /v1/tenants/{tid}/executions/{exec_id}/cancel → 202 Accepted
Cancellation is cooperative — the endpoint flags metrics.cancel_requested=true and the running pipeline polls between stages (parse → chunk → between embed batches → before indexing). When it sees the flag, it raises ExecutionCancelled, which the top-level handler translates to status="failed" with error_msg="cancelled by request". No partial chunks are committed.
| Code | When |
|---|---|
| 202 | Cancel requested; pipeline will observe at the next stage boundary |
| 409 | Execution is already succeeded or failed |
| 404 | Execution not found for this tenant |
Retrieval cancellation is best-effort. Most retrieval calls complete in well under a second, so the cancel flag is usually flipped after the run has already returned.
Fire the same query against two retrieval-configs in parallel and get both result sets back with per-leg latency + agreement metrics. Drives a side-by-side compare UI.
POST /v1/tenants/{tenant_id}/playground/compare
{
"query": "What are the key risks in the contract?",
"retrieval_config_id_a": "<uuid>",
"retrieval_config_id_b": "<uuid>",
"top_k_for_metrics": 10 // optional; default 10
}Response shape:
{
"query": "...",
"a": {"retrieval_config_id":"...","latency_ms":245,"group_by":"chunk","results":[...],"total":5},
"b": {"retrieval_config_id":"...","latency_ms":312,"group_by":"chunk","results":[...],"total":5},
"comparison": {
"top_k_for_metrics": 10,
"chunk_overlap_jaccard": 0.40,
"document_overlap_jaccard": 0.75,
"score_concordance_spearman": 0.62
},
"warnings": []
}The three comparison metrics:
| Metric | What it measures |
|---|---|
chunk_overlap_jaccard |
Top-K chunk IDs that appear in BOTH configs' results / union of both. |
document_overlap_jaccard |
Same, but keyed by document_id — useful when the two configs use different chunking. |
score_concordance_spearman |
Rank correlation of scores on the intersection of A's and B's top-K. null when < 2 chunks overlap. |
Both legs run via the existing run_retrieval, so each gets its own Execution row written by the tracker — full audit trail for free. Sending the same id for a and b is allowed but emits a warnings entry (metrics will trivially be 1.0 / 1.0 / 1.0).
POST /v1/tenants/{tenant_id}/retrieval-configs/{retrieval_config_id}/chat
Runs the retrieval pipeline, calls an OpenAI mini model with the retrieved chunks as context, and returns the answer plus citations with freshly-generated presigned URLs to each source document. URLs aren't stored — every call regenerates them with the requested TTL.
POST /v1/tenants/{tid}/retrieval-configs/{rc_id}/chat
{
"query": "What are the key risks in the contract?",
"llm_provider": "openai", // fixed for v1
"llm_model": "gpt-4o-mini", // any LiteLLM-supported openai chat model
"top_k_for_context": 5, // 1–20; chunks fed to the LLM
"max_tokens": 2048, // 1–8192; hard cap on LLM output (default 2048)
"presign_expires_in": 3600 // 60–86400 seconds
}
200 OK →
{
"query": "...",
"answer": "Per [1] clause 14, liability is capped at...",
"citations": [
{
"document_id": "<uuid>",
"filename": "contract.pdf",
"download_url": "https://...?X-Amz-Signature=...",
"expires_at": "2026-05-17T13:45:00Z",
"score": 0.91,
"chunk_ids": ["<chunk-uuid>", "..."]
}
],
"retrieval_config_id": "<uuid>",
"llm_provider": "openai",
"llm_model": "gpt-4o-mini",
"latency_ms": 1840,
"tokens": {"input": 1240, "output": 187, "total": 1427},
"uncited": false
}Citation behavior:
- One citation per unique
document_idcited (chunks from the same doc roll up to a single citation with all contributingchunk_ids). [N]markers inanswerindex unique documents, never chunks — multiple chunks from the same source document share one[N].- Marker hygiene — the API sanitises the LLM output before returning: hallucinated or out-of-range
[N]markers (e.g.[5]when only 2 unique docs are in context) are stripped from the answer; surviving valid markers are kept verbatim with no renumbering.citationsis then filtered to only the documents whose marker actually appears in the cleaned answer. - No-citation fallback — when the model emits zero valid markers (it answered without grounding in any single source),
uncitedis set totrueandcitationsreturns every retrieved document so the UI can still display what was looked at. Distinguish "model cited these N sources" from "model didn't cite anything" via theuncitedflag. download_urlis generated on demand: managed docs presign against our MinIO/S3 backing; linked docs presign against the customer bucket using the stored s3-config creds. Presigning runs in parallel viaasyncio.gatherso adding more citations doesn't multiply round-trips.- Citation order follows the order docs first appeared in the retrieval results.
Status codes: 200 success, 404 retrieval-config not found, 422 missing OpenAI API key, 502 LLM provider error.
The OpenAI API key is resolved via the existing POST /api-keys flow (with provider="openai").
Same retrieval semantics as /chat, but the answer streams as Server-Sent Events. Time-to-first-token drops from "wait for full LLM response" (~7-8s on a 200-token answer) to retrieval-latency + ~600ms (~2.5-3s); total wall time is roughly the same as /chat. Use this from any UI that wants to paint tokens as they arrive.
Request body is identical to /chat (same ChatRequest schema — max_tokens, top_k_for_context, presign_expires_in, etc).
Wire format: Content-Type: text/event-stream, frames separated by blank lines.
event: meta
data: {"retrieval_latency_ms": 150, "context_chunks": 5,
"doc_order": ["uuid", "..."]}
event: token
data: {"text": "The "}
event: token
data: {"text": "answer "}
...
event: final
data: {"answer": "<sanitised>", "citations": [...], "uncited": false,
"tokens": {"input": 272, "output": 37, "total": 309},
"latency_ms": 3093}
metafires the moment retrieval finishes (so the UI can paint "found N sources" before the LLM speaks).doc_orderlists the unique document IDs in[N]order.tokencarries the next text delta. Stream is raw text — may include hallucinated[N]markers. UI choices: show streamed tokens live for snappiness OR buffer-and-display only the sanitisedanswerfromfinal. Same marker hygiene + citation filtering as the JSON endpoint runs on the buffered text at end.finalcarries{answer, citations, uncited, tokens, latency_ms}once at the end. Citation presigning runs in parallel with the token stream sofinalarrives ~immediately after the last token.errormay fire (with{detail: ...}) if the LLM call blows up mid-stream; the HTTP status is already 200 by that point so callers must handle it on the event channel.- Validation errors (404, 422 missing key, schema 422) are returned as a normal HTTP error response before the stream starts — they never appear as SSE events.
| Strategy | Key | Description |
|---|---|---|
| Basic / Token | basic (alias token) |
Fixed token-window split using tiktoken (cl100k_base). Fast, no dependencies. |
| Recursive | recursive |
Splits on a hierarchy of separators (\n\n → \n → → ""). Good general-purpose default. |
| Character | character |
Splits by raw character count. Ignores token boundaries. |
| Pages | pages |
One chunk per parsed page. Best for PDFs where page context matters. Won NVIDIA's chunking benchmark. |
| Semantic | semantic |
Splits at topic boundaries via percentile-based embedding similarity drops (95th percentile by default). Embeds every sentence — slower but boundary-aware. |
| Contextual | contextual |
Recursive split + per-chunk situating context generated by Claude (Anthropic) with prompt caching. Highest retrieval quality, costs ~$3 per 1k chunks with Haiku. Requires anthropic API key. Auto-splits oversized docs at paragraph boundary. |
| Hierarchical | hierarchical |
Creates small child chunks for retrieval and stores the parent chunk in metadata for wider context. |
Each strategy declares only its own parameters. Sending a field that belongs to another strategy returns 422 Unprocessable Entity with extra_forbidden — there's no shared chunk_size / chunk_overlap across all strategies. Match the param names below exactly.
strategy |
Required / optional fields | Defaults |
|---|---|---|
basic |
token_size (int), token_overlap (int) |
512, 64 |
token |
token_size (int), token_overlap (int) — alias of basic |
512, 64 |
recursive |
separators (list[str]) |
["\n\n", "\n", " ", ""] |
character |
chunk_size (int), chunk_overlap (int) |
512, 64 |
pages |
(no params) | — |
semantic |
breakpoint_percentile (int), min_chunk_size (int), max_chunk_size (int) |
95, 100, 1500 |
hierarchical |
parent_size (int), chunk_size (int), chunk_overlap (int) |
2048, 512, 64 |
contextual |
character_size (int), character_overlap (int), llm_provider (openai | anthropic), llm_model (str), prompt_template (object) |
512, 64, anthropic, claude-haiku-4-5, default template |
For contextual, the prompt_template object accepts:
| Field | Type | Required | Notes |
|---|---|---|---|
template |
"default" | "custom" |
yes | Built-in or user-supplied. |
template_name |
string | no | Free-form label, metadata only. |
prompt |
string | required iff template = "custom" |
Used in place of the built-in prompt. Should include the {chunk_content} placeholder — that's where the chunk text gets substituted. |
Examples — minimal valid bodies:
// basic / token
{"strategy": "basic", "token_size": 512, "token_overlap": 64}
// recursive
{"strategy": "recursive", "separators": ["\n\n", "\n"]}
// character
{"strategy": "character", "chunk_size": 512, "chunk_overlap": 64}
// hierarchical
{"strategy": "hierarchical", "parent_size": 2048, "chunk_size": 512, "chunk_overlap": 64}
// pages
{"strategy": "pages"}
// semantic
{"strategy": "semantic", "breakpoint_percentile": 95, "min_chunk_size": 100, "max_chunk_size": 1500}
// contextual (default prompt)
{"strategy": "contextual", "character_size": 512, "character_overlap": 64,
"llm_provider": "anthropic", "llm_model": "claude-haiku-4-5"}
// contextual (custom prompt)
{"strategy": "contextual", "character_size": 512, "character_overlap": 64,
"llm_provider": "anthropic", "llm_model": "claude-haiku-4-5",
"prompt_template": {"template": "custom", "template_name": "mine",
"prompt": "Situate <chunk>{chunk_content}</chunk> in the document."}}Common pitfall. Sending chunk_size / chunk_overlap with strategy: "basic" (or recursive, or semantic) will 422 — those keys belong to character and hierarchical. Use token_size / token_overlap for basic, or change the strategy to character.
| Provider | Key | Models | Dimensions |
|---|---|---|---|
| OpenAI | openai |
text-embedding-3-small, text-embedding-3-large |
1536, 3072 |
google |
gemini-embedding-001 |
3072 | |
| Voyage AI | voyage |
voyage-3, voyage-3-large, voyage-4, voyage-4-lite |
512–2048 |
| Zero Entropy | zeroentropy |
zembed-1 |
40–2560 (Matryoshka) |
| HuggingFace | huggingface |
Any TEI-compatible model | Varies |
Note:
QDRANT_VECTOR_SIZEsets the dimension of the default Qdrant collection (kept at the bareQDRANT_COLLECTIONname for backwards compatibility). Switching to a different embedding dimension automatically routes upserts and queries to a{base}_{dim}collection that's created lazily — no manual collection management needed. Thedocuments.embedding_dimensionscolumn records each doc's collection so DELETE routes correctly.
| Provider | Key | Models |
|---|---|---|
| Cohere | cohere |
rerank-v3.5, rerank-v3 |
| Voyage AI | voyage |
rerank-2.5, rerank-2.5-lite |
| Zero Entropy | zeroentropy |
zerank-2, zerank-2-nano |
| HuggingFace | huggingface |
Any self-hosted cross-encoder |
| Strategy | LLM calls | Description |
|---|---|---|
none |
0 | Pass original query directly to search |
rewrite |
1 | Rewrite query for better retrieval specificity |
expand |
N (num_llm_calls) |
Generate N diverse query variants, search all |
hyde |
1 | Generate a hypothetical document, embed it as the query vector |
Eight tables: the original five (tenants, documents, chunks, ingestion_configs, retrieval_configs, api_keys) plus platform_api_keys (shared cross-tenant provider defaults), executions (pipeline run log), and tenant_auth_keys (bearer tokens for the auth layer).
| # | Change | Migration | Reason |
|---|---|---|---|
| 1 | New table executions + ENUMs pipeline_type, execution_status |
a7b8c9d0e1f2_add_executions |
Every ingestion + retrieval run is now logged with timings + metrics (chunks, tokens, cost, *_ms phases). Drives the cost_summary roll-up on ingestion configs and the GET /executions endpoints. |
| 2 | New table tenant_auth_keys |
b8c9d0e1f2a3_add_tenant_auth_keys |
Bearer-token auth for every /v1/tenants/{tid}/... route. Separate from api_keys (which holds provider creds like OpenAI). Raw keys are SHA-256 hashed; the key_prefix column is the lookup handle. |
| 3 | provider_type enum gains aws_s3 |
c9d0e1f2a3b4_add_aws_s3_provider |
Lets tenants store AWS S3 credentials under api_keys so the new POST /documents/from-s3 endpoint can pull objects from a customer bucket. |
| 4 | New table s3_configs |
d0e1f2a3b4c5_add_s3_configs |
First-class tenant-scoped AWS S3 credentials with default_bucket / default_prefix. Replaces the legacy api_keys.aws_s3 JSON blob for new clients (legacy still works). |
| 5 | New table source_presets |
e1f2a3b4c5d6_add_source_presets |
Saved named S3 source bundles (s3_config_id + uri + kind OR presigned URLs). Drives the UI's "pick a saved source" dropdown. last_used_at updates on each /run-from-s3 call that resolves a preset. |
| 6 | tenants.email nullable column + unique constraint |
f2a3b4c5d6e7_add_tenants_email |
Powers self-service signups via POST /v1/signups. Existing admin-created tenants keep email IS NULL. |
| 7 | Hosted-DB compat: convert api_keys.provider enum from legacy api_provider to provider_type |
a3b4c5d6e7f8_fix_api_keys_provider_enum |
Some hosted Postgres instances bootstrapped with the wrong enum name; reads against api_keys 500'd with operator does not exist: api_provider = provider_type. Idempotent — no-op on databases already using provider_type. |
| 8 | documents.linked_s3_config_id nullable FK → s3_configs.id ON DELETE SET NULL |
0a3b4c5d6e7f_add_documents_linked_s3_config |
Tags documents that reference the customer bucket directly (linked mode — see Storage modes). NULL on managed copies (existing rows). |
All migrations are purely additive — no rewrites of existing rows. The a3b4c5d6e7f8 enum-fix migration is the only one with conditional logic; it inspects the current column type and only transforms it if the legacy name is present.
┌────────────────────────────────────────────────────────────────────────────────────┐
│ PostgreSQL │
│ │
│ ┌──────────────┐ │
│ │ tenants │ │
│ ├──────────────┤ │
│ │ id UUID │◄───────┬────────┬────────┬────────┬────────┬────────┬────────┐ │
│ │ name VARCHAR │ │ │ │ │ │ │ │ │
│ │ created_at │ │ │ │ │ │ │ │ │
│ └──────────────┘ │ │ │ │ │ │ │ │
│ │ * (FK tenant_id on every child, ON DELETE CASCADE) │ │
│ ▼ ▼ ▼ ▼ ▼ ▼ ▼ │
│ ┌──────────────┐ ┌──────────┐ ┌──────────────┐ ┌─────────┐ ┌──────────┐ ┌─────┐│
│ │ documents │ │ chunks │ │ingestion_cfgs│ │ api_keys│ │executions│ │auth ││
│ ├──────────────┤ ├──────────┤ ├──────────────┤ ├─────────┤ ├──────────┤ │keys ││
│ │ id │ │ id │ │ id │ │ id │ │ id │ ├─────┤│
│ │ tenant_id │ │ doc_id ├──┤ tenant_id │ │tenant_id│ │tenant_id │ │id ││
│ │ ic_id ───────┼──┘ │ │ name │ │provider │ │pipeline_ │ │tid ││
│ │ filename │ │tenant_id │ │ config JSONB │ │ name │ │ type │ │name ││
│ │ file_key │ │ content │ │ created_at │ │encrypt_ │ │status │ │pre- ││
│ │ mime_type │ │ chunk_ix │ │ updated_at │ │ key │ │doc_id ──►│ │fix ││
│ │ status │ │ metadata │ └──────┬───────┘ │created │ │ic_id ──►│ │hash ││
│ │ error_msg │ │created_at│ │ ▲ └─────────┘ │rc_id ──►│ │exp/ ││
│ │ metadata │ └──────────┘ │ │ FK ic_id │started_at│ │rev/ ││
│ │ embed_dim │ ▲ │ │ ON DELETE RESTRICT │finished │ │used ││
│ │ created_at │ │ FK doc_id │ │ │duration │ │_at ││
│ │ updated_at │ │ id = qdrant pt│ │ │error_msg │ │crea-││
│ └─────┬────────┘ │ │ │metrics │ │ted ││
│ ▲ │ │ │ JSONB │ └─────┘│
│ │ FK doc_id ┌────────────┴─┴──┐ │created │ │
│ │ ON DELETE CASCADE │ retrieval_configs│ │updated │ │
│ │ ├──────────────────┤ └──────────┘ │
│ │ │ id UUID │ ▲ FK ic_id, rc_id │
│ │ │ tenant_id │ │ ON DELETE SET NULL │
│ │ │ ic_id ───────────┼──────┘ │
│ │ │ name │ (executions keep history when │
│ │ │ config JSONB │ the config they reference is │
│ │ │ created_at │ deleted) │
│ │ │ updated_at │ │
│ │ └──────────────────┘ │
│ │ │
│ └──────────── FK doc_id (executions.document_id) ON DELETE CASCADE │
│ │
│ platform_api_keys: cross-tenant provider defaults (no tenant FK) │
│ - (provider, name) unique; consulted when get_api_key finds no tenant row. │
└────────────────────────────────────────────────────────────────────────────────────────┘
documents.ingestion_config_id links each doc to the ingestion config that processed it (ON DELETE RESTRICT — can't delete a config that still has docs). executions.ingestion_config_id and executions.retrieval_config_id use ON DELETE SET NULL so deleting a config preserves its run history.
| Column | Type | Constraints | Notes |
|---|---|---|---|
id |
UUID |
PRIMARY KEY, default gen_random_uuid() |
|
name |
VARCHAR(255) |
NOT NULL |
Human-readable tenant label |
created_at |
TIMESTAMPTZ |
NOT NULL, default now() |
| Column | Type | Constraints | Notes |
|---|---|---|---|
id |
UUID |
PRIMARY KEY, default gen_random_uuid() |
|
tenant_id |
UUID |
NOT NULL, FK → tenants.id ON DELETE CASCADE |
Scopes document to a tenant |
ingestion_config_id |
UUID |
nullable, FK → ingestion_configs.id ON DELETE RESTRICT |
The ingestion config used to process this document. RESTRICT prevents deleting a config that still has docs attached. NULL on legacy rows. |
filename |
VARCHAR(512) |
NOT NULL |
Original upload filename |
file_key |
VARCHAR(1024) |
NOT NULL |
MinIO object path: {tenant_id}/{uuid}/{filename} |
mime_type |
VARCHAR(128) |
NOT NULL |
e.g. application/pdf |
status |
ENUM |
NOT NULL, default pending |
pending → processing → indexed | failed |
error_msg |
TEXT |
nullable | Populated on failed; contains exception message |
metadata |
JSONB |
NOT NULL, default {} |
User-supplied key-value pairs attached at upload time |
embedding_dimensions |
INTEGER |
nullable | Set at ingest from the embedding config; used by DELETE to route to the correct per-dim Qdrant collection. NULL on legacy rows ingested before the per-dim refactor. |
created_at |
TIMESTAMPTZ |
NOT NULL, default now() |
|
updated_at |
TIMESTAMPTZ |
NOT NULL, default now(), auto-updated |
Indexes: ix_documents_tenant_id on (tenant_id)
| Column | Type | Constraints | Notes |
|---|---|---|---|
id |
UUID |
PRIMARY KEY, default gen_random_uuid() |
Also used as the Qdrant point ID — the link between PG and the vector store |
document_id |
UUID |
NOT NULL, FK → documents.id ON DELETE CASCADE |
Parent document |
tenant_id |
UUID |
NOT NULL, FK → tenants.id ON DELETE CASCADE |
Denormalised for fast tenant-scoped queries |
content |
TEXT |
NOT NULL |
Raw chunk text as indexed |
chunk_index |
INTEGER |
NOT NULL |
0-based position within the source document |
metadata |
JSONB |
NOT NULL, default {} |
Page number, source filename, parent chunk ref (hierarchical), strategy tag, etc. |
created_at |
TIMESTAMPTZ |
NOT NULL, default now() |
Indexes: ix_chunks_tenant_id on (tenant_id), ix_chunks_document_id on (document_id)
Pipeline configs were split into two tables: ingestion_configs (chunking + embedding, used at index time) and retrieval_configs (search blend + reranker + top_k, used at query time).
| Column | Type | Constraints | Notes |
|---|---|---|---|
id |
UUID |
PRIMARY KEY, default gen_random_uuid() |
|
tenant_id |
UUID |
NOT NULL, FK → tenants.id ON DELETE CASCADE |
|
name |
VARCHAR(255) |
NOT NULL |
Human label, e.g. "Recursive 512 + ZE zembed-1" |
config |
JSONB |
nullable | {chunking, embedding} blob, null for empty slots |
created_at |
TIMESTAMPTZ |
NOT NULL, default now() |
|
updated_at |
TIMESTAMPTZ |
NOT NULL, default now(), auto-updated |
Indexes: ix_ingestion_configs_tenant_id on (tenant_id)
| Column | Type | Constraints | Notes |
|---|---|---|---|
id |
UUID |
PRIMARY KEY, default gen_random_uuid() |
|
tenant_id |
UUID |
NOT NULL, FK → tenants.id ON DELETE CASCADE |
|
name |
VARCHAR(255) |
NOT NULL |
|
config |
JSONB |
nullable | {query_transformation, vector_search, keyword_search, fusion, deduplication, reranker, top_k, document_ids, metadata_filters, group_by, chunks_per_doc} blob, null for empty slots |
created_at |
TIMESTAMPTZ |
NOT NULL, default now() |
|
updated_at |
TIMESTAMPTZ |
NOT NULL, default now(), auto-updated |
Indexes: ix_retrieval_configs_tenant_id on (tenant_id)
Ingestion-config link is stored in the retrieval_config_ingestion_configs join table (below), not as a column on this row — a retrieval config can search across one or more ingestion configs.
Many-to-many join between retrieval configs and the ingestion configs they search across.
| Column | Type | Constraints | Notes |
|---|---|---|---|
retrieval_config_id |
UUID |
PRIMARY KEY (part 1), FK → retrieval_configs.id ON DELETE CASCADE |
Deleting a retrieval config drops its links. |
ingestion_config_id |
UUID |
PRIMARY KEY (part 2), FK → ingestion_configs.id ON DELETE RESTRICT |
Cannot delete an ingestion config that any retrieval config still references. |
created_at |
TIMESTAMPTZ |
NOT NULL, default now() |
When this link was added; on PUT-replace the row's created_at becomes the time of the replace. |
Indexes: composite PK on (retrieval_config_id, ingestion_config_id), plus ix_rcic_ingestion_config_id on (ingestion_config_id) for the reverse lookup.
Embedding compatibility (same provider + model + dimensions) across all rows for a given retrieval_config_id is enforced at the API layer on POST/PUT, NOT by a database constraint.
| Column | Type | Constraints | Notes |
|---|---|---|---|
id |
UUID |
PRIMARY KEY, default gen_random_uuid() |
|
tenant_id |
UUID |
NOT NULL, FK → tenants.id ON DELETE CASCADE |
|
provider |
ENUM |
NOT NULL |
openai | anthropic | google | voyage | zeroentropy | cohere | huggingface | aws_s3 |
name |
VARCHAR(255) |
NOT NULL, default 'api_key' |
Label for the key — multiple keys per provider are allowed when names differ |
encrypted_key |
TEXT |
NOT NULL |
AES-256 Fernet-encrypted API key. For aws_s3, this is a JSON string {access_key_id, secret_access_key, region}. |
created_at |
TIMESTAMPTZ |
NOT NULL, default now() |
Unique constraint: (tenant_id, provider, name) — upsert behaviour: submitting the same (provider, name) pair overwrites the existing key.
| Column | Type | Constraints | Notes |
|---|---|---|---|
id |
UUID |
PRIMARY KEY, default gen_random_uuid() |
|
tenant_id |
UUID |
NOT NULL, FK → tenants.id ON DELETE CASCADE |
|
pipeline_type |
ENUM |
NOT NULL |
ingestion | retrieval |
status |
ENUM |
NOT NULL, default running |
running → succeeded | failed |
document_id |
UUID |
nullable, FK → documents.id ON DELETE CASCADE |
Populated for ingestion rows; null for retrieval. |
ingestion_config_id |
UUID |
nullable, FK → ingestion_configs.id ON DELETE SET NULL |
Preserves history when a config is deleted. |
retrieval_config_id |
UUID |
nullable, FK → retrieval_configs.id ON DELETE SET NULL |
|
started_at |
TIMESTAMPTZ |
NOT NULL, default now() |
|
finished_at |
TIMESTAMPTZ |
nullable | Populated when the row transitions out of running. |
duration_ms |
INTEGER |
nullable | Wall-clock between started_at and finished_at. |
error_msg |
TEXT |
nullable | Populated on failed. |
metrics |
JSONB |
NOT NULL, default {} |
Free-form: chunks_count, embed_tokens, *_cost_usd, *_ms timings, etc. |
created_at, updated_at |
TIMESTAMPTZ |
NOT NULL |
Indexes: (tenant_id, pipeline_type, started_at DESC), (document_id), (retrieval_config_id, started_at DESC).
| Column | Type | Constraints | Notes |
|---|---|---|---|
id |
UUID |
PRIMARY KEY, default gen_random_uuid() |
|
tenant_id |
UUID |
NOT NULL, FK → tenants.id ON DELETE CASCADE |
|
name |
VARCHAR(128) |
NOT NULL |
Human label, e.g. "production", "ci-runner". |
key_prefix |
VARCHAR(32) |
NOT NULL |
Visible portion of the key for lookup, e.g. wec_pk_a1b2c3d4. |
key_hash |
VARCHAR(256) |
NOT NULL |
SHA-256 hash of the full raw key; compared via hmac.compare_digest. |
expires_at |
TIMESTAMPTZ |
nullable | Optional expiry. |
revoked_at |
TIMESTAMPTZ |
nullable | Set by DELETE /auth-keys/{key_id}. |
last_used_at |
TIMESTAMPTZ |
nullable | Updated on successful auth. |
created_at |
TIMESTAMPTZ |
NOT NULL, default now() |
Unique constraint: (tenant_id, name). Indexes: (key_prefix), (tenant_id, revoked_at).
Raw key format: wec_pk_{8-char prefix}_{32-char secret}. The raw value is returned only once at creation time.
| Relationship | Type | Cascade |
|---|---|---|
tenants → documents |
one-to-many | delete tenant → delete all documents |
tenants → chunks |
one-to-many (denormalised) | delete tenant → delete all chunks |
tenants → ingestion_configs |
one-to-many | delete tenant → delete all ingestion configs |
tenants → retrieval_configs |
one-to-many | delete tenant → delete all retrieval configs |
ingestion_configs ↔ retrieval_configs |
many-to-many via retrieval_config_ingestion_configs |
CASCADE on the retrieval side; RESTRICT on the ingestion side — cannot delete an ingestion config that any retrieval config still links to |
ingestion_configs → documents |
one-to-many | RESTRICT — cannot delete an ingestion config that still has docs |
tenants → api_keys |
one-to-many | delete tenant → delete all keys |
tenants → executions |
one-to-many | delete tenant → delete all rows |
tenants → tenant_auth_keys |
one-to-many | delete tenant → delete all auth keys |
documents → chunks |
one-to-many | delete document → delete all its chunks |
documents → executions |
one-to-many | delete document → delete all its execution rows |
ingestion_configs → executions |
one-to-many | SET NULL — delete config → executions keep history |
retrieval_configs → executions |
one-to-many | SET NULL |
Deleting a document via DELETE /v1/tenants/{id}/documents/{doc_id} also removes the corresponding points from Qdrant and documents from the OpenSearch index before the PostgreSQL row is deleted.
# All tests
pytest
# With verbose output
pytest -v
# Specific file
pytest tests/test_rrf.py -v
# Only unit tests (fast, no external services needed)
pytest tests/test_chunking.py tests/test_embeddings.py tests/test_rrf.py -vThe test suite uses mocks for all external services (Qdrant, OpenSearch, OpenAI, etc.) — no running infrastructure needed for unit tests.
wec_rag_studio/
├── app/
│ ├── main.py # FastAPI app entry point
│ ├── core/
│ │ ├── config.py # pydantic-settings (env vars)
│ │ ├── database.py # SQLAlchemy async engine + session
│ │ └── deps.py # FastAPI DI helpers
│ ├── models/ # SQLAlchemy ORM models
│ ├── schemas/ # Pydantic v2 request/response models
│ ├── api/v1/ # FastAPI routers
│ │ ├── tenants.py
│ │ ├── signups.py # POST /v1/signups (open) — email + first key
│ │ ├── documents.py # legacy /documents/from-s3 + browse/get/delete
│ │ ├── run.py # POST /run (multipart) + /run-from-s3 (json)
│ │ ├── retrieval.py
│ │ ├── chat.py # POST /chat — retrieval + LLM + citations; POST /chat/stream — SSE variant
│ │ ├── playground.py # POST /playground/compare — A/B retrieval
│ │ ├── ingestion_configs.py # incl. /preview-cost endpoint
│ │ ├── retrieval_configs.py
│ │ ├── s3_configs.py # tenant-scoped AWS credentials
│ │ ├── source_presets.py # saved S3 source bundles (dropdown)
│ │ ├── providers.py # GET /v1/providers — catalog enriched live from LiteLLM
│ │ ├── platform.py # admin-only cross-tenant provider keys
│ │ ├── auth_keys.py # tenant bearer-token CRUD
│ │ └── executions.py # GET + POST /cancel
│ ├── modules/ # Modular, swappable components
│ │ ├── parser/ # LiteParse wrapper
│ │ ├── chunking/ # 7 chunking strategies (recursive, basic/token, character, pages, hierarchical, semantic, contextual)
│ │ ├── embeddings/ # 5 embedding providers (ZeroEntropy direct, others via LiteLLM)
│ │ ├── vdb/ # Qdrant — per-dim collections, lazy create
│ │ ├── keyword_search/ # OpenSearch BM25
│ │ ├── reranker/ # 4 reranker providers (ZeroEntropy direct, others via LiteLLM)
│ │ └── storage/ # MinIO / S3 object storage
│ ├── pipelines/
│ │ ├── ingestion.py # parse → chunk → embed → index
│ │ └── retrieval.py # transform → search → fuse → rerank
│ ├── core/
│ │ └── security.py # HTTPBearer + require_api_key / tenant_match / admin deps
│ └── services/
│ ├── rrf.py # Reciprocal Rank Fusion
│ ├── query_transformer.py # HyDE / rewrite / expand
│ ├── config_service.py # Load active configs from DB
│ ├── embedder_factory.py # Build embedder from config
│ ├── reranker_factory.py # Build reranker from config
│ ├── crypto.py # API key encrypt/decrypt
│ ├── auth_service.py # Mint / verify / revoke tenant API keys
│ ├── cost_estimator.py # LiteLLM-driven cost prediction + actuals
│ ├── execution_tracker.py # Writes the executions table + cancel poll
│ ├── retrieval_compare.py # Jaccard + Spearman metrics for /playground
│ ├── document_bytes.py # Managed-vs-linked fetch dispatch
│ ├── presign.py # Generate citation download URLs
│ ├── s3_config_service.py # s3-config lookups + cred decryption
│ ├── source_preset_service.py # source-preset lookups + validation
│ └── s3_source.py # Pull files from a customer S3 bucket
├── migrations/ # Alembic async migrations
├── tests/ # ~205 tests, mocked infra
├── docker-compose.yml # local-bundled: postgres + qdrant + opensearch + minio + app
├── docker-compose.hosted.yml # hosted: app-only, talks to external infra
├── Dockerfile # Python 3.11 + Node 20 + LiteParse CLI
├── entrypoint.sh # `alembic upgrade head` then uvicorn
├── pyproject.toml
└── .env.example
- Create
app/modules/embeddings/my_provider.pyimplementingBaseEmbedder - Add it to
app/modules/embeddings/__init__.py - Add a branch in
app/services/embedder_factory.py - Add the provider name to the
ProviderTypeenum inapp/models/api_key.py - Store the API key via
POST /v1/tenants/{id}/api-keys
The pattern is identical for rerankers (BaseReranker → reranker_factory.py).
- Create
app/modules/chunking/my_strategy.pyimplementingBaseChunker - Add it to
STRATEGY_MAPinapp/modules/chunking/__init__.py - Reference it by key in a
ChunkingConfigJSON blob
Private — WEC internal use.