A uv-managed Python 3.12 monorepo for a small, opinionated RAG stack:
vectorizer/— FastAPI pod that pulls PDFs from api.govinfo.gov, extracts text with PyMuPDF, splits with LangChain, embeds with AWS Bedrock Titan Text Embeddings v2 (1024-dim), and upserts into a vanillapostgres:17 + apt postgresql-17-pgvectordatabase.question-api/— FastAPI pod that answers strictly-grounded questions: it embeds the incoming question with Titan v2, retrieves top-k chunks from pgvector with a metadata filter and similarity threshold, then asks a switchable LLM (Anthropic Claude Sonnet 4.5 by default, or a host-resident Ollama on the Docker Desktop laptop) to answer using only that context.libs/rag-core/— sharedProtocolports, the grounded system prompt, the chunk splitter, settings, and structlog config (DRY for both apps without coupling their deployments).infra/— custompostgres-pgvectorDockerfile and an umbrella Helm chart that deploys all three pods (postgres,vectorizer,question-api) into Docker Desktop's built-in Kubernetes.
flowchart LR
subgraph Laptop["Laptop (Docker Desktop host)"]
User[curl / client]
HostOllama["Ollama (host process, :11434)"]
end
User -->|"POST localhost:8001/ingest"| PFv[kubectl port-forward 8001 -> svc/vectorizer:8000]
User -->|"POST localhost:8002/ask"| PFq[kubectl port-forward 8002 -> svc/question-api:8000]
subgraph Cluster["Helm release: rag-pgvector"]
PFv --> Vec[vectorizer Pod]
PFq --> QA[question-api Pod]
Vec -->|UPSERT chunks| PG[("postgres:17 + pgvector Pod")]
QA -->|cosine search top-k| PG
end
Vec -->|HTTPS| Gov[api.govinfo.gov/packages]
Vec -->|Titan v2 Embed| Bedrock[(AWS Bedrock)]
QA -->|Titan v2 Embed| Bedrock
QA -->|Grounded prompt| LLM{LLM_PROVIDER}
LLM -->|anthropic| Anthropic[Anthropic Claude Sonnet 4.5]
LLM -->|"ollama via host.docker.internal:11434"| HostOllama
The two pods are independently deployable microservices. The vectorizer is the write side (ingest + embed + upsert); the question-api is the read side (embed + retrieve + ground + generate). They share nothing at runtime — only the pgvector table and the Protocol ports in libs/rag-core.
flowchart TD
Client["curl / client"] -->|"POST /ingest IngestRequest"| Route["FastAPI route<br/>vectorizer/api/routes.py"]
Route --> Svc["VectorizeService.ingest<br/>vectorizer/domain/vectorize_service.py"]
Svc -->|"list_packages collection, dates, pageSize"| Gov["GovInfoClient<br/>vectorizer/adapters/govinfo_client.py"]
Gov -->|"HTTPS GET /collections/{c}"| GovApi[("api.govinfo.gov")]
GovApi -->|"packages[] + nextPage"| Gov
Gov -->|"async yield package summaries"| Svc
Svc -->|"per packageId<br/>download_pdf"| Gov
Gov -->|"GET /packages/{id}/summary + /pdf"| GovApi
GovApi -->|"PDF bytes + title metadata"| Gov
Gov -->|"PdfDocument bytes, title, sourceUrl"| Svc
Svc -->|"loader.load PdfDocument"| Pdf["PyMuPdfLoader<br/>vectorizer/adapters/pdf_loader.py"]
Pdf -->|"fitz.open stream<br/>page.get_text"| Pdf
Pdf -->|"one Chunk per non-empty page<br/>pageNumber, totalPages"| Svc
Svc -->|"splitter.split_text per page"| Split["chonkie RecursiveChunker<br/>rag_core.chunking<br/>legislative rules, chunk_size=1600"]
Split -->|"text splits"| Svc
Svc -->|"build Chunk with<br/>uuidv5 packageId|page|chunkIndex<br/>+ enriched metadata"| Svc
Svc -->|"store.add_chunks chunks"| Store["PgVectorStoreAdapter<br/>vectorizer/adapters/pgvector_store.py"]
Store -->|"aadd_documents docs, ids"| Lc["langchain_postgres<br/>PGVectorStore"]
Lc -->|"embed_documents texts"| Titan["TitanEmbeddingsAdapter<br/>vectorizer/adapters/titan_embeddings.py"]
Titan -->|"Bedrock InvokeModel<br/>amazon.titan-embed-text-v2:0 1024-dim"| Bedrock[("AWS Bedrock")]
Bedrock -->|"vectors"| Titan
Titan -->|"list of 1024-dim vectors"| Lc
Lc -->|"UPSERT id, content, metadata, embedding"| PG[("postgres:17 + pgvector<br/>table = DATABASE_COLLECTION")]
PG -->|"rows written"| Store
Store -->|"written count"| Svc
Svc -->|"IngestResponse<br/>ingested, packages, skipped"| Route
Route -->|"200 OK JSON"| Client
Notes:
VectorizeServiceis pure orchestration — every IO call goes through a port adapter (GovInfoClientPort,DocumentLoaderPort,VectorStorePort).- Chunk IDs are deterministic UUIDv5 of
packageId|pageNumber|chunkIndexunder a fixed namespace, so re-ingesting the same package upserts in place (theidcolumn in pgvector is typedUUID, so the id has to be a real UUID — not a hex digest). - Embedding is not called directly by
VectorizeService;PGVectorStorecallsTitanEmbeddingsAdapter.embed_documentsinternally duringaadd_documents.
flowchart TD
Client["curl / client"] -->|"POST /ask AskRequest<br/>question, metadata, topK, threshold"| Route["FastAPI route<br/>question_api/api/routes.py"]
Route --> Svc["AskService.ask<br/>question_api/domain/ask_service.py"]
Svc -->|"resolve top_k + threshold<br/>request > RetrievalSettings"| Svc
Svc -->|"store.similarity_search<br/>question, k, metadata_filter"| Retr["PgVectorRetrieverAdapter<br/>question_api/adapters/pgvector_retriever.py"]
Retr -->|"asimilarity_search_with_score"| Lc["langchain_postgres<br/>PGVectorStore"]
Lc -->|"embed_query question"| Titan["TitanEmbeddingsAdapter<br/>question_api/adapters/titan_embeddings.py"]
Titan -->|"Bedrock InvokeModel<br/>titan-embed-text-v2:0"| Bedrock[("AWS Bedrock")]
Bedrock -->|"1024-dim query vector"| Titan
Titan -->|"query vector"| Lc
Lc -->|"SELECT ... ORDER BY embedding <=> q<br/>LIMIT k WHERE metadata @> filter"| PG[("postgres:17 + pgvector")]
PG -->|"top-k rows + cosine distance"| Lc
Lc -->|"Document + score pairs"| Retr
Retr -->|"list of RetrievedChunk<br/>id, text, metadata, score"| Svc
Svc -->|"keep score <= threshold<br/>cosine distance: lower = closer"| Filter{"any kept?"}
Filter -- "no" --> Empty["AskResponse<br/>'I don't know based on the provided context.'<br/>citations=[], used_context_count=0"]
Empty --> Route
Filter -- "yes" --> Build["build_user_prompt<br/>rag_core/prompts.py<br/>numbered CONTEXT block:<br/>[i] packageId, sourceUrl, page + chunk text"]
Build -->|"SYSTEM_PROMPT_QA + user prompt"| LLM["LLMPort.generate<br/>system, user"]
LLM -->|"LLM_PROVIDER=anthropic"| Anthropic["AnthropicLLMAdapter<br/>ChatAnthropic claude-sonnet-4-5"]
LLM -->|"LLM_PROVIDER=ollama"| Ollama["OllamaLLMAdapter<br/>host.docker.internal:11434"]
Anthropic --> Ans["grounded answer text"]
Ollama --> Ans
Ans --> ToCit["_to_citations kept chunks<br/>packageId, sourceUrl, pageNumber,<br/>score, 280-char snippet"]
ToCit --> Resp["AskResponse<br/>answer, citations[],<br/>used_context_count, provider"]
Resp --> Route
Route -->|"200 OK JSON"| Client
Notes:
- The question is embedded by
TitanEmbeddingsAdapter.embed_queryinsidePGVectorStore.asimilarity_search_with_score—AskServicenever touches embeddings directly. - pgvector returns cosine distance, so
AskService._apply_thresholdkeeps chunks withscore <= threshold(lower = more similar). - If nothing survives the threshold, the service short-circuits with the fixed
"I don't know based on the provided context."reply and never calls the LLM (no spend, no hallucination surface). - Grounding happens in
rag_core.prompts.build_user_prompt: kept chunks become a numberedCONTEXT:block carryingpackageId,sourceUrl, andpageNumber, paired withSYSTEM_PROMPT_QAwhich forbids tool use and mandates a trailingSources:section. - The LLM is a
Protocolport — swapping Anthropic for Ollama is a singleLLM_PROVIDERenv flip wired inquestion_api/core/composition.py.
- macOS or Linux with Docker Desktop, with Kubernetes enabled:
Docker Desktop → Settings → Kubernetes → Enable Kubernetes → Apply & restart. The cluster appears inkubectl config get-contextsasdocker-desktop. uv≥ 0.5 (brew install uv)helm≥ 3.13 (brew install helm)kubectl(brew install kubectl)- (Optional)
ollamainstalled natively on the laptop if you want to useLLM_PROVIDER=ollama.
Docker Desktop's built-in Kubernetes shares the Docker daemon's image store, so locally-built images are visible to the cluster without any push or
kind loadstep. We do not use kind, minikube, or any external cluster tool.
Copy and edit .env:
cp .env.example .env
$EDITOR .env| Key | What it is | Required when |
|---|---|---|
AWS_BEARER_TOKEN_BEDROCK |
Bedrock API key (single bearer token) | aws.auth.mode=bearer (default) |
AWS_REGION |
Region the Bedrock key was generated in | always |
BEDROCK_EMBEDDING_MODEL_ID |
amazon.titan-embed-text-v2:0 |
always |
BEDROCK_EMBEDDING_DIMENSIONS |
1024 |
always |
ANTHROPIC_API_KEY |
Anthropic key for Claude Sonnet 4.5 | when LLM_PROVIDER=anthropic (default) |
ANTHROPIC_MODEL |
claude-sonnet-4-5 |
always |
LLM_PROVIDER |
anthropic (default) or ollama |
always |
OLLAMA_BASE_URL |
http://host.docker.internal:11434 |
when LLM_PROVIDER=ollama |
OLLAMA_MODEL |
e.g. llama3.1:8b |
when LLM_PROVIDER=ollama |
GOVINFO_API_KEY |
Free key from https://api.data.gov/signup/ | always |
DATABASE_URL |
psycopg3 URL for pgvector | always |
RETRIEVAL_TOP_K / RETRIEVAL_SCORE_THRESHOLD |
Defaults 5 / 0.25 |
optional |
Two modes are wired end-to-end through both the Python settings layer and the Helm chart's aws.auth.mode:
-
Bearer token (default) — generate a long-term Bedrock API key in the AWS console (
Amazon Bedrock → API keys → Generate long-term API key) with a minimum-permission policy, e.g.:{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": ["bedrock:InvokeModel"], "Resource": ["arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-embed-text-v2:0"] } ] }Make sure
amazon.titan-embed-text-v2:0is enabled underBedrock → Model access. Put the key inAWS_BEARER_TOKEN_BEDROCK.langchain-aws(>=0.2.28) reads this env var automatically — no boto3 session is needed. -
IRSA (real EKS only) — pass
--set aws.auth.mode=irsa --set aws.auth.irsaRoleArn=arn:aws:iam::...:role/.... The chart annotates the ServiceAccount witheks.amazonaws.com/role-arnand renders no AWS keys into the Secret.
Bedrock API keys are region-locked. The bearer-token path also pollutes the process env at startup (a known langchain-aws quirk, harmless for our single-tenant pods).
# 1. Verify Docker Desktop Kubernetes is enabled and build the three images
bash scripts/docker-desktop-up.sh
# 2. Install the Helm release (reads .env)
bash scripts/helm-install.sh
# 3. Forward all three services to the laptop
bash scripts/port-forward.shThen:
# Vectorize a govinfo collection (small sample)
curl -X POST http://localhost:8001/ingest \
-H 'content-type: application/json' \
-d '{"collection":"BILLS","lastModifiedStartDate":"2026-01-01T00:00:00Z","pageSize":25,"maxPackages":5}'
# Ask a grounded question
curl -X POST http://localhost:8002/ask \
-H 'content-type: application/json' \
-d '{"question":"What does the latest BILLS package say about appropriations?","metadata":{"collection":"BILLS"},"topK":5}'Once Helm is installed and bash scripts/port-forward.sh is up (see Local pod workflow), the two helper scripts in scripts/ cover the full ingest-then-ask demo without writing curl by hand.
./scripts/ingest-package.sh BILLS-115hr1625enrThis wraps POST http://localhost:8001/ingest/package. The vectorizer pod resolves the packageId against api.govinfo.gov/packages/{id}/summary + /pdf, splits each page with the shared chonkie.RecursiveChunker configured with legislative rules (SEC., TITLE, §, etc. as level-0 boundaries; chunk_size=1600 chars), embeds every split with Titan v2 (1024-dim), and upserts into rag_chunks with a deterministic UUIDv5 chunk id (so re-running the same packageId is idempotent).
Expected response shape:
{ "ingested": 3375, "packages": 1, "skipped": 0, "collection": "BILLS" }Verify the rows actually landed in pgvector:
kubectl -n rag exec rag-postgres-0 -- psql -U rag -d rag -At \
-c "select count(*), count(distinct metadata->>'packageId') as packages from rag_chunks;"The rag_chunks table is created with renamed/extended columns (overrides on
langchain-postgres's defaults):
| Column | Type | Source |
|---|---|---|
id |
uuid (PK) |
deterministic UUIDv5 of packageId|pageNumber|chunkIndex |
content |
text |
chunk text |
embedding |
vector(1024) |
Titan v2 embedding |
metadata |
jsonb |
full chunk metadata blob (packageId, pageNumber, sourceUrl, ...) |
embedding_model |
text |
hoisted from metadata, e.g. amazon.titan-embed-text-v2 |
embedding_model_version |
text |
hoisted from metadata, e.g. 0 |
The last two are hoisted metadata columns: PGVectorStore writes them from
Document.metadata[<name>] into typed columns on insert AND keeps them in the
JSON blob, so you can index/filter on them cheaply (WHERE embedding_model_version = '0') without paying for JSON extraction. The values
are stamped per-chunk by PgVectorStoreAdapter.add_chunks from the configured
BEDROCK_EMBEDDING_MODEL_ID (split on the trailing :<rev> suffix), so every
row is self-describing about which model produced its vector — handy when
swapping embedders or running A/B corpora in the same table.
Script knobs (override via env or positional arg):
| Var | Default | Purpose |
|---|---|---|
$1 / PACKAGE_ID |
(required) | govinfo packageId — e.g. BILLS-115hr1625enr, FR-2018-04-12, CREC-2018-01-03 |
VECTORIZER_URL |
http://localhost:8001 |
Base URL of the port-forwarded vectorizer |
METADATA |
{"source":"ingest-package.sh"} |
JSON merged into every chunk's metadata (handy for tagging ingest runs) |
./scripts/ask.shWith no arguments, this asks a built-in demo question that targets the bill loaded above:
What conditions does the Consolidated Appropriations Act place on U.S. assistance to the West Bank and Gaza, and what restrictions apply to the Palestinian Authority?
That question hits SEC. 1004 + Sec. 7038–7040 of BILLS-115hr1625enr, which is a self-contained multi-page policy framework, so the LLM has plenty of grounded context to write a structured, citable answer. You'll get back a multi-section response plus 6 citations, each with packageId, pageNumber, sourceUrl, cosine-distance score, and a ~220-char snippet.
Other invocation patterns:
./scripts/ask.sh "your custom question here" # custom question
QUESTION="..." ./scripts/ask.sh # via env var
TOP_K=10 SCORE_THRESHOLD=0.50 ./scripts/ask.sh "..." # tighter retrieval
METADATA='{"packageId":"BILLS-115hr1625enr"}' ./scripts/ask.sh # restrict to one bill
RAW=1 ./scripts/ask.sh ... # raw JSON (pipe to jq)Notes on the script-level retrieval defaults:
ask.shships withTOP_K=6andSCORE_THRESHOLD=0.80baked in as defaults so the no-arg invocation actually returns context. Over real bill text, Titan v2 cosine distances typically land in the 0.4–0.7 range, so the pod's stricterRETRIEVAL_SCORE_THRESHOLD=0.25default suppresses real matches for natural-language questions. To fall back to the pod-level defaults, delete those two lines inscripts/ask.sh(or override per call:TOP_K=5 SCORE_THRESHOLD=0.25 ./scripts/ask.sh ...).scoreThresholdis cosine distance (lower = stricter / more similar), not similarity. A chunk withscore=0.42is closer to the query than one withscore=0.71.- If no chunks clear the threshold,
AskServiceshort-circuits with"I don't know based on the provided context."and never calls the LLM — so a 0-citation reply costs no Anthropic / Ollama tokens.
# Just run the postgres+pgvector image via compose
docker compose up -d postgres
# Sync the workspace
uv sync --all-packages
# Run each service directly on the host
uv run uvicorn vectorizer.main:app --reload --port 8001
uv run uvicorn question_api.main:app --reload --port 8002The chart does not deploy Ollama. Instead, it expects Ollama to run on the Docker Desktop host (your laptop):
ollama serve & # if not already running
ollama pull llama3.1:8b
curl http://localhost:11434/api/tags # sanity checkThen re-deploy with LLM_PROVIDER=ollama in your .env and re-run bash scripts/helm-install.sh. The question-api pod calls http://host.docker.internal:11434 from inside the cluster.
Docker Desktop Kubernetes resolves host.docker.internal from inside pods automatically. If you ever switch to a cluster that doesn't (e.g. a remote cluster), override the chart value:
qa:
hostAliases:
- ip: "192.168.65.2" # the laptop's host IP from inside the cluster
hostnames: ["host.docker.internal"]rag-pgvector/
├── pyproject.toml # uv workspace root
├── uv.lock
├── .env.example
├── docker-compose.yml # postgres-only convenience for non-pod dev
├── vectorizer/ # service: ingest + embed + upsert
├── question-api/ # service: retrieve + ground + generate
├── libs/rag-core/ # shared Protocols, prompts, chunker, settings
├── infra/
│ ├── docker/postgres-pgvector/ # FROM postgres:17 + apt postgresql-17-pgvector
│ └── helm/rag-pgvector/ # umbrella chart (3 pods, no Ollama, no Ingress)
├── scripts/ # docker-desktop-up, helm-install, port-forward, seed-ingest
└── tests/ # repo-level tests (helm lint + helm template)
uv run pytest -q # 19 tests, no network/services requiredThe test suite covers:
- Prompt + chunker invariants in
libs/rag-core/tests/. - Vectorizer domain orchestration with fake ports (
FakeGovInfoClient,FakeLoader,FakeStore). - govinfo HTTP client with
respxmocks. - Question-api retrieval/threshold/grounding with fake ports.
- Helm chart smoke tests:
helm lint+helm templatein all threeaws.auth.modes and both LLM providers.
A separate @pytest.mark.integration marker is reserved for testcontainers-backed adapter tests against a real pgvector — these are opt-in (run with pytest -m integration).
- No CronJob ingestion scheduler — ingestion is operator-driven via
POST /ingestper the original design. - No in-cluster Ollama; the host-resident model is used when
LLM_PROVIDER=ollama.