pathwaycom/llm-app is a curated catalogue of ten reference applications — eight deployable templates plus two Jupyter cookbooks — each built on the Pathway streaming-dataflow engine and its pathway.xpacks.llm toolkit. This guide documents the architecture, deployment model, configuration surface, security posture, and developer internals for the catalogue. For end-user task walkthroughs, see the Pathway LLM App Templates User Guide.
The repository is small (126 files, ~62 KB of analyzed source). Most of the substance lives upstream in the Pathway library; the templates are configuration recipes and Pydantic glue that wire the library's RAG, indexing, and MCP primitives into deployable services. Read this guide together with the Pathway core docs at https://pathway.com/developers/ when you need to understand a class or method that the templates merely reference.
1. Architecture Overview
What the catalogue actually is
Each template under templates/ is a self-contained Pathway program with the following on-disk shape:
templates/<template_name>/
app.py ← the Pydantic App shell + entrypoint
app.yaml ← declarative pipeline config (App-class templates only)
Dockerfile ← image build: pathwaycom/pathway base + requirements
docker-compose.yml ← multi-container stack (app, ui, optional services)
requirements.txt ← typically just python-dotenv (pathway is in the base image)
.env.example ← env-var template (some templates only)
README.md ← per-template operational doc
data/ ← seed documents (or files-for-indexing/)
ui/ ← Streamlit UI (some templates only)
Modern templates use a Pydantic App(BaseModel) class whose fields are parsed from app.yaml via pw.load_yaml. Legacy templates (drive_alert, unstructured_to_sql_on_the_fly) instead expose a procedural run(*, ...) function with os.environ.get defaults.
High-level system diagram
┌────────────────────────────────────────────────┐
│ pathwaycom/llm-app TEMPLATE CATALOGUE │
│ (this repository — Pydantic shells + YAML) │
└──────────────────────┬─────────────────────────┘
│
┌─────────────┴───────────────┐
▼ ▼
┌──────────────────────────────────┐ ┌─────────────────────────────┐
│ App(BaseModel) Pydantic shell │ │ Legacy procedural run() │
│ extra="forbid" │ │ os.environ.get defaults │
│ ├── question_answerer | doc_ │ │ ├── drive_alert │
│ │ store | mcp_http (one of) │ │ └── unstructured_to_sql_ │
│ ├── host, port │ │ on_the_fly │
│ ├── persistence_mode/_backend │ └─────────────────────────────┘
│ ├── terminate_on_error │ │
│ └── run() → pw.run(...) │ │
└────────┬─────────────────────────┘ │
│ │
└──────────────────┬────────────────────────┘
▼
┌────────────────────────────────────────────────────────────────┐
│ pathway.xpacks.llm (UPSTREAM LIBRARY — pathway[all]) │
│ │
│ llms.OpenAIChat / LiteLLMChat │
│ embedders.OpenAIEmbedder / SentenceTransformerEmbedder │
│ parsers.DoclingParser / SlideParser / UnstructuredParser │
│ splitters.TokenCountSplitter │
│ document_store.DocumentStore / SlidesDocumentStore │
│ question_answering.{Base,Adaptive}RAGQuestionAnswerer │
│ question_answering.SummaryQuestionAnswerer │
│ servers.{QASummary,DocumentStore}RestServer │
│ mcp_server.PathwayMcp │
└────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────┐
│ pathway core (pathway[all]) — Python+Rust dataflow │
│ │
│ pw.io.{fs,gdrive,postgres,slack,...} │
│ pw.xpacks.connectors.sharepoint │
│ pw.persistence.{Backend,Config}, pw.PersistenceMode │
│ pw.indexing.{UsearchKnnFactory,USearchMetricKind} │
│ pw.run(persistence_config=…, terminate_on_error=…) │
└────────────────────────────────────────────────────────────────┘
Note
The templates own almost no logic. The Pydantic App is ~50 lines per template; everything substantive — the streaming dataflow, the connectors, the LLM orchestration, the persistence layer — lives in the Pathway library. Audit and hardening work on the templates is therefore mostly about (a) configuration choices, (b) container/network posture, and (c) the legacy procedural code in
drive_alertandunstructured_to_sql_on_the_fly.
Component inventory
| Component | Path | Role |
|---|---|---|
App Pydantic shell |
templates/<name>/app.py |
Loads YAML → instantiates xpack server → calls pw.run |
app.yaml declarative config |
templates/<name>/app.yaml |
Source of truth for pipeline composition |
| Dockerfile | templates/<name>/Dockerfile |
Image build (pathwaycom/pathway base + reqs) |
| docker-compose | templates/<name>/docker-compose.yml |
Local multi-container orchestration (where present) |
| Streamlit UI | templates/<name>/ui/ui.py (or server.py for legacy) |
Browser-facing chat/search front-end |
Legacy drive_alert procedural run |
templates/drive_alert/app.py |
Google-Drive RAG with Slack alerts (port 8080) |
Legacy unstructured_to_sql_on_the_fly |
templates/unstructured_to_sql_on_the_fly/app.py |
PDF→Postgres extractor + NL2SQL on top |
| Cookbooks | cookbooks/self-rag-agents/*.ipynb |
Jupyter tutorials (LangGraph + Pathway), not deployable |
| nginx file server | templates/slides_ai_search/nginx/ |
Serves slide images and PDFs back to the slides UI |
| Postgres init SQL | templates/unstructured_to_sql_on_the_fly/postgres/init-db.sql |
Creates the target table for the legacy SQL pipeline |
Technology stack
| Layer | Technology | Used by |
|---|---|---|
| Streaming dataflow runtime | Pathway (Python entrypoint, Rust core via pw.run) |
Every template |
| LLM client | OpenAI Python (via pathway.xpacks.llm.llms.OpenAIChat) |
All except private_rag |
| Local LLM client | LiteLLM (via LiteLLMChat) → Ollama |
private_rag |
| Embeddings | OpenAI text-embedding-3-small / SentenceTransformer (mxbai-embed-large-v1, GIST-small-Embedding-v0) |
per-template |
| Parser | Docling (pathway.xpacks.llm.parsers.DoclingParser) |
RAG templates |
| Slide parser | SlideParser (LLM-based, image-aware) |
slides_ai_search |
| Unstructured parser | UnstructuredParser |
Legacy templates |
| Vector index | usearch (UsearchKnnFactory, cosine metric) |
RAG/indexing templates |
| Legacy index | pathway.stdlib.ml.index.KNNIndex |
drive_alert |
| REST API | QASummaryRestServer / DocumentStoreServer (xpacks) |
App-class templates |
| MCP | pathway.xpacks.llm.mcp_server.PathwayMcp (streamable-http) |
document_store_mcp_server |
| UI | Streamlit | 4 templates |
| Reverse-proxy / file server | nginx | slides_ai_search |
| Database (output) | Postgres | unstructured_to_sql_on_the_fly |
| Alerting (output) | Slack (pw.io.slack.send_alerts) |
drive_alert |
| Container runtime | Docker + Compose | All templates |
| Config | YAML + Pydantic v2 (extra="forbid") |
App-class templates |
| Auth (LLM) | API keys via .env / os.environ |
All templates |
Data flow — canonical RAG (question_answering_rag)
[ source files (filesystem / GDrive / SharePoint) ]
│
▼ (Pathway streaming connector watches changes)
pw.io.fs.read(path=data, format=binary, with_metadata=true)
│
▼ (table of binary files)
DoclingParser (LLM-aware table parsing)
│
▼ (table of text strings)
TokenCountSplitter(max_tokens=400)
│
▼ (table of chunks)
OpenAIEmbedder(text-embedding-3-small)
│
▼ (table of (chunk, vector))
UsearchKnnFactory(metric=COS) → DocumentStore
│
▼ (live, always-current vector index)
┌────────────────────┴─────────────────────────┐
│ │
▼ ▼
BaseRAGQuestionAnswerer.answer() DocumentStore.list_documents()
(top-k retrieval → prompt build → (returns metadata for UI)
OpenAIChat → response)
│
▼
QASummaryRestServer (HTTP, port 8000)
│
▼
[ /v2/answer | /v1/retrieve | /v1/statistics | /v2/list_documents | /v2/summarize ]
Data flow — MCP server (document_store_mcp_server)
[ files-for-indexing/ ] ──► pw.io.fs.read ──► DoclingParser ──► TokenCountSplitter
──► SentenceTransformerEmbedder(mxbai-embed-large-v1) ──► UsearchKnn
──► DocumentStore ──► PathwayMcp(streamable-http, port 8068)
│
▼
[ MCP-aware client: Claude Desktop, agent, etc. ]
Data flow — drive_alert (legacy)
[ Google Drive folder ]
│ pw.io.gdrive.read(refresh_interval=30s)
▼
UnstructuredParser ──► TokenCountSplitter(40-120 tokens)
│
▼
OpenAIEmbedder(text-embedding-ada-002) ──► KNNIndex
│
│ ┌──────────────── HTTP POST / on port 8080 (REST connector) ──┐
│ ▼ │
│ QueryInputSchema(query, user) │
│ │ │
│ ▼ prompt: "Is this an alerting query?" → OpenAIChat │
│ ▼ yields (alert_enabled: bool, query: str) │
▼ ▼
KNNIndex.get_nearest_items(k=3)
│
▼ build_prompt(documents_list, query) → OpenAIChat → response
│
▼ HTTP response_writer(output)
│
▼ filter(alert_enabled) → pw.stateful.deduplicate(acceptor=LLM)
│
▼ pw.io.slack.send_alerts(message, channel_id, token)
Data flow — unstructured_to_sql_on_the_fly (legacy)
[ ./data/quarterly_earnings/*.pdf ]
│ pw.io.fs.read(format=binary)
▼
UnstructuredParser ──► strip_metadata
│
▼ build_prompt_structure (insert FinancialStatementSchema typehints)
▼ OpenAIChat → JSON response
▼ parse_str_to_list → unpack_col → typed columns
▼ pw.io.postgres.write(structured_table, settings, table=quarterly_earnings)
▼ pw.io.csv.write(structured_table, "./data/quarterly_earnings.csv")
──── alongside ────
[ HTTP POST / on port 8080 ] ──► NLQuerySchema(query, user)
│
▼ build_prompt_query(table, query) → OpenAIChat → SQL string
▼ psycopg cursor.execute(sql_query) → fetchall()
▼ pw.make_tuple(sql_query, result) → response_writer
Note
Both legacy templates use
pw.io.http.rest_connector(port 8080), not the modernQASummaryRestServer. They predate the App-class refactor and therefore expose a different REST shape (singlePOST /rather than the/v2/answer-family). Keep this in mind when fronting them with a reverse proxy.
2. System Requirements
Runtime requirements
| Component | Required | Notes |
|---|---|---|
| Docker Engine | 24.0+ | Compose plugin (docker compose, not docker-compose) |
| Linux kernel | 5.15+ | Pathway's Rust core links against modern glibc |
| RAM | 4 GB minimum / 8+ GB recommended | Multimodal/Slides templates push higher |
| CPU | 2 cores minimum / 4+ cores recommended | Pathway parallelism scales with cores |
| Disk | 10 GB | Docker images, Pathway persistence, document cache |
| Network | egress to OpenAI / GDrive / SharePoint | Skip with private_rag |
Native (non-Docker) runtime
| Component | Required | Notes |
|---|---|---|
| Python | 3.10 - 3.12 | pathway[all] published wheels |
pip |
23+ | For pip install pathway[all] |
| OpenSSL | system | Pathway's Rust core links against it |
| Postgres | 14+ | Only for unstructured_to_sql_on_the_fly (compose ships its own) |
| Ollama | 0.1.30+ | Only for private_rag; install on the host |
External services
| Service | Required by | Auth |
|---|---|---|
| OpenAI Chat Completions | Most templates | OPENAI_API_KEY |
| OpenAI Embeddings | Templates with OpenAIEmbedder |
same key |
| Google Drive API | drive_alert and any template configured with the GDrive connector |
service-account JSON |
| Microsoft Graph (SharePoint) | Templates configured with the SharePoint connector | client cert + thumbprint |
| Slack Web API (chat.postMessage) | drive_alert |
bot token |
| Postgres | unstructured_to_sql_on_the_fly |
user/password |
| Ollama | private_rag |
none (local) |
Pathway license
Templates ship with pw.set_license_key("demo-license-key-with-telemetry"). Operational implications:
| License mode | What works | What doesn't | Telemetry |
|---|---|---|---|
| Demo (default) | All template features as shown | none — full features for evaluation | anonymous usage telemetry sent to Pathway |
| Pathway Community (key removed) | Single-machine streaming, all xpacks | --addresses clustering, autoscaling, certain enterprise persistence backends |
none |
| Scale (paid key) | Everything, including --addresses and autoscaling |
— | none |
Important
The templates do not read
PATHWAY_LICENSE_KEYfrom the environment by default — the license string is hard-coded inapp.py. To use a personal Scale key, editapp.pyto callpw.set_license_key(os.environ["PATHWAY_LICENSE_KEY"])and put the key in.env. This is a small but important hardening step for production deployments.
3. Configuration Reference
Configuration files (per template)
| File | Format | Location | Purpose |
|---|---|---|---|
app.yaml |
YAML w/ Pathway tags | templates/<name>/ (App-class only) |
Declarative pipeline composition |
app.py |
Python | templates/<name>/ |
Entrypoint + Pydantic shell (modern) or procedural run() (legacy) |
.env |
dotenv | templates/<name>/ |
Secrets (gitignored) |
.env.example |
dotenv | templates/<name>/ (when present) |
Template for .env |
Dockerfile |
Dockerfile | templates/<name>/ |
Image build |
docker-compose.yml |
YAML | templates/<name>/ (most) |
Container orchestration |
requirements.txt |
pip | templates/<name>/ |
Extra Python deps beyond the Pathway base image |
pyproject.toml |
TOML | repo root | Linter/formatter config only (package-mode = false — not an installable package) |
setup.cfg |
INI | repo root | Linter (flake8/black) config |
Note
The repo root has
pyproject.tomlandsetup.cfg, but they declarepackage-mode = falseand only configure linters. There is no PyPI-installablellm-apppackage. Each template is its own freestanding application; cloning the repo is the supported install method.
App Pydantic model — the shared shape
Every modern template's App(BaseModel) exposes a near-identical surface (the variable field is the domain object: question_answerer, document_store, or mcp_http):
| Field | Type | Default | Required | Notes |
|---|---|---|---|---|
question_answerer or document_store or mcp_http |
InstanceOf[<class>] |
— | yes | The domain object the App wraps. Exactly one is present per template. |
host |
str |
"0.0.0.0" |
no | REST/MCP bind address |
port |
int |
8000 |
no | REST/MCP port |
with_cache |
bool | None |
None |
no | Deprecated — emits DeprecationWarning if set; use persistence_mode instead |
persistence_backend |
pw.persistence.Backend | None |
None (resolved to Backend.filesystem("./Cache") when persistence active) |
no | Where Pathway stores cached/persisted state |
persistence_mode |
pw.PersistenceMode | None |
pw.PersistenceMode.UDF_CACHING |
no | One of UDF_CACHING, PERSISTING, or None |
terminate_on_error |
bool |
False |
no | If True, pw.run aborts on first runtime error |
model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True) is set on every App. Forbid is the load-bearing setting: it turns YAML typos into Pydantic validation errors at startup rather than silently ignored fields.
run() resolves persistence as follows:
- If
persistence_mode is Noneand the deprecatedwith_cache is True: warn, useUDF_CACHING. - If
persistence_mode is Noneandwith_cacheis notTrue: persistence disabled. - Else: use the explicit
persistence_mode. - If persistence is active and
persistence_backend is None: default toBackend.filesystem("./Cache"). - Build
pw.persistence.Configand pass it topw.run. - Always call
pw.run(persistence_config=…, terminate_on_error=…, monitoring_level=pw.MonitoringLevel.NONE).
Slides AI Search App — extra fields
templates/slides_ai_search/app.py's App has additional fields tied to its slide-specific pipeline:
| Field | Type | Default | Notes |
|---|---|---|---|
sources |
list[InstanceOf[pw.Table]] |
— | Top-level (no $ prefix) — list of source connectors |
llm |
InstanceOf[pw.UDF] |
— | Top-level — used for both classification and parsing |
retriever_factory |
InstanceOf[pw.indexing.AbstractRetrieverFactory] |
— | Top-level — usearch by default |
search_topk |
int |
6 |
Per-query result count |
details_schema |
FilePath | dict[str, Any] | None |
None |
The slide-classification schema (categories, tags, language, etc.) |
Note that sources, llm, retriever_factory are top-level (read by App) in this template, whereas in the canonical RAG they are $-prefixed YAML anchors consumed by $document_store and question_answerer.
YAML configuration grammar (per-template differences)
| Block | question_answering_rag |
adaptive_rag |
multimodal_rag |
private_rag |
document_indexing |
document_store_mcp_server |
slides_ai_search |
|---|---|---|---|---|---|---|---|
$llm |
OpenAIChat gpt-4.1-mini |
same | same + $parsing_llm: gpt-4o |
LiteLLMChat ollama/mistral |
none | none | top-level llm: gpt-4o |
$embedder |
OpenAIEmbedder text-embedding-3-small |
same | same | SentenceTransformer GIST-small-Embedding-v0 |
SentenceTransformer mxbai-embed-large-v1 |
same as document_indexing | OpenAIEmbedder text-embedding-3-small |
$splitter |
TokenCountSplitter max_tokens=400 |
same | same | same | same | same | none (slide-level chunks) |
$parser |
DoclingParser table_parsing_strategy=llm |
DoclingParser (no table strategy) |
DoclingParser + multimodal_llm + image_parsing_strategy=llm |
DoclingParser table_parsing_strategy=llm |
DoclingParser (no table strategy) |
same as document_indexing | SlideParser (built in App.run) |
$retriever_factory |
UsearchKnnFactory metric=COS |
same | same | same | same | same | top-level retriever_factory: |
$document_store |
DocumentStore |
same | same | same | top-level document_store: |
$document_store: |
n/a (uses SlidesDocumentStore internally) |
| Domain object | question_answerer: BaseRAGQuestionAnswerer |
question_answerer: AdaptiveRAGQuestionAnswerer (n_starting_documents=2, factor=2, max_iterations=4) |
question_answerer: BaseRAGQuestionAnswerer |
question_answerer: AdaptiveRAGQuestionAnswerer (… strict_prompt: true) |
document_store: (DocumentStore directly) |
mcp_http: PathwayMcp transport=streamable-http port=8068 |
top-level details_schema: |
| Default port | 8000 | 8000 | 8000 | 8000 | 8000 | 8000 (App) / 8068 (MCP) | 8000 (App) + 8501 UI + 8080 nginx |
BaseRAGQuestionAnswerer knobs (recognised under question_answerer:)
| Key | Type | Default | Purpose |
|---|---|---|---|
llm |
pw.UDF |
— | Required — the chat model |
indexer |
DocumentStore |
— | Required — the vector index |
search_topk |
int |
6 |
Top-k chunks fetched per query |
prompt_template |
str |
(Pathway internal default) | Custom Jinja-style template; placeholders {query}, {context} |
summarize_template |
str |
(default) | For the summarize endpoint |
AdaptiveRAGQuestionAnswerer knobs
| Key | Type | Default in adaptive_rag |
Default in private_rag |
|---|---|---|---|
n_starting_documents |
int |
2 |
2 |
factor |
int |
2 |
2 |
max_iterations |
int |
4 |
4 |
strict_prompt |
bool |
not set | true |
Connector configuration
Every template's app.yaml includes commented examples for SharePoint and Google Drive sources. Live examples in shipped configs:
Filesystem (default, every template)
- !pw.io.fs.read
path: data # or files-for-indexing for indexing/MCP templates
format: binary
with_metadata: true
Google Drive (commented in every template)
- !pw.io.gdrive.read
object_id: $DRIVE_ID
service_user_credentials_file: gdrive_indexer.json
file_name_pattern:
- "*.pdf"
- "*.pptx"
object_size_limit: null
with_metadata: true
refresh_interval: 30
SharePoint (commented in every template)
- !pw.xpacks.connectors.sharepoint.read
url: $SHAREPOINT_URL
tenant: $SHAREPOINT_TENANT
client_id: $SHAREPOINT_CLIENT_ID
cert_path: sharepointcert.pem
thumbprint: $SHAREPOINT_THUMBPRINT
root_path: $SHAREPOINT_ROOT
with_metadata: true
refresh_interval: 30
Important
SharePoint uses app-only authentication via certificate + thumbprint, not username/password. The cert file (
sharepointcert.pem) must be next toapp.yaml(or wherevercert_path:points) and the matching thumbprint registered with the Azure AD app registration. The app registration must have application (not delegated) permissions forSites.Read.All.
Environment variable reference
| Variable | Type | Default | Required | Used by | Description |
|---|---|---|---|---|---|
OPENAI_API_KEY |
secret | — | conditionally | OpenAI-backed templates | Bearer token for OpenAI Chat + Embeddings |
PATHWAY_LICENSE_KEY |
secret | — | optional | (manual wiring) | Scale-tier features; not auto-read by templates |
PATHWAY_HOST |
string | app (compose) / localhost |
optional | Streamlit UIs | UI's target Pathway service hostname |
PATHWAY_PORT |
int | 8000 |
optional | Streamlit UIs | UI's target Pathway service port |
PATHWAY_REST_CONNECTOR_HOST |
string | 0.0.0.0 |
optional | drive_alert, unstructured_to_sql_on_the_fly |
Bind address for legacy REST connector |
PATHWAY_REST_CONNECTOR_PORT |
int | 8080 |
optional | legacy templates | Port for legacy REST connector |
FILE_OR_DIRECTORY_ID |
string | — | yes (for drive_alert) |
drive_alert |
Google Drive folder/file ID |
GOOGLE_CREDS |
path | templates/drive_alert/secrets.json |
yes (for drive_alert) |
drive_alert |
Service-account JSON path |
SLACK_ALERT_CHANNEL_ID |
string | — | yes (for drive_alert) |
drive_alert |
Slack channel to post alerts to |
SLACK_ALERT_TOKEN |
secret | — | yes (for drive_alert) |
drive_alert |
Slack bot token (xoxb-…) |
SHAREPOINT_URL |
URL | — | yes (if SharePoint enabled) | RAG templates with SharePoint | SharePoint site URL |
SHAREPOINT_TENANT |
string | — | yes (if SharePoint enabled) | RAG templates with SharePoint | Azure AD tenant |
SHAREPOINT_CLIENT_ID |
string | — | yes (if SharePoint enabled) | RAG templates with SharePoint | Azure AD app registration client ID |
SHAREPOINT_THUMBPRINT |
string | — | yes (if SharePoint enabled) | RAG templates with SharePoint | Cert thumbprint registered to the app |
SHAREPOINT_ROOT |
string | — | yes (if SharePoint enabled) | RAG templates with SharePoint | Root path in SharePoint to start indexing |
DRIVE_ID |
string | — | yes (if GDrive enabled) | RAG templates with GDrive | Google Drive folder ID |
PATHWAY_DATA_DIR |
path | ./data/quarterly_earnings |
optional | unstructured_to_sql_on_the_fly |
Where to read source PDFs |
POSTGRESQL_HOST |
string | localhost |
optional | unstructured_to_sql_on_the_fly |
Postgres host |
POSTGRESQL_PORT |
int | 5432 |
optional | unstructured_to_sql_on_the_fly |
Postgres port |
POSTGRESQL_DB |
string | STRUCTUREDDB |
optional | unstructured_to_sql_on_the_fly |
Postgres database name |
POSTGRESQL_USER |
string | user |
optional | unstructured_to_sql_on_the_fly |
Postgres user |
POSTGRESQL_PASSWORD |
secret | password |
optional | unstructured_to_sql_on_the_fly |
Postgres password |
POSTGRESQL_TABLE |
string | quarterly_earnings |
optional | unstructured_to_sql_on_the_fly |
Target table |
FILE_SERVER_URL |
URL | http://localhost:8080/ |
optional | slides_ai_search/ui |
nginx-served slide images/PDFs |
Important
Several variables (
SHAREPOINT_*,DRIVE_ID) are referenced only insideapp.yamlvia$VARinterpolation, so they will not appear in any AST scan ofos.environ.get— they are still required at runtime when the corresponding connector block is uncommented. Keep this matrix as the canonical inventory; the Python source alone is incomplete.
Annotated walkthrough — production-hardened app.yaml
# Persistence: keep the cache between restarts so we don't re-embed on deploy
persistence_mode: !pw.PersistenceMode.PERSISTING
persistence_backend: !pw.persistence.Backend.filesystem
path: "/var/lib/pathway/cache"
# Halt on errors so a partial pipeline doesn't silently serve bad answers
terminate_on_error: true
# Listen only on the in-cluster network; reverse-proxy will terminate TLS
host: "0.0.0.0"
port: 8000
# Sources: SharePoint as primary, with file-system fallback (mounted volume)
$sources:
- !pw.xpacks.connectors.sharepoint.read
url: $SHAREPOINT_URL
tenant: $SHAREPOINT_TENANT
client_id: $SHAREPOINT_CLIENT_ID
cert_path: /etc/secrets/sharepointcert.pem
thumbprint: $SHAREPOINT_THUMBPRINT
root_path: $SHAREPOINT_ROOT
with_metadata: true
refresh_interval: 60
- !pw.io.fs.read
path: /data/local
format: binary
with_metadata: true
# Production model + capacity tuned to OpenAI tier-3 limits
$llm: !pw.xpacks.llm.llms.OpenAIChat
model: "gpt-4o"
retry_strategy: !pw.udfs.ExponentialBackoffRetryStrategy
max_retries: 8
cache_strategy: !pw.udfs.DefaultCache {}
temperature: 0
capacity: 32
async_mode: "fully_async"
$embedder: !pw.xpacks.llm.embedders.OpenAIEmbedder
model: "text-embedding-3-small"
cache_strategy: !pw.udfs.DefaultCache {}
retry_strategy: !pw.udfs.ExponentialBackoffRetryStrategy {}
$splitter: !pw.xpacks.llm.splitters.TokenCountSplitter
max_tokens: 400
$parser: !pw.xpacks.llm.parsers.DoclingParser
table_parsing_strategy: "llm"
async_mode: "fully_async"
chunk: false
cache_strategy: !pw.udfs.DefaultCache {}
$retriever_factory: !pw.indexing.UsearchKnnFactory
reserved_space: 100000 # tune to expected doc count
embedder: $embedder
metric: !pw.indexing.USearchMetricKind.COS
$document_store: !pw.xpacks.llm.document_store.DocumentStore
docs: $sources
parser: $parser
splitter: $splitter
retriever_factory: $retriever_factory
question_answerer: !pw.xpacks.llm.question_answering.BaseRAGQuestionAnswerer
llm: $llm
indexer: $document_store
search_topk: 8
prompt_template: |
You are a customer-support assistant for ACME Corp.
Use ONLY the provided context. If the answer isn't in the context,
say "I don't have that information."
Context: {context}
Question: {query}
Answer:
4. Deployment & Scaling
Single-machine Docker Compose (the shipped path)
Each template ships a docker-compose.yml (most do — multimodal_rag, private_rag, adaptive_rag, slides_ai_search, question_answering_rag, document_indexing, document_store_mcp_server, unstructured_to_sql_on_the_fly, drive_alert all do). For the canonical RAG:
cd templates/question_answering_rag
docker compose up --build
Compose typically starts:
| Service | Image source | Ports | Role |
|---|---|---|---|
app |
template's Dockerfile (Pathway base) |
8000:8000 | Pathway REST API |
ui |
template's ui/Dockerfile |
8501:8501 | Streamlit |
Slides adds an nginx service (8080); unstructured-to-SQL adds a postgres service.
Single Dockerfile (no UI)
cd templates/<template>
docker build -t myorg/llm-app-<template>:5fa97b7 .
docker run -d \
-p 8000:8000 \
-v "$PWD/data:/app/data" \
-v "/var/lib/pathway/cache:/app/Cache" \
--env-file .env \
--restart unless-stopped \
myorg/llm-app-<template>:5fa97b7
Kubernetes (manifest sketch — not bundled)
The repository does not ship Kubernetes manifests. A typical Deployment looks like:
apiVersion: apps/v1
kind: Deployment
metadata:
name: pathway-rag
spec:
replicas: 1 # see "scaling" below before raising
selector:
matchLabels: {app: pathway-rag}
template:
metadata:
labels: {app: pathway-rag}
spec:
containers:
- name: app
image: myorg/llm-app-question_answering_rag:5fa97b7
ports: [{containerPort: 8000}]
envFrom:
- secretRef: {name: pathway-rag-secrets}
volumeMounts:
- {name: data, mountPath: /app/data}
- {name: cache, mountPath: /app/Cache}
resources:
requests: {cpu: 1, memory: 4Gi}
limits: {cpu: 4, memory: 8Gi}
volumes:
- {name: data, persistentVolumeClaim: {claimName: pathway-data}}
- {name: cache, persistentVolumeClaim: {claimName: pathway-cache}}
---
apiVersion: v1
kind: Service
metadata: {name: pathway-rag}
spec:
selector: {app: pathway-rag}
ports: [{port: 80, targetPort: 8000}]
Multi-machine clustering (Pathway Scale)
Pathway supports multi-machine workers via the --addresses runtime flag. The catalogue's app.py files do not invoke --addresses themselves — you would either:
- Modify
app.pyto callpw.runwith theprocessesandnodesarguments (Pathway core API), or - Run multiple instances of the container with appropriate environment variables (Pathway's docs on multi-machine cover the exact incantation).
Both require PATHWAY_LICENSE_KEY (Scale tier) — without it pw.run raises a license error when clustering is requested.
Warning
Do not raise the Kubernetes
replicascount above 1 unless you have wired up Pathway's multi-machine clustering. Two instances of the same App without coordination will both try to be authoritative for the same index and produce duplicated work, not horizontal scaling.
Persistence and state
pw.PersistenceMode controls what the cache holds:
| Mode | Behaviour |
|---|---|
None |
No persistence. Restart re-runs everything from scratch. |
UDF_CACHING (default) |
Cache LLM and embedder calls (memoise expensive UDFs). Restart loses streaming state but skips already-computed embeddings. |
PERSISTING |
Full streaming-state persistence. Restart resumes the pipeline where it left off. |
pw.persistence.Backend.filesystem(path) is the only backend used in the shipped templates. Pathway Scale supports additional backends (S3, Azure Blob) — those require a Scale license and app.py changes.
Restart and reload behaviour
- Config change (
app.yaml): edit,docker compose restart app. Pathway re-reads YAML at process start. - Code change (
app.py): rebuild image withdocker compose up --build. - New documents: Pathway picks them up live; no restart.
- Document deletion: Pathway notices the file disappear; the corresponding chunks leave the index.
OPENAI_API_KEYrotation: restart; key is read once at process start.
5. Operations
Health checks
Every App-class template exposes the pw.io.http.PathwayWebserver-backed endpoints. A simple liveness probe:
curl -f -X POST http://localhost:8000/v1/statistics
Returns 200 with index stats when the pipeline is alive. For Kubernetes:
livenessProbe:
httpGet: {path: /v1/statistics, port: 8000, scheme: HTTP}
initialDelaySeconds: 60
periodSeconds: 30
readinessProbe:
httpGet: {path: /v1/statistics, port: 8000, scheme: HTTP}
initialDelaySeconds: 10
periodSeconds: 5
Note
Initial delay matters — first index build can take a minute on a large corpus. The pipeline will return 503 (or refuse connections) until then.
Logging
Every template configures Python's logging to INFO level with a fixed format:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(name)s %(levelname)s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
Pathway emits log lines like Indexed N documents, OpenAI request retried, and Document store updated. For production, redirect Docker logs to your log aggregator (the included compose files do not configure log drivers — add logging: blocks).
Streamlit UI logging
The UIs log structured events for analytics:
logger.info({"_type": "search_request_event", "query": question})
logger.info({"_type": "search_response_event", "query": question, "response": type(response)})
logger.info({"_type": "rate_event", "rating": event, "rank": idx, ...})
Slides UI also logs _type: create_adjacent_image_urls. These are JSON-shaped log lines — filter on the _type field.
Monitoring
Pathway emits internal monitoring at monitoring_level=pw.MonitoringLevel.NONE (the catalogue's default). Higher levels (IN_OUT, ALL) write to stdout; configure them by editing pw.run(monitoring_level=…) in app.py. For production, integrate with Prometheus by scraping the Pathway metrics endpoint (see Pathway core docs).
Scaling guidance
| Situation | Action |
|---|---|
| OpenAI rate limit hit | Lower capacity: on $llm:, raise max_retries: |
| Slow first index build | Acceptable — cache warms on subsequent runs; mount the cache volume |
| Need multi-machine clustering | Scale-tier license + --addresses configuration |
| Need horizontal replicas (no clustering) | Front N independent instances with a load balancer; each maintains its own index |
| Memory grows | Tune reserved_space: on UsearchKnnFactory to your expected doc count; very large indexes may need a different retriever |
| Slow embedding | Switch from OpenAI to SentenceTransformerEmbedder on a GPU node |
Backup and disaster recovery
| Asset | Backup strategy |
|---|---|
| Source documents | Source of truth lives outside Pathway (filesystem mount, GDrive, SharePoint). Back up at the source. |
Pathway cache (./Cache) |
Treat as ephemeral; rebuilds in minutes. Optional volume backup. |
| Postgres (legacy) | Standard pg_dump; the data lives in Postgres, not the catalogue. |
app.yaml and .env |
Source-controlled (yaml) and secret-managed (env). |
Ops checklist for go-live
- [ ] Replace demo license key with personal key or remove entirely.
- [ ] Move
OPENAI_API_KEY(and Slack/SharePoint secrets) to a secret manager (Kubernetes Secrets, AWS Secrets Manager). - [ ] Set
terminate_on_error: trueso partial-pipeline answers don't reach customers. - [ ] Mount
/app/Cacheto a persistent volume. - [ ] Configure
persistence_mode: !pw.PersistenceMode.PERSISTINGif you need restart-resumption. - [ ] Place the API behind a reverse proxy with TLS (Caddy, nginx, Traefik).
- [ ] Add an authentication layer in front (the templates do not authenticate REST calls).
- [ ] Set log shipping (the templates write to stdout — wire to your log aggregator).
- [ ] Add liveness/readiness probes.
- [ ] Pin OpenAI model versions explicitly (avoid floating
gpt-4o-class names that update silently). - [ ] Test failure modes: kill OpenAI access, kill Postgres (legacy), kill Drive access, confirm the pipeline degrades sensibly.
Tip
Keep one Streamlit UI deployed even if your end users hit the API directly — it's the fastest way to debug the index and confirm a deployment is healthy without writing curl commands.
6. Performance
Where the time and money go
For a typical RAG query on the canonical template:
| Phase | Cost driver | Typical cost (per query) |
|---|---|---|
| Embed query | OpenAI embeddings | <1 ms (cached) / ~50 ms cold |
| Retrieve top-k | usearch in-memory | <1 ms |
| Build prompt | string concat | <1 ms |
| LLM completion | OpenAI Chat | ~1-5 s (cold) / 0 ms (cache hit) |
| Serialise response | JSON encode | <1 ms |
Indexing is the heavy phase: parsing PDFs (especially with DoclingParser table_parsing_strategy: "llm"), chunking, and embedding. A 100-page PDF with table-LLM parsing can take 30-60 s and ~10 cents in OpenAI calls. Subsequent runs hit DefaultCache and are nearly free.
Tuning levers
| Lever | Effect | Recommendation |
|---|---|---|
$llm.capacity: |
Concurrent LLM calls | 8 default; lower if rate-limited; raise if your tier permits |
$llm.retry_strategy.max_retries: |
Retries on transient failure | 6 default; raise to 8-10 in production |
$llm.async_mode: |
"fully_async" (default in modern templates) lets Pathway parallelise |
Keep on |
$splitter.max_tokens: |
Chunk size | 400 default; smaller = more chunks, more retrieval precision, more embedding cost; larger = fewer chunks, more LLM context per chunk |
$retriever_factory.reserved_space: |
usearch initial capacity | 1000 default; tune up to 10x expected docs to avoid resize |
search_topk: (under question_answerer) |
Context document count | 6 default; 4-8 is typical; higher costs more LLM tokens |
Adaptive RAG n_starting_documents |
Iterative retrieval seed | 2 default; lower = cheaper average query, more risk of escalation |
persistence_mode |
UDF_CACHING skips repeated embedding/LLM calls |
Always on in production |
Benchmarks (rough orders of magnitude)
| Workload | Indexing | Per-query latency | Per-query cost |
|---|---|---|---|
10 small PDFs, text-embedding-3-small, gpt-4.1-mini, DoclingParser no LLM tables |
~30 s | 1-3 s | ~$0.001-0.005 |
100 PDFs, multimodal, gpt-4o parsing |
~10-20 min | 2-5 s | ~$0.01-0.05 |
10 PDFs, private_rag (CPU Mistral via Ollama) |
~5 min | 5-30 s | $0 (local) |
Slides AI search, 50 decks, gpt-4o parsing |
~30-60 min | 1-3 s | ~$0.005 (search only) |
These are not from a benchmark suite — they are typical orders of magnitude for the shipped configurations on commodity hardware.
7. Security
Threat model
| Threat | Surface | Mitigation in shipped templates | Production hardening |
|---|---|---|---|
| Stolen LLM API key | .env, environment |
None (key sits in .env) |
Secret manager, key rotation, narrow OpenAI project key with low budget |
| Indexed documents leaking via LLM | OpenAI receives every retrieved chunk | Demo data uses public PDFs | Use private_rag for sensitive data; review OpenAI's data-retention terms |
| Unauthenticated REST API | /v2/answer, /v1/retrieve, /v2/list_documents exposed on 0.0.0.0:8000 |
None (no auth in templates) | Reverse-proxy with auth (mTLS, OAuth2, API keys) |
| Prompt injection in source docs | A malicious PDF can include "ignore previous instructions" content | None | LLM-side guardrails, document source vetting, content moderation on responses |
| LLM-generated SQL injection | unstructured_to_sql_on_the_fly executes LLM-produced SQL |
Constrained prompt grammar (SELECT only) |
Read-only DB role, strictly siloed database, allow-list the column set |
| GitHub issue #75 — security issue (closed) | Reported and addressed in upstream commits | — | Track upstream advisories on pathwaycom/pathway |
| Slack token leak | SLACK_ALERT_TOKEN in env |
None | Rotate on incident; least-privilege bot scope |
| Service-account JSON theft | secrets.json in template directory |
.gitignore for the file |
Do not bake into images; mount as a Kubernetes Secret |
| Network-level interception | All LLM calls over HTTPS by default | OK | TLS-terminate at the proxy; pin certificates if your environment requires |
| Exposed nginx file server | slides_ai_search/nginx serves slide images and PDFs |
None | Bind only to the cluster network; require auth for the file server |
Warning
The shipped templates have no authentication on the REST or MCP endpoints. They are designed for evaluation and internal deployments behind a reverse proxy. Putting a template directly on the public internet without a fronting authn/authz layer is unsafe — anyone can query your index, read
list_documents, and incur LLM cost.
Secret handling
The templates rely on python-dotenv's load_dotenv() plus os.environ.get (legacy) or YAML $VAR interpolation (modern):
| Secret | Where it sits | Rotation |
|---|---|---|
OPENAI_API_KEY |
.env next to app.py |
restart |
SLACK_ALERT_TOKEN |
.env |
restart |
Service-account JSON (secrets.json) |
next to app.py |
restart |
SharePoint cert (sharepointcert.pem) |
next to app.yaml |
restart |
POSTGRESQL_PASSWORD |
.env |
restart |
PATHWAY_LICENSE_KEY |
hard-coded in app.py (manual edit) |
restart |
For production, replace .env with environment variables sourced from your secret manager. Do not bake secrets into images.
Auditing
Pathway logs every retry and connector poll. The Streamlit UIs log structured search_request_event / search_response_event lines that can feed audit trails. For full audit coverage of "who asked what":
- Front the API with a proxy that logs (Caddy, Traefik, ALB).
- Pass user identity through to the request body or a header that ends up in the prompt.
- Capture the JSON-shaped UI log lines.
Caution
Pathway's
pw.io.slack.send_alertsposts to Slack with the bot token in plaintext at request time. Anyone with read access to the running container's environment or to outgoing-traffic mirrors can recover the token. Treat the container as a security boundary equivalent to the Slack bot itself.
License compliance
| Component | License |
|---|---|
pathwaycom/llm-app (this repo) |
MIT (LICENSE file at repo root) |
| Pathway core (Python+Rust) | BSL 1.1 → Apache 2.0 after 4 years |
Pathway xpacks (pathway.xpacks.llm) |
BSL 1.1 → Apache 2.0 after 4 years |
| OpenAI API (terms separate) | OpenAI ToS |
| LiteLLM | MIT |
| Sentence-Transformers embeddings | Apache 2.0 (most models) |
| Ollama | MIT |
| Streamlit | Apache 2.0 |
The catalogue is MIT and you can use, modify, and redistribute the templates freely. The Pathway engine you depend on is under BSL 1.1 — for commercial production use of advanced features you need a Pathway Scale license (which also unlocks the demo telemetry-free mode). Read the BSL terms carefully if you are forking Pathway itself.
8. Developer Internals
Repository structure
pathwaycom/llm-app/
├── README.md ← marketing landing + selector table
├── CONTRIBUTING.md
├── CODE_OF_CONDUCT.md
├── LICENSE ← MIT
├── pyproject.toml ← linter config; package-mode = false
├── setup.cfg ← flake8/black config
├── .gitignore
├── assets/ ← logos, marketing images
├── cookbooks/
│ └── self-rag-agents/
│ ├── pathway_langgraph_agentic_rag.ipynb
│ └── pathway_deploy_langgraph_agents.ipynb
└── templates/
├── adaptive_rag/
│ ├── app.py ← App(BaseModel), question_answerer
│ ├── app.yaml
│ ├── Dockerfile
│ ├── requirements.txt ← python-dotenv~=1.0
│ ├── .env.example
│ ├── README.md
│ └── data/ ← seed PDF
├── document_indexing/ ← App(BaseModel), document_store
│ └── … (similar shape)
├── document_store_mcp_server/ ← App(BaseModel), mcp_http (PathwayMcp)
│ └── …
├── drive_alert/ ← LEGACY — procedural run(*, ...)
│ ├── app.py
│ ├── __init__.py ← re-exports run
│ ├── ui/server.py
│ └── …
├── multimodal_rag/ ← App(BaseModel), question_answerer + $parsing_llm
│ └── …
├── private_rag/ ← App(BaseModel), AdaptiveRAG + LiteLLM
│ └── …
├── question_answering_rag/ ← canonical App(BaseModel), question_answerer
│ ├── app.py
│ ├── app.yaml
│ ├── docker-compose.yml
│ ├── ui/ ← Streamlit RAGClient UI
│ │ ├── ui.py
│ │ ├── Dockerfile
│ │ ├── requirements.txt ← streamlit==1.37.0, load_dotenv
│ │ └── static/pathway-logo-black.png
│ └── data/
├── slides_ai_search/ ← App(BaseModel) + SlidesDocumentStore + nginx
│ ├── app.py
│ ├── app.yaml
│ ├── nginx/ ← file-server stack
│ │ ├── Dockerfile
│ │ └── nginx.conf
│ ├── pathway_slides_ai_search/ ← per-template helper module
│ │ └── __init__.py
│ └── ui/ ← Streamlit UI (categories, languages, slide cards)
│ ├── ui.py
│ └── requirements.txt ← includes openai==1.35.10, beautifulsoup4
└── unstructured_to_sql_on_the_fly/ ← LEGACY — procedural run(*, ...)
├── app.py
├── postgres/init-db.sql
├── ui/server.py
└── data/quarterly_earnings/ ← seed PDFs
The shared App-class lifecycle (deep dive)
Every modern template's app.py follows this exact recipe:
import logging
from warnings import warn
import pathway as pw
from dotenv import load_dotenv
# template-specific xpack imports (one of):
from pathway.xpacks.llm.question_answering import SummaryQuestionAnswerer
from pathway.xpacks.llm.servers import QASummaryRestServer
# or DocumentStore + DocumentStoreServer
# or PathwayMcp
from pydantic import BaseModel, ConfigDict, InstanceOf
pw.set_license_key("demo-license-key-with-telemetry")
logging.basicConfig(level=logging.INFO, format="…", datefmt="…")
load_dotenv()
class App(BaseModel):
# exactly one domain object: question_answerer / document_store / mcp_http
question_answerer: InstanceOf[SummaryQuestionAnswerer]
host: str = "0.0.0.0"
port: int = 8000
with_cache: bool | None = None # deprecated
persistence_backend: pw.persistence.Backend | None = None
persistence_mode: pw.PersistenceMode | None = pw.PersistenceMode.UDF_CACHING
terminate_on_error: bool = False
def run(self) -> None:
server = QASummaryRestServer(self.host, self.port, self.question_answerer)
# … persistence resolution …
pw.run(persistence_config=…, terminate_on_error=…,
monitoring_level=pw.MonitoringLevel.NONE)
model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
if __name__ == "__main__":
with open("app.yaml") as f:
config = pw.load_yaml(f)
app = App(**config)
app.run()
The five blocks that vary across modern templates:
- The xpack import — which class is used as the domain object.
- The single Pydantic field —
question_answerer,document_store, ormcp_http. - The server instantiation in
run()— which xpack server (or none, for MCP) is built. - Whether
with_cacheexists — the MCP template omits the deprecated field. base_dirresolution —slides_ai_searchreads the YAML relative to__file__because its compose mounts a non-default working directory.
Legacy template internals
drive_alert/app.py
A 200-line procedural run(*, ...) that builds a four-stage Pathway dataflow:
- Index build:
pw.io.gdrive.read→UnstructuredParser→TokenCountSplitter(min_tokens=40, max_tokens=120)→OpenAIEmbedder→KNNIndex. - Query intake + intent detection:
pw.io.http.rest_connector(port 8080, schemaQueryInputSchema(query, user)),build_prompt_check_for_alert_request_and_extract_queryUDF,OpenAIChat,split_answerUDF that yields(alert_enabled: bool, cleaned_query: str). - Query answer: for each query, fetch top-3 nearest chunks, build prompt, call
OpenAIChat, send back viaresponse_writer. - Alert loop: filter
alert_enabled=Truequeries, runpw.stateful.deduplicatewith an LLM-drivenacceptor(new, old) -> boolthat asks the LLM whether two answers materially differ. When the dedupe accepts a new answer,pw.io.slack.send_alertsposts a notification.
Key UDFs:
| UDF | Purpose |
|---|---|
build_prompt(documents, query) |
RAG prompt construction |
build_prompt_check_for_alert_request_and_extract_query(query) |
Alert-intent classifier prompt |
split_answer(answer) -> tuple[bool, str] |
Parse "Yes/No. <query>" into (alert_enabled, query) |
build_prompt_compare_answers(new, old) |
Dedupe-acceptor prompt |
make_query_id(user, query) |
Stable hash for deduplication instance |
construct_notification_message(query, response) |
Slack message body |
construct_message(response, alert_flag, metainfo=None) |
UI-side response with bell emoji when alerting |
decision_to_bool(decision) |
LLM "yes/no" → Python bool |
Schemas:
class DocumentInputSchema(pw.Schema):
doc: str
class QueryInputSchema(pw.Schema):
query: str
user: str
Defaults that matter:
| Parameter | Default |
|---|---|
embedder_locator |
text-embedding-ada-002 |
embedding_dimension |
1536 |
model_locator |
gpt-3.5-turbo |
max_tokens (response) |
400 |
temperature |
0.0 |
refresh_interval (GDrive) |
30 seconds |
splitter min/max tokens |
40/120 |
KNN k |
3 |
delete_completed_queries |
False |
autocommit_duration_ms |
50 |
unstructured_to_sql_on_the_fly/app.py
A 240-line procedural run(*, ...) that builds two pipelines in one process:
Pipeline 1 — extraction:
files = pw.io.fs.read(data_dir, format="binary")
parser = UnstructuredParser()
unstructured_documents = files.select(texts=parser(pw.this.data)).select(
texts=strip_metadata(pw.this.texts)
)
structured_table = structure_on_the_fly(
unstructured_documents, api_key, model_locator, max_tokens, temperature
)
pw.io.postgres.write(structured_table, postgreSQL_settings, postresql_table)
pw.io.csv.write(structured_table, "./data/quarterly_earnings.csv")
structure_on_the_fly builds a token-aware prompt, calls OpenAIChat, parses JSON, unpacks columns, types them. Schema:
class FinancialStatementSchema(pw.Schema):
company_symbol: str
year: int
quarter: str
revenue_md: float
eps: float
net_income_md: float
Pipeline 2 — NL→SQL query:
query, response_writer = pw.io.http.rest_connector(
host=host, port=port, schema=NLQuerySchema,
autocommit_duration_ms=50, delete_completed_queries=True,
)
query += query.select(prompt=build_prompt_query(postresql_table, pw.this.query))
query += query.select(sql_query=model(prompt_chat_single_qa(pw.this.prompt)))
@pw.udf
def execute_sql_query(sql_query):
cursor.execute(sql_query); answer = cursor.fetchall(); conn.commit(); return answer
query = query.select(query, sql_query, result=execute_sql_query(pw.this.sql_query))
answers = query.select(result=pw.make_tuple(pw.this.sql_query, pw.this.result))
response_writer(answers)
The prompt grammar pinned by build_prompt_query:
SELECTclause specifies columns or aggregators (SUM,COUNT,MIN,MAX,AVG).WHEREuses standard binary operators withAND/OR.GROUP BYincludes anyWHEREcolumns to avoidpsycopg2.errors.GroupingError.- If no aggregator, always include
company_symbol, year, quarter.
Caution
The prompt is the only SQL guard. An LLM can be tricked into producing malicious SQL with sufficient prompt injection. Run against a read-only Postgres role; never share a connection with privileged tables.
Streamlit UIs
Every UI is a single ui.py (or server.py) that:
- Loads
.env, readsPATHWAY_HOSTandPATHWAY_PORT. - Constructs
RAGClient(url=f"http://{PATHWAY_HOST}:{PATHWAY_PORT}"). - Renders a Streamlit page with a query input, a sidebar listing indexed docs (from
conn.list_documents(keys=[])), and a result region. - On submit, calls
conn.answer(question, ...)(or for slides, a filtered variant) and renders.
question_answering_rag/ui/ui.py (5408 chars) is the simplest. slides_ai_search/ui/ui.py (11886 chars) adds:
- Image rendering via the nginx file server (
FILE_SERVER_URL). - Filename → slide-ID parsing (
parse_slide_id_components,create_slide_url,get_adjacent_image_urls). - Category and language multi-selects → JMESPath filter strings (
combine_filters). - BeautifulSoup scrape of the nginx index page to surface "Indexing in Progress" files.
drive_alert/ui/server.py and unstructured_to_sql_on_the_fly/ui/server.py are smaller (2047 and 2502 chars) — they use the legacy requests.post against port 8080 rather than RAGClient.
Module reference
| Module | Path | Purpose |
|---|---|---|
templates/question_answering_rag/app.py |
canonical RAG App | Pydantic shell, BaseRAGQuestionAnswerer, QASummaryRestServer |
templates/adaptive_rag/app.py |
adaptive RAG App | identical shell + AdaptiveRAGQuestionAnswerer |
templates/multimodal_rag/app.py |
multimodal RAG App | identical shell + multimodal parser config in YAML |
templates/private_rag/app.py |
private RAG App | identical shell + LiteLLMChat + SentenceTransformerEmbedder |
templates/document_indexing/app.py |
indexing-only App | DocumentStore + DocumentStoreServer, no LLM |
templates/document_store_mcp_server/app.py |
MCP server App | PathwayMcp (streamable-http) on port 8068 |
templates/slides_ai_search/app.py |
slides App | SlidesDocumentStore + SlideParser; helper module pathway_slides_ai_search |
templates/drive_alert/app.py |
legacy GDrive+Slack | procedural run(), KNNIndex, REST on 8080, Slack output |
templates/unstructured_to_sql_on_the_fly/app.py |
legacy PDF→SQL | procedural run(), two pipelines, Postgres output, NL2SQL on REST |
templates/question_answering_rag/ui/ui.py |
RAG Streamlit UI | RAGClient + sidebar list of indexed files |
templates/slides_ai_search/ui/ui.py |
Slides Streamlit UI | RAGClient + image rendering + category/language filters |
templates/drive_alert/ui/server.py |
legacy GDrive UI | Streamlit hitting port 8080, displays response |
templates/unstructured_to_sql_on_the_fly/ui/server.py |
legacy SQL UI | Streamlit hitting port 8080, parses (sql_query, rows) tuple result |
templates/slides_ai_search/pathway_slides_ai_search/__init__.py |
slides helpers | DeckRetrieverWithFileSave, add_slide_id, get_model (per-template helpers) |
cookbooks/self-rag-agents/pathway_langgraph_agentic_rag.ipynb |
tutorial notebook | LangGraph + Pathway agentic RAG |
cookbooks/self-rag-agents/pathway_deploy_langgraph_agents.ipynb |
tutorial notebook | deploy the same graph |
Code patterns and conventions
Pydantic-with-extra-forbid is the hard contract. Every modern template sets extra="forbid". This shifts every config error from runtime mystery to startup-time validation error with a clear message. Keep this when forking.
YAML anchors over inheritance. $llm, $embedder, $splitter, $parser, $retriever_factory are conventionally $-prefixed; question_answerer, host, port, persistence_*, terminate_on_error are top-level. The $-prefix doesn't change Pathway behaviour — it's a project convention indicating "this is referenced elsewhere in the YAML, not consumed by App". Keep the convention.
pw.set_license_key at module-import time. Every app.py calls pw.set_license_key("demo-…") at the top, before App is even defined. Do not move this — Pathway needs the license set before the dataflow is described.
load_dotenv() once, early. Same pattern in every template.
monitoring_level=pw.MonitoringLevel.NONE. The default for the catalogue. Raise to IN_OUT or ALL for production.
Procedural legacy is not deprecated as a Pathway pattern. Pathway still supports building dataflows procedurally (in fact, app.py is a procedural Pathway program — it just happens to be pre-wired by Pydantic). The legacy templates are "legacy" only in the sense that they predate the App-class catalogue convention; the underlying API is current.
Testing strategy
The repository ships no test suite — there are no tests/ or pytest.ini files. The catalogue's correctness story is:
- Each template has a working seed dataset (PDFs in
data/orfiles-for-indexing/). docker compose up --buildis the smoke test: it must produce indexed documents and answer a curl query.- Upstream Pathway has its own test suite that covers the xpacks the templates use.
For your fork, recommended tests:
- Build smoke test —
docker compose up --buildreachesINFO Indexed N documents. - Endpoint smoke test —
curl /v2/answerreturns a non-empty JSONresponse. - YAML schema test —
python -c "import yaml; from app import App; cfg = yaml.safe_load(open('app.yaml')); App(**cfg)"passes Pydantic validation. - Connector test — point at a tiny S3 bucket / SharePoint folder / Drive folder and confirm indexing.
Potential improvements (tech debt)
- Legacy templates lack the App-class shell.
drive_alertandunstructured_to_sql_on_the_flycould be refactored to expose configuration viaapp.yamllike the modern templates. They predate the convention; refactoring is straightforward but breaks API surface. - No central README with the selector table. The root README is marketing; users discover templates by ls'ing
templates/. Adding a selector table to the README would close GitHub issue #124. (This guide's section 4.1 is the de-facto fix.) PATHWAY_LICENSE_KEYshould be read from env by default. Hard-coding"demo-license-key-with-telemetry"in everyapp.pyis a footgun for production deployments.- Streaming response support (issue #46) —
OpenAIChatdoesn't exposestream=Truethrough Pathway xpacks. Would require upstream library changes. - Templates do not expose Pathway monitoring metrics. A metrics endpoint and a tiny Prometheus scrape would help operators.
- No authentication on REST/MCP endpoints. A simple API-key middleware is a 50-line fix, currently absent.
- No rate-limiting on REST/MCP endpoints. Any client can hammer the LLM and run up the OpenAI bill.
- Per-template
pyproject.tomlwould help dependency pinning. Currentrequirements.txtfiles are sparse — they rely on the Pathway base image for everything substantive. - Tests — see above.
Contributing
The repository is open-source under MIT (see LICENSE). CONTRIBUTING.md exists at the root with the contribution workflow. Pull requests typically:
- Add a new template (mirror the App-class shape — Pydantic shell +
app.yaml+ Dockerfile + compose + README). - Update an existing template to a newer Pathway xpack version.
- Refactor a legacy template to App-class.
- Fix a connector configuration.
For substantial work (e.g., wiring multi-machine clustering into the templates), open an issue first.
9. Integration Points
Inbound integrations (data sources)
| Integration | Class | Auth | Used by templates | Notes |
|---|---|---|---|---|
| Local filesystem | pw.io.fs.read |
none | every template | Default; watches the path live |
| Google Drive | pw.io.gdrive.read |
service-account JSON | drive_alert (live), every RAG template (commented example) |
refresh_interval: 30 default; can filter by file_name_pattern: |
| Microsoft SharePoint | pw.xpacks.connectors.sharepoint.read |
app-only cert + thumbprint | every RAG template (commented example) | Requires Azure AD app registration with Sites.Read.All |
| Amazon S3 | pw.io.s3.read |
AWS credentials | none directly (mentioned in drive_alert comments) |
Drop into $sources: |
| HTTP REST | pw.io.http.rest_connector |
none | drive_alert, unstructured_to_sql_on_the_fly |
Also acts as input — this is how queries arrive |
Outbound integrations (sinks and services)
| Integration | Class | Auth | Used by | Notes |
|---|---|---|---|---|
| Slack | pw.io.slack.send_alerts |
bot token | drive_alert |
Posts to a single channel |
| Postgres | pw.io.postgres.write |
user/password | unstructured_to_sql_on_the_fly |
Writes typed columns from the schema |
| CSV | pw.io.csv.write |
none | unstructured_to_sql_on_the_fly |
Mirrors output to disk |
| OpenAI Chat Completions | OpenAIChat (xpacks) |
API key | most templates | Configurable retry + cache |
| LiteLLM (Ollama, Anthropic, Bedrock, Vertex, …) | LiteLLMChat |
varies by provider | private_rag |
One class, dozens of providers |
| OpenAI Embeddings | OpenAIEmbedder |
API key | most templates | text-embedding-3-small default |
| SentenceTransformer | SentenceTransformerEmbedder |
none (local) | private_rag, document_indexing, document_store_mcp_server |
First run downloads the model |
| MCP-over-HTTP | PathwayMcp |
none in template | document_store_mcp_server |
Streamable HTTP transport |
Client-side integration
The Streamlit UIs use pathway.xpacks.llm.question_answering.RAGClient — a Python client pointed at the Pathway REST API. You can use the same client from any Python application:
from pathway.xpacks.llm.question_answering import RAGClient
conn = RAGClient(url="http://your-pathway-host:8000")
response = conn.answer("Your question here", return_context_docs=True)
print(response["response"])
print(response["context_docs"])
# Other methods used by the shipped UIs
docs = conn.list_documents(keys=[]) # for a sidebar/admin view
For non-Python clients, hit the REST endpoints directly (see section 4.3 of the User Guide).
MCP integration
document_store_mcp_server exposes the live Pathway document store to MCP-aware clients via streamable HTTP. Configuration in app.yaml:
mcp_http: !pw.xpacks.llm.mcp_server.PathwayMcp
name: "Streamable MCP Server"
transport: "streamable-http"
host: "0.0.0.0"
port: 8068
serve:
- $document_store
To consume from Claude Desktop or another MCP client, register http://your-host:8068 as an MCP server. The server exposes the document store as MCP tools (retrieve, list, etc.) — your client sees them as native tool calls.
LangGraph integration (cookbook only)
cookbooks/self-rag-agents/ demonstrates wiring Pathway's live DocumentStore into a LangGraph state machine. The notebooks pin LangGraph and pathway[all] but the cookbooks are not deployable — they are tutorials. To productionise, port the LangGraph state machine into a templates/<your_name>/app.py that spins up its own Pathway document store, exposes a REST endpoint, and runs the graph as a UDF over the query stream.
Tip
The cookbook pattern is the bridge for adding agentic behaviour to a Pathway-powered RAG: keep the live document store on Pathway, run the agent reasoning in LangGraph, expose the combined service as REST or MCP. The catalogue does not yet ship this combined shape as a deployable template — building it is a natural extension if you need multi-step agentic RAG.