Architecture
RAG is a Temporal worker. The Go API is the
Temporal client that starts these workflows; RAG executes them, and the API
surfaces their progress by watching Temporal and streaming status over its
…/watch SSE endpoints
(which the app consumes as
tRPC subscriptions).
Worker
Section titled “Worker”The worker (app/temporal/worker.py) connects with the deployment environment
as its Temporal namespace and polls a single task queue (rag-tasks by
default). It registers 6 workflows and 22 activities.
- Activities are synchronous (
def, notasync def) and run on aThreadPoolExecutor— the heavy work (PDF rendering, OCR, embeddings, DB) is blocking and CPU/IO-bound. Admission is resource-based:WorkerTuner.create_resource_basedadmits activities against memory/CPU targets (worker_target_memory0.8,worker_target_cpu0.9), bounded by configurable slot caps (worker_activity_slots_min8,worker_activity_slots_max200; workflow slots capped at 500), and the thread pool is sized to the activity-slot max — not a fixed count. (Resource-based slots replaced the old fixedmax_concurrent_*settings; the SDK can’t combine the two.) - Workflows run in Temporal’s sandbox, with
sniffioandopentelemetrypassed through (the httpx-based API SDK’s async-client garbage-collector finalizers run on arbitrary threads where a lazyimport sniffioinside httpcore would crash, and the OTel imports don’t survive the sandbox otherwise). - The worker also runs a minimal background health server
(
start_health_server, port8080,GET→200 "ok") in a daemon thread, for the Railway platform healthcheck. It is not the source of the status signal — that comes from a Temporal poller metric, not an HTTP probe. - Services are imported lazily inside activities, not at module top, to keep
the workflow sandbox’s import graph small and defer heavy deps (
fitz, numpy, Voyage) off the startup path.
Workflows
Section titled “Workflows”| Workflow | Arg | What it does |
|---|---|---|
ParseDocumentWorkflow |
document_id |
Parse → clean → chunk → embed → mark-parsed |
GenerateFieldWorkflow |
field_id |
Retrieve → rerank → LLM extract → ground → evidence |
FindBondWorkflow |
request_id |
Locate the bid/final bond form(s) in the docs |
FillBondWorkflow |
bond_id |
Detect blank fields and fill them (vision-LM) |
DeleteDocumentWorkflow |
document_id |
Remove a document’s chunks/elements |
ProcessBondRequestWorkflow |
request_id |
Parent — orchestrates the above |
Each stage is a separate activity with its own timeout and retry, so a failure resumes at the failed stage rather than re-running the whole chain.
ProcessBondRequest: the orchestrator
Section titled “ProcessBondRequest: the orchestrator”ProcessBondRequestWorkflow is the durable, server-side replacement for what
used to be a fire-and-forget chain kicked off in the browser (which died on a
page refresh). It takes only a request_id and spawns the others as child
workflows:
graph TD
P[ProcessBondRequestWorkflow] --> R[resolve_process activity<br/>→ docs to parse + fields to generate]
R --> PH1{Phase 1 — parse, strict}
PH1 --> PD1[child: ParseDocumentWorkflow ×N]
PH1 --> PH2{Phase 2 — find + generate, best-effort}
PH2 --> FB[child: FindBondWorkflow]
PH2 --> GF[child: GenerateFieldWorkflow ×M]
PH2 --> PH3{Phase 3 — fill, best-effort}
PH3 --> TS[terminate_stale_fills activity]
TS --> FL[child: FillBondWorkflow ×K]
- Phase 1 (parse — strict): one
ParseDocumentWorkflowchild per unparsed document, gathered concurrently. It drains all children first (each completed one is durably marked parsed), then fails the run if any failed — so a resubmit re-parses only the failures. - Phase 2 (find + generate — best-effort): once parsing succeeds, it spawns
FindBondWorkflowplus oneGenerateFieldWorkflowper generated field, all concurrently. Individual child failures are logged but don’t sink the rest. - Phase 3 (fill — best-effort): once find + generate finish, the parent fills
every located form (
FindBondWorkflow’sfillable_form_ids). Fill must run last because itsdata_prepreads both theBondrecordsfindwrote and the freshly generated field values. Since a child start can’t carry a conflict policy, the parent first runs theterminate_stale_fillsactivity to kill any in-flightFillBondWorkflowwith the same derived ID (e.g. a wizard-triggered fill whosedata_prepran before all generated values landed), then spawns oneFillBondWorkflowchild per fillable form. A child already running under the same ID is handed off (not double-filled), and one terminated by a Go-side re-trigger is treated as superseded, not fatal.
Document ingestion pipeline (parse)
Section titled “Document ingestion pipeline (parse)”graph LR
A[Download PDF<br/>presigned URL] --> B{Scanned?}
B -->|text layer| C[PyMuPDF blocks]
B -->|scanned| D[Tesseract OCR<br/>ProcessPool per page]
C --> E[Store elements]
D --> E
E --> F[Clean + chunk]
F --> G[Embed<br/>Gemini · LiteLLM]
G --> H[Store chunks<br/>pgvector]
- Parser (
parser.py): downloads the PDF via a presigned URL, samples pages to decide scanned vs. text-layer. Text PDFs use PyMuPDFget_text("dict")blocks; scanned PDFs render each page to PNG and OCR in parallel via aProcessPoolExecutor(one process per page — Tesseract isn’t thread-safe). Output isDOMElements with normalized[x1,y1,x2,y2]bboxes. - Clean + chunk: OCR noise (single-char runs, dot/dash leaders,
U+FFFD) is stripped, then elements aggregate into ~1000-char chunks (1500 max, 200 overlap), each keeping its sourceelement_idsfor grounding. - Embed: the
EmbeddingServiceembeds vialitellm.embedding(gemini/gemini-embedding-001, 1536 dims,task_type="RETRIEVAL_DOCUMENT") in parallel batches with a shared token-bucket rate limiter; only rows with aNULLembedding are embedded, so it resumes cleanly. Calls route through the LiteLLM gateway and carry the requester org / workflow asx-litellm-customer-id/x-litellm-tagsheaders for per-org, per-workflow spend attribution.
Field extraction pipeline (generate)
Section titled “Field extraction pipeline (generate)”graph LR
A[Fetch field spec<br/>from API] --> B[Embed query]
B --> C[Hybrid search<br/>dense + BM25 → RRF]
C --> D[Rerank<br/>Voyage]
D --> E[LLM extract<br/>strict JSON schema]
E --> F[Ground quote<br/>→ page + bbox]
F --> G[Evidence PNG<br/>+ update field]
- Embed query (
EmbeddingService.embed_query,task_type="RETRIEVAL_QUERY"): the query vector is served from a Postgres query-embedding cache (query_embedding_cache) before any API call — a hit returns in-process, spending no tokens and skipping the rate-limit bucket. Only a miss embeds through the gateway and back-fills the row. - Hybrid retrieval (
vectorstore.hybrid_search): a dense arm (pgvector cosine,<=>) and a BM25 arm (ParadeDBpg_search) run on separate pooled connections and are combined with Reciprocal Rank Fusion (with optional anchor-term boosting). - Rerank: Voyage
rerank-2.5-litevialitellm.rerank, routed through the gateway (the aliasvoyage/rerank-2.5-litethe proxy declares) and gated by thererank_enabledflag — no longer by the presence of a Voyage key, since the worker no longer holds one. - LLM extraction: a Gemini chat model (via
litellm.completion) with strict JSON-schema structured output (additionalProperties: false,reasoning_effortkept minimal), under an explicit per-calltimeout. Confidence is a weighted blend of vector score and the model’s self-reported confidence. - Grounding (
grounding.py): matches the answer/quote back to a source element (exact substring, elserapidfuzzfuzzy match ≥ 85), producing a page number and bbox. The evidence step re-renders that page with a highlight rectangle, uploads the PNG via a presigned URL, and writes the value + evidence reference + confidence back to the API.
Find & fill bond forms
Section titled “Find & fill bond forms”- Find (
bond_finder.py,final_bond_finder.py): a fast scan pass produces page references only (no page text crosses the activity boundary — a recurring memory-hygiene rule), then an LLM refine pass re-fetches just those pages to pick the winning document and consecutive page span. The final-bond finder dedups against any bid bond already found. A best-effortfind_bid_bond_percentactivity runs concurrently with the refine pass to extract the bid-bond penalty percentage; it degrades to0.0on failure. Percentages are a strict-number schema coerced through a sharedservices/pct.py(coerce_pct) and clamped to[0, 100], while performance/payment specs are forced to 100%.write_bondscentralizes a delete-then-insert for idempotency and maps internal refs to the SDK’sbond_type(bid/final). - Fill (
bond_filler.py): a two-pass vision-LM flow — pass 1 detects blank fields (bboxes), pass 2 fills values from known contractor data. Penal sums and their word forms are computed in Python, not by the model. Surety details (name/address/phone) are fetched live from the API byorg_idindata_preprather than hardcoded (phone falls back to a default if the fetch fails).
Calling the Go API
Section titled “Calling the Go API”Everything goes through the git-installed huddlesurety-api SDK, built by
get_huddle_client() on a custom httpx.Client:
- Auth: a request hook injects
Authorization: Bearer <KEY_AUTH_SECRET>for requests to the API host (service-key auth). - Timeout: an explicit
httpx.Timeout(10s, connect=10s)— set wider than httpx’s 5s default because some API calls (e.g. synchronous PDF trimming) take longer than a couple of seconds. - The client is instrumented so RAG → API calls emit a CLIENT span and inject
traceparent, producing the RAG → API edge in the service graph.
Distributed tracing & identity
Section titled “Distributed tracing & identity”Baggage doesn’t cross the Temporal boundary, so RAG carries requester identity
itself via worker interceptors (app/o11y/identity.py):
- The inbound workflow interceptor reads the org header off the workflow header; the outbound interceptor re-injects it onto every child workflow and activity (mirroring the Go API’s identity propagator, which doesn’t run on the Python side).
- The activity interceptor reads it back and stamps
org.id+workflow.typeon the OTel span (and acurrent_org_idcontext var).
The header key is x-huddle-org-id, JSON-encoded to match the
API’s propagator.
Only org is propagated (not user), a deliberate cardinality choice. Traces
export on a 5s interval (metrics on 15s) so app → api → rag traces assemble
quickly in Grafana. Custom rag.* metrics record LLM/embedding tokens, call
duration, and USD cost per model, plus a rag.embedding.cache counter with a
three-way result (hit/miss/error) and an op (read/write) so a dead
cache reads as error, never as a cold-but-healthy miss. Note that
provider-side spend and latency now live in the
LLM gateway’s own dashboard,
not in Tempo — RAG spans end at the litellm call.
Database
Section titled “Database”Schema document, migrated by goose (app/db/):
document.chunk—id,document_id,project_id,org_id,raw,embedding vector(1536),element_ids UUID[], plus a BM25 index (USING bm25 … WITH (key_field='id')) for ParadeDB search. The embedding dimension is substituted into the migration fromEMBEDDING_DIMENSION.document.element— raw text blocks withbbox,page_number,element_type.document.query_embedding_cache—query_hash(PK),query,model,dimensions,embedding. Caches query-side embeddings keyed on a SHA-256 of(model, dimensions, task_type, query), so a model swap orEMBEDDING_DIMENSIONchange misses into fresh rows rather than needing invalidation. Every read/write failure degrades to a miss — a cache outage looks exactly like the pre-cache path plus a log line.
A process-wide psycopg3 ConnectionPool (db_pool_min_size 2 / db_pool_max_size
30, sized independently of the activity-slot cap) is shared across activities. Vectors are passed as pgvector text
literals; IDs convert between ULID (Go/S3) and UUID (DB) via idconvert.