← All work
CASE STUDY · [01]
YEAR · 2024 – 2026
ROLE · Full-stack AI + backend

CX Consulting AI

Production RAG assistant for consulting teams. Streams grounded answers with source citations, generates full CX deliverables (strategy docs, journey maps, ROI analyses) via an intelligent planner-executor architecture, and runs on AWS g4dn with local llama.cpp inference.

Outcomes
0%Retrieval accuracy
0%Context retention
0msp95 latency
0Executor flows
MEDIA SLOT
chat / citation UI screenshot
Suggested: streaming answer with a citation card open showing the source chunk
BRIEF

Consultants needed an assistant they could trust to pull the right paragraph from long client documents, then either answer inline or generate an entire deliverable. The bar was accuracy on real corpora, not novelty. Deployed to production for Cloud Primero; carried forward as a personal framework benchmarked against the standard 2024–2026 RAG eval stack.

[01]

The core innovation isn't a RAG pipeline — plenty of teams have those. It's the orchestration layer. Every /api/ask (app/api/routes_chat.py:306) request routes through an LLM-driven planner (app/core/intelligent_planner.py:68, IntelligentPlanner.plan_response at :75) that classifies user_intent across 14 categories and picks one of 13 next_action values (planner.py:39-52). The chosen plan then runs through three guard layers before execution: an IntentVerifier that re-asks the LLM when confidence is low, a ToolValidator that checks preconditions, and a DecisionVerifier (app/core/decision_verifier.py:32) that makes a second LLM call with an intentionally conservative prompt ("only reject if OBVIOUSLY inappropriate") to catch bad intent classification. If the DecisionVerifier flips the plan, it hands back a suggested_intent and suggested_action and orchestration continues with the corrected plan. Then execution dispatches to one of 13 dedicated executor classes: DirectAnswerer, StrategyCreator, ClarificationAsker, CasualResponder, DeliverableGenerator, IterativeResponseGenerator, DocumentAnalyzer, WebSearcher, QuestionnaireUpdater, MoMGenerator, KnowledgeCollector, ProgressiveContextBuilder, GenericDocumentGenerator. Deliverable output auto-saves via `_auto_save_deliverable` at orchestrator.py:966: writes markdown to disk (orchestrator.py:1023), inserts a database row (orchestrator.py:1050), and once a project accumulates 50+ deliverables the auto-save also re-indexes into ChromaDB (orchestrator.py:1084-1089) so future queries retrieve from prior work.

Query lifecycle
LIVE·yellow = hot path·rust = data flow·+ = click for source
React clientstreaming chat500+ usersFastAPI /api/askapp/api/routes_chat.pyp95 620msJWT authCurrentUserDepIntent classifierqa · deliverable · chit_chat+Intelligent PlannerLLM · ResponsePlan+Decision verifiersecond LLM check+Tool validatorcheck preconditions+Executor router13 flows+DirectAnswererDeliverableGeneratorcx_strategy · journey · ROIDocumentAnalyzerhybrid retrievalSSE streamcontent deltas → clientAuto-save + indexmarkdown + DB + Chroma+
Every request goes through JWT auth, intent classification, an LLM planner, an optional verifier, then one of thirteen executor flows. Streamed to the client as it generates.
[02]

HybridRetriever at app/services/retrieval.py:17 runs BM25 (BM25Okapi, retrieval.py:45) and semantic search (ChromaDB with BAAI/bge-base-en-v1.5, downloaded in app/scripts/download_embeddings.py:26) in parallel, then fuses ranked lists via Reciprocal Rank Fusion at retrieval.py:82-96 with rrf_k=60 (RRF_K default in config.py:290, env-overridable). Two-stage retrieval hybrid_cross_encoder at retrieval.py:112 pulls RRF top-M then reranks to top-K with a CrossEncoder model (default cross-encoder/ms-marco-MiniLM-L-6-v2 from config.py:222; actually loaded in agents/retriever.py:110 when USE_RERANKING is on). Optional MMR diversification at services/mmr_diversifier.py runs after fusion, before the reranker. Collections are scoped in document_service.py:37-39: CX_GLOBAL_COLLECTION = 'global_kb' for shared frameworks, USER_COLLECTION_PREFIX = 'user_' for project docs, DELIVERABLE_COLLECTION_PREFIX = 'deliverable_' for generated outputs. A query cannot cross projects because it never asks for another project's collection.

[03]

p95 went from 2.3 seconds on the initial build to 620ms in production — a 73% cut, measured against the client's production traffic (not verifiable in the repo). The optimizations that got it there: async FastAPI streaming (SSE / chunked responses so the client sees tokens as they generate — the /ask endpoint returns StreamingResponse with media_type='text/event-stream' at routes_chat.py:1320), backend routing that sends simpler intents to a smaller/faster model, and making CrossEncoder reranking optional per-request (USE_RERANKING env flag at agents/retriever.py:89) because the client's eval set hit 91% accuracy without it. Prometheus histograms at app/observability/metrics.py:13 track LLM_LATENCY per backend for ongoing regression detection.

p95 response latency (production telemetry, not in-repo)
milliseconds, lower is better
Initial build
2,300
Current
620ms
[04]

Users upload PDFs, DOCX, TXT, CSV, XLSX, or Markdown through app/api/routes_upload.py (upload_docs_api at :59) or the aliased app/api/routes_documents.py. Uploaded bytes are written to a NamedTemporaryFile matching the original extension, then handed to RagEngine.process_document at services/rag_engine.py:326. The engine dispatches by extension to the right loader, extracts text, chunks it (~512 tokens, 50 overlap), computes content-addressed chunk IDs, embeds with BGE-base-en-v1.5, upserts into the tenant-scoped ChromaDB collection (user_{project_id} for project docs, global_kb for shared frameworks), and rebuilds the BM25 sparse index in parallel so hybrid retrieval sees the new content on the next query.

Upload to queryable chunk
LIVE·yellow = hot path·rust = data flow·+ = click for source
Client uploadPOST /api/documentsroutes_documents.pyauth + project scope checkNamedTemporaryFilematching extensionRagEngine.process_documentservices/rag_engine.py:326+Format loaderPDF · DOCX · CSV · XLSX · MDChunker~512 tok · 50 overlap · content-addr IDDense embedBGE-base-en-v1.5 · 768dBM25 sparse indexrank_bm25.BM25OkapiTenant-scoped collectionuser_{project_id} / global_kb91% recallChromaDBHNSW · persist_dirQueryablenext /api/ask sees itunder 2sCleanuptemp file deleted
One file drop becomes queryable content in the project's ChromaDB collection + BM25 index. Every step is idempotent — re-uploading the same document produces the same chunk IDs.
[05]

Tenancy is column-based on top of SQLModel. Every Project has an owner and a shared_with list of user IDs; every route dependency verifies the current user is owner or shared. ChromaDB collection names embed the project ID (user_{project_id}, deliverable_{project_id}) so retrieval is naturally scoped — a query cannot cross projects because it never sees another project's collection. Cleaner than row-level security, easier to reason about than schema-per-tenant.

[06]

Config-driven backend selection via LLM_BACKEND=vllm|llama.cpp|azure|ollama. Local llama.cpp with Qwen3-4B-Q4_K_M runs on the AWS g4dn.2xlarge T4 GPU for cost control; Azure OpenAI is the drop-in for when a client needs enterprise-managed inference; vLLM is available for higher-throughput single-model serving; Ollama for local dev. Tenacity-wrapped generate_async retries with exponential backoff on transient failures. Startup loads exactly one backend at a time on the deployed node — no runtime failover, but the switch is a one-env-var deploy.

[07]

docker-compose.g4dn.yml runs two services: the FastAPI app container (with GPU reservation via nvidia device driver, LLM_THREAD_QOS=USER_INITIATED) and Redis 7-alpine for conversation memory. Models live on the host at /opt/models bind-mounted into the container so image size stays small; SQLite persists via a second bind mount at ./app/data:/app/data. Env comes from .env.prod.g4dn. Nginx as a TLS/buffering front is a documented recommendation in docs/DEPLOY_g4dn_2xlarge.md:59 for production but is not part of the compose stack. Alembic manages SQLModel migrations across 6 tables (User, ProjectUserLink, Project, Chat, ChatMessage, GeneratedDeliverable — declared in app/models/chat_models.py and user_models.py) across 3 migration files in alembic/versions/.

[08]

After the client project, I kept building. The personal continuation (github: LangChain) carries the same architecture forward and adds the pieces the client version doesn't need: NLI-verified citations via DeBERTa-v3-base at span level (87% recall on RAGTruth's gold hallucination spans), hash-chained SQLite audit log for tamper-evident provenance, LangGraph agent mode with tool binding (rag_search, web_search via Tavily, calculator, sql_query on DuckDB), Dagster scheduled ingest with hash-based delta detection, and a benchmark harness that runs against BRIGHT, FinanceBench, RAGTruth, BIPIA, CUAD, and Amazon ESCI. Same retrieval philosophy, deeper testing, cleaner observability (OpenTelemetry + Phoenix + Langfuse + LangSmith).

LangChain framework: query pipeline with NLI verification
LIVE·yellow = hot path·rust = data flow·+ = click for source
responseClientHTTPFastAPI /querysrc/api/routes.pyAuth + Tenant ctxrow-level ACLHybrid RetrieverQdrant · RRF+Dense embedFastEmbed / GeminiBM25 sparseFastEmbedQdranthybrid one-shotCross-encoder rerankBGE-v2-m3 / VoyageGeneratorstructured JSON + IDs94% contextLLM routerGroq · Gemini · NVIDIA · CerebrasNLI verifierDeBERTa-v3 · span-levelConfidence gateabstain if weakAudit writerhash-chain SQLite
The personal continuation adds a DeBERTa NLI verifier between generation and response, and a hash-chained audit log downstream. 87% span recall on RAGTruth.
[09]

Real numbers from real evaluation runs on the LangChain framework, not vibes. Verifier catches 87% of RAGTruth's gold hallucination spans. Retrieval hits 40% on FinanceBench's partial corpus (limited by how many source PDFs were ingested, not by the retriever). ~13 nDCG@10 on BRIGHT's pony subset with FastEmbed 384-d + BM25 RRF (reference: SFR-Embedding-Mistral averages 18.3 nDCG@10 on full BRIGHT — so a small embedder in the same ballpark). 17% BIPIA adversarial attack success rate against a defensive system prompt (published unprotected GPT-3.5 numbers on BIPIA are >50%). BuyChat, a sibling product built on the same retrieval stack, ranks Amazon ESCI at 0.94 recall@100.

[010]

Consultants and product were the primary users, not other engineers, so most of the work was translating what they wanted into what a RAG system could actually deliver. Explaining why a reranker helps a fuzzy query but not a narrow one, why streaming feels faster than a lower absolute latency, why some deliverables need to draft-then-critique and others can be one-shot. Every ambiguous request became a backend milestone I could ship against. Honesty note on the metric strip: the 91% retrieval accuracy and 94% context retention are from the client's own evaluation set — they're not measured inside this repo. The 620ms p95 is production telemetry. And 500+ concurrent users is peak session count, not simultaneous in-flight requests — the ConcurrencyLimitMiddleware at main.py:192 caps in-flight at max(2, min(4, cpu_count // 2)), and a p95 of 620ms with request bursts gives comfortable headroom for hundreds of active sessions on a single g4dn.2xlarge node.

STACK
FastAPIChromaDB (HNSW)BGE-base-en-v1.5BM25 + RRF fusion (k=60)Cross-Encoder rerank (ms-marco-MiniLM-L-6-v2)llama.cpp / vLLM / Azure OpenAI / OllamaSQLModel · AlembicRedisReact frontend (separate repo)Docker · AWS g4dn.2xlarge (T4 GPU)Prometheus