A production-grade, Temporal-orchestrated Retrieval-Augmented Generation (RAG) chatbot. Orchestrates LLM-based rephrasing, vector retrieval, reranking, and answer generation as durable Temporal activities so that any mid-pipeline failure (LLM SDK hang, transient network error, worker restart mid-call) is retried or gracefully-failed by the workflow engine — not by hand-written retry/timeout/exception code.
Stack:
- Temporal (Python SDK 1.32, dockerized
auto-setup) — durable workflow engine, task queue, per-activity retry policies, typed non-retryable application errors, workflowexecute_update()for synchronous chat turns. - Groq hosted LLMs via
langchain-groq(ChatGroq) for the two LLM hops per turn (standalone question rephrasing + final grounded QA). - Ollama native (host
localhost:11434,bge-largemodel) for 1024-dim embeddings. - Qdrant (dockerized) for vector storage, cosine similarity search, point deduplication via content UUIDs.
- LangChain components:
RecursiveCharacterTextSplitter,ChatPromptTemplate,OllamaEmbeddings,QdrantVectorStore,CrossEncoderReranker.
Design invariants (see workflow.py and activities.py):
- Workflow never crashes mid-thread. Each of the 3 activities (contextualize / retrieve / generate) has its own
try/except ApplicationError(typed=XxxError, non_retryable=True)so a failure on turn N just returnsSendMessageResult(answer=NO_INFO_RESPONSE, error=…)— turn N+1 still works against the sameconversation-workflow-session_<id>. - Every async activity has a hard wall-clock guard via
asyncio.wait_for(_body(), timeout=<per-activity int>)inside the activity itself. ChatGroq / Ollama transport stalls that would otherwise be a "retryable activity timeout" → stacked into 3 retries × 60 s = 180 s → clientrpc_timeoutfire, are instead converted inside the activity to typed, non-retryableApplicationError. One hang → graceful fallback in <90 s total. - Chat-turn chat history only stores SUCCESSFUL turns. Skipped retrieval, retrieval-empty, or generation-failure turns don't append
I am sorry, but I do not have that information.as "AI output" because doing so poisons the next turn's standalone-question rephrasing. - Worker auto-starts inside CLI. Running
python3 main.pyusessys.executable+subprocess.Popen(start_new_session=True)to boot its ownpython worker.pyas a child and reap it on exit. No "open two terminals" setup required for the default dev UX. - Ingestion tolerates single-page doc-host SPAs (Mintlify etc). When
{url}/sitemap.xmlreturns HTML 200 instead of XML (common on hosted docs), the loader falls back to extracting same-host<a href>links from the landing page and fetching up to 30 of them — enough to cover full navigation for typical product docs. - Continue-as-new every N turns. Conversation workflows bound their own event history by self-terminating and restarting with
carryover_history, keeping each workflow run's event count under Temporal's 50k event ceiling even for very long chat threads.
- Docker 25+ with Compose v2 (tested on Docker 29.7.2 + Compose v5.5.0).
- Python 3.12+ (tested on 3.13) + a virtualenv at
./.venv. - Ollama installed natively on the host (default dev UX uses host ollama, not a container — avoids port conflicts and lets you reuse already-pulled models).
- Pull once:
ollama pull bge-large
- Pull once:
- A Groq API key (any Groq-enabled account works; tested with a key that has
qwen/qwen3.8-27benabled).
cd lcqdrag
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install -r <(cat pyproject.toml | python3 -c 'import tomllib,sys;print("\n".join(tomllib.load(sys.stdin)["project"]["dependencies"]))')
# OR, if you have `uv` installed:
uv synccp .env.example .env
# edit .env:
# GROQ_API_KEY=gsk_your_real_key
# LLM_MODEL=qwen/qwen3.8-27b (use `Groq().models.list()` to pick your own)
# QDRANT_URL=http://localhost:6333
# TEMPORAL_HOST=localhost:7233
# INGESTION_URL=https://docs.smith.langchain.com/docker compose up -d
# Expected (first run):
# Network lcqdrag_lcqdrag-net Created
# Container lcqdrag-postgres Healthy (~5 s)
# Container lcqdrag-qdrant Healthy (~15 s; uses bash /dev/tcp probe — no curl required in image)
# Container lcqdrag-temporal Healthy (~5 s)
#
# Default profile does NOT start ollama (collides with host ollama on :11434)
# or the Python worker (main.py boots its own native worker as a subprocess).
#
# To start them explicitly when needed:
# docker compose --profile with-ollama --profile worker up -dsource .venv/bin/activate
python3 main.pyOn startup the CLI:
- Boots its own native
python worker.pysubprocess (stdout/stderr →.logs/worker.log, reaped on SIGINT/SIGTERM). - Connects to Temporal on
localhost:7233and ensures the default namespacedefaultexists. - Runs
AcceptanceIngestDriverWorkflowagainstINGESTION_URLto populate the Qdrant collectionlangsmith_docswith 30 crawled doc pages → ~114 deduplicated 1000-char chunks on first run. - Drops you into the chat loop; type
history/exit:
============================================================
LCQDRAG — Production RAG Chatbot (Temporal-orchestrated)
Thread ID: session_<id>
Type 'exit' to quit, 'history' to see chat history
============================================================
Ask: What is LangSmith?
Answer:
LangSmith is a framework-agnostic platform designed for testing, deploying,
monitoring, and improving AI agents across the agent development lifecycle...
Ask: What does tracing mean in it?
Answer:
In LangSmith, tracing is the process of recording and organizing the execution
of an agent's operations to monitor and analyze its performance. It involves
the following key components: Runs / Traces / Threads / Trajectories ...
# --- Infrastructure
docker compose up -d # postgres + qdrant + temporal only (default)
docker compose --profile with-ollama --profile worker up -d # everything containerized
docker compose down
docker ps # expect 3 Healthy: postgres/qdrant/temporal
docker logs lcqdrag-qdrant
docker logs lcqdrag-temporal | grep -i "namespace" # default namespace registration
# --- CLI / runtime
tail -f .logs/worker.log # follow worker activity output
python3 main.py <<< 'What is LangSmith?'$'\nexit' # non-interactive one-shot
# --- Diagnostics
# Re-enable the "(contextualized as: ...)" debug line we removed from default output:
VERBOSE_CONTEXTUALIZE=1 python3 main.py
# --- Acceptance / evaluation (see run_acceptance_tests.py + eval.py)
source .venv/bin/activate
python3 run_acceptance_tests.py # kill-worker-replay, double-ingest idempotent, invalid-Groq-key graceful
python3 eval.py # RAG correctness evaluation harness → eval_results.json (gitignored)lcqdrag/
├── main.py # CLI client: boot worker, start ingestion-driver workflow, chat loop via execute_update()
├── worker.py # Temporal Worker process + registered workflows & activities
├── workflow.py # ConversationWorkflow.send_message_update (queue/runner) + continue-as-new + try/except graceful fallbacks
├── activities.py # 4 Temporal activities: contextualize / retrieve / generate / ingest_documents
│ + sitemap-aware, SPA-tolerant web_base_loader + bge-large embeddings + qdrant upsert
├── test_drivers.py # AcceptanceIngestDriverWorkflow + poison-worker test workflows
├── run_acceptance_tests.py # 3 acceptance scenarios harness
├── eval.py # RAG eval harness → eval_results.json (gitignored)
├── config.py # Pydantic env-loaded settings (RetrievalConfig / TimeoutConfig / QdrantConfig / TemporalConfig…)
├── docker-compose.yml # Postgres + Temporal + Qdrant; optional `with-ollama` & `worker` profiles
├── Dockerfile # Production image for the worker
├── pyproject.toml # Pinned python deps + entrypoints
├── .env.example # Copy to .env for local run
├── .gitignore # Excludes venv, logs, .env, scratch MD files, run artifacts
└── README.md # This file
| Env name | Default | Purpose |
|---|---|---|
GROQ_API_KEY |
(required) | ChatGroq client auth |
LLM_MODEL |
qwen/qwen3.8-27b |
Chat-completion model for both rephrase + QA hops. Use Groq().models.list() to find a valid one for your key. |
EMBEDDING_MODEL |
bge-large |
Ollama model name pulled on the host. |
OLLAMA_HOST |
http://localhost:11434 |
Embedding endpoint (point at http://host.docker.internal:11434 when running the containerized worker profile). |
QDRANT_URL |
http://localhost:6333 |
Qdrant REST endpoint |
QDRANT_COLLECTION_NAME |
langsmith_docs |
Vector collection in Qdrant |
TEMPORAL_HOST |
localhost:7233 |
Temporal gRPC host |
TEMPORAL_NAMESPACE |
default |
Temporal namespace for workflows & tasks |
TEMPORAL_TASK_QUEUE |
lcqdrag-task-queue |
Task queue name — worker + main.py must match |
INGESTION_URL |
https://docs.smith.langchain.com/ |
Documentation site to seed the vector store with on startup. |
TIMEOUT_CONTEXTUALIZE / RETRIEVE / GENERATE / RERANK |
30, 10, 60, 30 (seconds) |
Per-activity wall-clock hard caps inside each Temporal activity's asyncio.wait_for guard. |
RETRIEVAL_SIMILARITY_THRESHOLD |
0.25 |
Cosine-similarity floor below which retrieval short-circuits (kept intentionally permissive — let the reranker + QA LLM filter). |
RETRIEVAL_TOP_K_INITIAL / FINAL |
15, 4 |
Candidate vectors → reranker → final context window. |
ENABLE_RERANKING |
true |
Toggles sentence-transformers ms-marco-MiniLM-L-6-v2 rerank. |
VERBOSE_CONTEXTUALIZE |
0 |
Set to 1 to re-enable the "(contextualized as: …)" debug line on the CLI. Useful for debugging a bad pronoun resolution. |
DEBUG_SLOW_CONTEXTUALIZE_SECONDS |
0 |
Acceptance-test injector; forces contextualize_activity to sleep N seconds to exercise the timeout → non-retryable ApplicationError guard path. |