llm-app Documentation

A no-code RAG server that keeps answers in sync with your documents. Works with 8 vector databases. Built on Pathway.

58,781 stars Python Python 6 files ยท ~22,941 tokens #chromadb#document-qa#document-search#duckdb#live-data#llm#milvus#mongodb
RAW Doc

Docker & Self-Hosted Deployment Verified OCI

Deploy llm-app on any server, homelab, or cloud container platform.

serviette

A universal, no-code, always up-to-date RAG server for any vector database
โ€” powered by the Pathway Live Data Framework.

serviette stands up Retrieval-Augmented Generation over your own documents โ€”
text, Office files, PDFs, scans, and (with API keys) audio and video โ€”
without writing code. Point it at a folder, choose a vector database and an
embedder in a YAML file (or generate one with the wizard), and run one
command (serviette up) or the two components separately:

  • serviette indexer โ€” a Pathway streaming pipeline that watches your files,
    parses and chunks them, embeds the chunks and keeps your vector DB in sync
    (additions, modifications and deletions) in real time.
  • serviette server โ€” a FastAPI service that embeds incoming queries and
    retrieves the most relevant chunks (and, optionally, answers them with an
    LLM). It also serves the web chat UI on / (same port, same origin) unless
    server.serve_frontend is disabled; the REST API lives under /api/v1.

The indexer and the server are fully decoupled: they share only the vector
database, so you can scale them independently.


1. Installation

serviette requires Python โ‰ฅ 3.10 (the minimum supported by Pathway).

bash
pip install "serviette[local]" --extra-index-url https://download.pytorch.org/whl/cpu

The local extra brings sentence-transformers โ€” the free, credential-free
embedder that serviette wizard selects by default. It pulls the PyTorch
stack, and the --extra-index-url flag makes pip take PyTorch's CPU build
(~0.8 GB). Without it, PyTorch's default Linux wheel is installed, which
carries the CUDA libraries (~5 GB) whether or not the machine has an NVIDIA
GPU โ€” leave the flag out only if you want to embed on such a GPU. (With
uv, add --index-strategy unsafe-best-match as well.) If you intend to
embed with OpenAI instead, a plain pip install serviette is enough (pick
openai in the wizard).
From a checkout, pip install -e ".[dev,local]" โ€” see the README's
Development section for the full walkthrough.
OpenAI support (the default /rag LLM and the OpenAI embedder) is built
in โ€” just set OPENAI_API_KEY.

Optional extras: docling (layout-aware PDF + Office parsing), ocr (scanned images),
pyfilesystem (FTP/SFTP/WebDAV/ZIP sources), gdrive, sharepoint, plus
one extra per vector-DB client (qdrant, pgvector, โ€ฆ, or all).


2. Config wizard

The fastest way to a chat is serviette demo (see the README); the fastest
way to a config for your documents is the interactive wizard:

bash
serviette wizard

It asks only what it must (the DuckDB + local-embeddings happy path is seven
questions) and writes a YAML file; everything else โ€” port, persistence,
chunking, parser routing โ€” gets silent, documented defaults you can edit
later. You only have to provide document paths, vector-DB connection
details (for client-server backends), API keys for keyed providers, and your
Pathway license key.

Then run the two components (in separate terminals or on separate machines):

bash
# One command: indexer + server supervised together (dev/demo convenience)
serviette up --config config.yaml         # open http://localhost:8989

# ... or run the components separately (how production deploys them):
# 1. Index your documents into the vector DB and keep it live
serviette indexer --config config.yaml

# 2. Serve the chat UI + retrieval / RAG API on one port
serviette server --config config.yaml     # open http://localhost:8989

# (optional, split deployments only) standalone UI tier on another host
serviette frontend --config config.yaml   # open http://localhost:3000

indexer, server and up check the obvious mistakes before starting
anything: a source folder that does not exist, a ${VAR} in the config that
is not exported, an openai-typed embedder or llm with no key in the config
or in OPENAI_API_KEY, a backend that cannot be shared by two processes. Each
stops with a one-paragraph message, not a traceback. If the indexer or the
server dies later, serviette up ends with a framed serviette up STOPPED
block that names the cause when it recognises it (missing or rejected API
key, OpenAI rate limit or quota, a port already in use, an unreachable
database) and what to do.

By default serviette up shows only its own progress, warnings, errors and
the URL to open; the indexer's and the server's routine INFO lines (the
engine's persistence and connector monitoring, one line per HTTP request)
are hidden. serviette up --verbose passes them all through. The hidden
lines are still kept for the STOPPED report, so a diagnosis never depends
on the flag.

Query the API (versioned under /api/v1; the pre-versioning /retrieve,
/rag and /health paths still work as deprecated aliases):

bash
curl -X POST http://localhost:8989/api/v1/retrieve \
  -H 'Content-Type: application/json' \
  -d '{"query": "how does persistence work?", "k": 5}'
jsonc
{
  "results": [
    {"text": "...", "metadata": {"path": "/data/docs/guide.pdf", ...}, "score": 0.95}
  ]
}

If you configured an llm section, the /rag endpoint also answers questions:

bash
curl -X POST http://localhost:8989/api/v1/rag \
  -H 'Content-Type: application/json' \
  -d '{"query": "summarize the onboarding guide", "k": 5}'
jsonc
{ "answer": "...", "sources": [ {"text": "...", "metadata": {...}, "score": 0.91} ] }

GET /api/v1/documents lists the indexed documents whose name or path
contains q (case_sensitive=true to match case; the default ignores it).
The chat page's Documents panel is built on it and asks only when a
search is submitted, never on load โ€” an index may hold tens of thousands of
files. Over limit matches (default 100, at most 1000) only the counts are
returned and the page asks to narrow the search:

bash
curl 'http://localhost:8989/api/v1/documents?q=report&case_sensitive=false'
jsonc
{ "total": 1240, "matched": 2, "truncated": false,
  "documents": [ {"id": "/data/docs/report_2026.pdf", "name": "report_2026.pdf",
                  "metadata": {"path": "...", "modified_at": 1790000000, "size": 12345}, "chunks": 41}, ... ] }

name is what a person calls the document โ€” the file name for folder, S3
and SharePoint sources, the Drive title for Google Drive โ€” and id is the
full identity the source reports (path or Drive id). Pinecone cannot
enumerate documents; there the endpoint answers 501.


3. Pathway License Key

Pathway requires a free license key. Get yours in one click at
https://pathway.com/framework/get-license (email or LinkedIn sign-in).

Provide it either via the config file:

yaml
pathway_license_key: ${PATHWAY_LICENSE_KEY}

or directly as a string. Using the ${PATHWAY_LICENSE_KEY} form keeps the key
out of the file โ€” set it in the environment:

bash
export PATHWAY_LICENSE_KEY="your-key-here"

serviette wizard notices an exported PATHWAY_LICENSE_KEY and offers to
write the ${PATHWAY_LICENSE_KEY} reference instead of asking for the key;
serviette demo uses the variable the same way.

The key is applied when the indexing graph is initialized.


4. Config reference

A single YAML file can configure both the indexer and the server (a universal
config), or you can split it into an indexer-only and a server-only file. Any
string value supports ${ENV_VAR} interpolation, so credentials never need to be
hardcoded. A reference to a variable that is not set is a startup error naming
the variable and the config key using it (it does not silently become an empty
string, which would only surface later as a provider 401).

Field Type Default Used by Description
pathway_license_key str โ€” indexer Free Pathway license key (or ${ENV}).
sources list โ€” (required) indexer One or more sources to index (mixed types allowed).
sources[].type fs|gdrive|s3|sharepoint|pyfilesystem fs indexer Source type โ€” see Sources below.
sources[].mode streaming|static streaming indexer streaming watches continuously; static indexes once and exits.
sources[].max_backlog_size int|null 1000 indexer Advanced. Backpressure bound on in-flight entries per connector (fs/gdrive/s3). Keeps memory flat during bulk backfills; the default suits virtually everyone โ€” not offered by the wizard, edit the YAML to change. null disables.
fs sources[].path str โ€” (required) indexer Directory to watch (~ is expanded). Must exist when the indexer starts โ€” a missing folder is an error, not an empty index.
fs sources[].glob str **/* indexer Glob of files to include.
gdrive sources[].object_id str โ€” (required) indexer Drive folder or file id (folders scanned recursively).
gdrive sources[].service_user_credentials_file str โ€” (required) indexer Path to a Google service-account JSON credentials file.
gdrive sources[].file_name_pattern str|list โ€” indexer Optional file-name glob(s), e.g. *.pdf.
gdrive sources[].object_size_limit int โ€” indexer Optional max object size in bytes.
s3 sources[].bucket str โ€” (required) indexer S3 bucket name.
s3 sources[].path str "" indexer Key prefix to index ("" = whole bucket).
s3 sources[].access_key / .secret_access_key str AWS chain indexer Credentials (or ${ENV}).
s3 sources[].region / .endpoint str โ€” indexer Region; custom endpoint for MinIO etc.
s3 sources[].with_path_style bool false indexer Path-style addressing (MinIO and most self-hosted).
sharepoint sources[].url str โ€” (required) indexer Site URL, e.g. https://co.sharepoint.com/sites/X.
sharepoint sources[].tenant / .client_id str โ€” (required) indexer App registration (certificate auth).
sharepoint sources[].cert_path / .thumbprint str โ€” (required) indexer Certificate .pem and its thumbprint.
sharepoint sources[].root_path str โ€” (required) indexer Directory/file to index.
sharepoint sources[].recursive bool true indexer Scan nested directories.
sharepoint sources[].refresh_interval int 30 indexer Polling period, seconds.
pyfilesystem sources[].fs_url str โ€” (required) indexer Any PyFilesystem URL: ftp://โ€ฆ, ssh://โ€ฆ, webdav://โ€ฆ, zip://โ€ฆ, osfs://โ€ฆ (extra: serviette[pyfilesystem]; some protocols need a driver package).
pyfilesystem sources[].path str "" indexer Path inside the opened filesystem (recursive).
pyfilesystem sources[].refresh_interval float 30.0 indexer Seconds between scans (streaming mode).
parser list of rules keyless-first defaults indexer Routing rules {match: ["*.pdf"], type: docling, options: {...}} โ€” globs are tried against the file name and its path as the source reports it (* matches /, so */scans/*.png selects a folder), first match wins; unmatched files use built-in defaults (textโ†’utf8, pdfโ†’docling/pypdf, officeโ†’unstructured, imagesโ†’paddle_ocr, audioโ†’whisper if OPENAI_API_KEY, videoโ†’twelvelabs_video if TWELVELABS_API_KEY, else skip+warn). Types: utf8, pypdf, docling, unstructured, paddle_ocr, vision_image, vision_slide, whisper, twelvelabs_video, skip. Changing routing is fingerprint-guarded.
vector_db.type duckdb|pgvector|milvus|qdrant|chroma|weaviate|pinecone|mongodb โ€” (required) both Vector database backend (see Backends).
duckdb vector_db.path str โ€” (required) both Path to the DuckDB database file.
duckdb vector_db.table str serviette_embeddings both Target table.
pgvector vector_db.connection_string str โ€” (required) both postgresql://user:pass@host/db.
pgvector vector_db.table str serviette_embeddings both Target table.
milvus vector_db.uri str โ€” both Milvus URI, e.g. http://localhost:19530.
milvus vector_db.host / .port str / int localhost / 19530 both Used if uri is omitted.
milvus vector_db.collection str serviette_embeddings both Milvus collection.
qdrant vector_db.host str localhost both Qdrant host.
qdrant vector_db.rest_port / .grpc_port int 6333 / 6334 server / indexer REST (server) and gRPC (indexer) ports.
qdrant vector_db.api_key str โ€” both Qdrant Cloud API key.
qdrant vector_db.collection str serviette_embeddings both Auto-created (cosine) if missing.
chroma vector_db.host / .port str / int localhost / 8000 both Chroma server address.
chroma vector_db.tenant / .database str Chroma defaults both Multi-tenant selectors.
chroma vector_db.collection str serviette_embeddings both Auto-created (cosine hnsw:space).
weaviate vector_db.http_host / .http_port str / int localhost / 8080 both HTTP endpoint.
weaviate vector_db.grpc_host / .grpc_port str / int http_host / 50051 server gRPC endpoint (v4 client).
weaviate vector_db.api_key str โ€” both API key.
weaviate vector_db.collection str ServietteEmbeddings both Auto-created (capitalized name).
pinecone vector_db.index_name str โ€” (required) both Auto-created (serverless, embedder's dimension, cosine).
pinecone vector_db.api_key str ${PINECONE_API_KEY} both Pinecone API key.
pinecone vector_db.namespace str "" both Optional namespace.
mongodb vector_db.connection_string str โ€” (required) both mongodb+srv://... for Atlas.
mongodb vector_db.database / .collection str โ€” / serviette_embeddings both Target collection (auto-created).
mongodb vector_db.vector_index str vector_index server Atlas vectorSearch index name.
embedder.type str openai both Embedder family (see below).
embedder.model str provider default both Model name.
embedder.api_key str โ€” both API key (or ${ENV}).
splitter.type token_count|recursive|null token_count indexer Chunking strategy. null disables splitting โ€” the whole document is one chunk (short, self-contained documents such as FAQ entries or product cards); written as type: null or type: "null", both work.
splitter.chunk_size int 512 indexer Max chunk size (tokens).
splitter.chunk_overlap int 50 indexer Overlap (used by recursive).
indexer.fetch_retries int 3 indexer Retries (exponential backoff from 1s) of a document's byte fetch before it is given up on: indexed as empty and ERROR-logged. Remote sources fail transiently under bulk backfills.
indexer.workers int 1 indexer Worker processes (sharded via pathway spawn). Raise for large backfills โ€” each worker carries its own local embedding stack; memory depends on the model, and library pages may be shared. The benchmarks run with 8.
workdir_path str ./serviette-workdir indexer serviette's working directory โ€” everything it keeps between runs: the configuration fingerprint at its top level, the Pathway persistence state (and the parse cache, runtime_calls/) under persistence/. Relative to the process's working directory. Silent default โ€” the wizard does not ask.
persistence.enabled bool true indexer See Persistence.
persistence.backend filesystem filesystem indexer Persistence backend.
server.host / server.port str / int 127.0.0.1 / 8989 server Bind address. Loopback by default; set 0.0.0.0 explicitly to listen on all interfaces (containers, remote access) โ€” see Security.
server.serve_frontend bool true server Serve the chat UI on / from the same port (API stays under /api/v1).
server.cors_origins list[str] [] (disabled) server Opt-in CORS allowlist for third-party browser frontends calling the API directly from another origin. serviette's own UIs never need it. Prefer exact origins over *.
up.index_wait_timeout float|null 120 up Seconds of indexer inactivity after which serviette up stops holding the server back waiting for the first indexed chunks. Counted from the indexer's last sign of work in its log (a document being parsed, input reaching the engine, embedding progress), so a few large PDFs that take minutes to parse do not trip it. A folder whose documents all yield no text (scans without the ocr extra, audio without its API key, parser failures) never produces a chunk and goes quiet right after parsing, so after this long the server starts over the empty index with a warning; null waits forever.
llm.type str openai server LLM for /rag (omit to disable /rag).
llm.model str gpt-4o-mini server Chat model.
llm.api_key str โ€” server API key (or ${ENV}).
llm.system_prompt str built-in grounded prompt server Answering policy for /rag. The default tells the model to answer only from the provided context; override to let it blend in its own knowledge (e.g. public-domain corpora). Kept under rag.adaptive, which appends its no-answer instruction to it.
rag.adaptive {factor, max_iterations, no_answer_string} โ€” (off) server Adaptive RAG: answer from k chunks, then grow the context by factor and re-ask (up to max_iterations rounds) while the reply contains no_answer_string (default No information found). The system prompt is the llm.system_prompt policy plus an appended instruction to reply with that marker when the context is insufficient; a policy that allows answers from the model's own knowledge rarely triggers the marker, so the context then rarely grows.
rag.documents {enabled, corpus_card, source_labels, max_context_chars, max_request_chars, max_listed_documents} on server Document-level questions on /rag (counts and listings, a named file as a whole, comparing files). A question that names a file with its extension is answered from that file without a search. Otherwise the ordinary attempt runs first; when its reply contains the no-answer marker, one short routing call (the question and up to max_listed_documents file names, never the retrieved context) chooses between continuing the search (rag.adaptive, if configured) and answering from the catalog or from the named documents โ€” at most once per request, and one-way. max_context_chars (default 24000) caps what any such answer reads: a document that does not fit is cut to its parts closest to the question, and a comparison shows only the differences โ€” found line by line (ignoring spacing, hyphenation, punctuation and running page headers), so it does not depend on how the files were chunked; when there are more than fit, the ones closest to the question are shown and notice says how many of how many. max_request_chars (default 100000) caps everything one request sends to the LLM across all its calls, the adaptive loop included: a call that no longer fits is cut down or not made, and notice says so. While enabled, the system prompt carries the no-answer instruction and, with corpus_card, one line stating the number of indexed documents; with source_labels, every context chunk is prefixed with its file name ([file: pricing.md]), which lets the model tell apart documents that disagree. Set enabled: false to keep the prompt and context untouched. The response gains mode (search, catalog, document, compare, unresolved), documents and notice. Counts cover indexed documents only โ€” files that produced no text are not in the store. Ignored on Pinecone.
frontend.host / frontend.port str / int 127.0.0.1 / 3000 frontend (standalone) Bind address for the split-deployment chat UI.
frontend.api_url str http://localhost:8989 frontend (standalone) Base URL of the API the standalone frontend proxies to โ€” point it at the backend host in split deployments (frontend fleet / backend fleet). The browser sends requests to the frontend's same-origin proxy; the upstream address is shown in the UI for information.
frontend.title str serviette both Title shown in the chat UI (embedded and standalone).

Supported embedders (from pathway.xpacks.llm.embedders): openai,
litellm (โ‰ˆ100 providers), sentence_transformer (fully local, no
credentials โ€” install serviette[local]), gemini (serviette[gemini]),
bedrock (serviette[bedrock] for the server side). Every family has a
matching async client on the server side, so one embedder section serves both
components; make sure the indexer and server use the same model so vectors
are comparable.

Which local model. The sentence_transformer default,
sentence-transformers/all-MiniLM-L6-v2, is an English-only model: with
French, German or Polish documents retrieval degrades to "the context does not
contain the information" even though the files were indexed. For non-English
or mixed corpora set model: intfloat/multilingual-e5-small (~100 languages,
same size and speed) together with the prefixes it was trained with,
query_prefix: "query: " and document_prefix: "passage: "; for the highest
quality and long chunks, BAAI/bge-m3 (1024-dim, ~2.3 GB, several times slower
on CPU). serviette wizard asks this as its "Embedding model" step and writes
the prefixes for you. Changing the model means re-indexing (the fingerprint
check will say so).

Sources

serviette only supports sources that offer Pathway's only_metadata read mode โ€”
the graph holds just paths/identifiers and metadata (never the file bytes), and
the bytes are fetched on demand during parsing. That keeps the memory footprint
tiny (1 GB of PDFs stays a few KB in the pipeline). Today that means:

  • fs โ€” a local directory, watched recursively per glob.

  • gdrive โ€” a Google Drive folder or file. Create a Google service
    account
    , download its JSON key, share the target folder with the service
    account's email, and point service_user_credentials_file at the key. See the
    Pathway Google Drive connector guide.
    Install the extra: pip install "serviette[gdrive]".

  • s3 โ€” an S3 bucket or key prefix, including S3-compatible stores
    (MinIO, DigitalOcean, Wasabi) via endpoint + with_path_style: true.
    Credentials fall back to the standard AWS chain when omitted. Install the
    extra: pip install "serviette[s3]".

  • sharepoint โ€” a SharePoint directory or file, authenticated with an
    app-registration certificate (tenant, client_id, cert_path,
    thumbprint). Requires a Pathway Scale license and
    pip install "serviette[sharepoint]".

  • pyfilesystem โ€” anything the
    PyFilesystem2 library opens: FTP, SFTP,
    WebDAV, even ZIP/TAR archives โ€” selected by fs_url (e.g.
    ftp://user:pass@host/dir, zip://./docs.zip). Install
    pip install "serviette[pyfilesystem]"; some protocols need their own driver
    package (fs.sshfs, fs.webdavfs).

You can list several sources of mixed types in one config.

Vector database backends

Every backend is written through Pathway's native pw.io.* output
connector
in snapshot/upsert mode keyed by the chunk id, so file additions,
edits and deletions become real inserts/updates/deletes in the store. The
server retrieves through each database's own vector-search API โ€” there are no
linear scans in Python anywhere.

Targets are created for you. At startup the indexer runs
prepare_backend: it creates the missing table / collection / index for
every backend (pgvector table + HNSW index, Milvus collection, Chroma and
Weaviate collections, the Pinecone serverless index, the MongoDB Atlas
vectorSearch index), sizing the vector dimension from
vector_db.embedding_dimension when set, or by introspecting the configured
embedder otherwise. The schemas below are shown for reference โ€” e.g. when a
DBA manages the database โ€” not as required manual steps.

DuckDB (embedded โ€” the zero-setup default)

yaml
vector_db:
  type: duckdb
  path: ./embeddings.duckdb
  table: serviette_embeddings

No external service and no setup: the file and table are created automatically
(pw.io.duckdb, snapshot mode). Embeddings are stored as native DOUBLE[]
columns and queried inside DuckDB with list_cosine_similarity โ€” a
vectorized, columnar scan that answers in milliseconds for local corpora.

Concurrency: DuckDB allows one read-write process or several read-only
processes per file โ€” never both. serviette resolves this with Pathway's
detach_between_batches: the streaming indexer releases the file lock
between minibatches, and the server's short-lived read-only connections query
it concurrently โ€” live indexing and serving work on one file. A request that
arrives while a batch is being committed waits for the lock (up to 30 s; the
first commit of a large corpus can hold it for ~10 s), and only past that
answers 503 with "the index is being updated โ€” try again in a few seconds"
(the chat shows the same text). On older Pathway builds without the flag, serviette
warns and falls back to the hold-the-lock behavior (use mode: static
there).

pgvector

Auto-created (extension, table, HNSW index). Reference schema, e.g. for
text-embedding-3-small (1536 dims):

sql
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE serviette_embeddings (
  chunk_id  text PRIMARY KEY,
  text      text,
  metadata  jsonb,
  embedding vector(1536)
);

(The indexer writes the chunk primary key to the chunk_id column.)

Milvus

Auto-created: a collection with a VARCHAR primary key, text (VARCHAR),
metadata (JSON) and an embedding (FLOAT_VECTOR) field of the embedder's
dimension, AUTOINDEX with the COSINE metric.

Milvus Lite (uri: ./milvus.db, a local file) is an embedded,
single-process database: the process that opens the file locks it. It works
for the one-process-at-a-time flow โ€” serviette indexer with mode: static
sources, then serviette server โ€” but not with serviette up, which runs
the indexer and the server as two processes against one store; up refuses
such a config up front. For up, a live chat over a changing folder, or
scaling the server, use a Milvus server (uri: http://host:19530, e.g. the
milvus run standalone docker image).

Qdrant

Nothing to create: the indexer (pw.io.qdrant, gRPC) auto-creates the
collection with the cosine metric on first write. The chunk's text and
metadata travel in the point payload; the server queries over REST.
Set api_key for Qdrant Cloud.

ChromaDB

Auto-created with the cosine distance (hnsw:space). The chunk text is
stored as the Chroma document and the source metadata as a JSON string
(Chroma metadata values must be scalars).

Weaviate

Auto-created (capitalized name, e.g. ServietteEmbeddings; vectorizer none).
The chunk vector is stored as the object vector; text / metadata (JSON
string) / chunk_id become properties. The server's v4 client needs both the
HTTP and gRPC ports.

Pinecone

Auto-created: a serverless index (vector_db.cloud / .region) with the
embedder's dimension and the cosine metric. The text and JSON-string
metadata are stored as record metadata. The API key comes from api_key
or $PINECONE_API_KEY.

The indexer writes one document per chunk (pw.io.mongodb, snapshot mode).
The collection and the Atlas vectorSearch index (default name
vector_index, cosine, numDimensions from the embedder) are auto-created;
on Atlas Local the indexer rides out the search service's slow boot with
retries. The server retrieves with $vectorSearch.


5. Persistence

When persistence.enabled: true, the indexer uses Pathway's built-in
persistence backend plus a DefaultCache UDF call-cache on the embedder. On
restart, Pathway replays its persisted state and re-emits only the diffs since
the last run, so:

  • unchanged documents are not re-embedded (the embedder cache and operator
    state are restored), and
  • documents removed while the indexer was down are correctly retracted from
    the vector DB on the next run.

The engine's state lives under <workdir_path>/persistence/ (the top level of
the working directory is reserved for serviette's own files, such as the
configuration fingerprint โ€” the engine logs an ERROR for every foreign entry
in the directory it is given, so the two never share a folder); the same
persistence/ also hosts the parse cache (runtime_calls/, via
pw.udfs.DefaultCache โ€” diskcache, LRU-bounded by
indexer.parse_cache_size_gb, default 8): extracted document text stays warm
across restarts, so unchanged documents are neither re-downloaded nor
re-parsed. Disabling persistence also disables the parse cache.

The configuration fingerprint (<workdir_path>/serviette-fingerprint.json)
records the chunking-relevant settings the working directory was indexed with
(splitter, parser routing, embedder identity, library versions, chunk-id
scheme). A change is reported as a diff on startup and the indexer refuses to
run unless confirmed (interactively, or with
SERVIETTE_ACCEPT_FINGERPRINT_CHANGES=1), because rows already in the vector
DB keep their old chunk ids and would be left behind as orphans. The check
runs with persistence on or off: a restart without persistence re-indexes
everything, but does not remove those rows either.

A document whose bytes cannot be fetched (after indexer.fetch_retries
retries) or parsed does not stop the indexer: it is indexed as empty and
logged at ERROR level with its path. That result sticks for this version of
the object โ€” a restart alone does not retry it โ€” so fix the cause and then
touch the file (or re-upload the object) to have the source re-emit it.

Disabling persistence still produces identical vectors for a given set of files;
it only forgoes the cross-restart diffing and caching.


6. Parsing & modalities

serviette never implements its own parsers โ€” each file is routed by extension to
a pathway.xpacks.llm.parsers parser. Every format is enabled by default,
preferring parsers that need no API key, and modalities whose only parser
requires an absent key are skipped with a single clear warning (never a
crash):

  • text / Markdown โ†’ as-is;
  • .pdf โ†’ DoclingParser (layout-aware, tables โ€” serviette[docling]), falling
    back to PypdfParser;
  • Office & co. (DOCX, PPTX, XLSX, HTML, EML, CSV, RTF, EPUB, โ€ฆ) โ†’
    UnstructuredParser;
  • scanned images (PNG, JPG, TIFF, โ€ฆ) โ†’ PaddleOCRParser (serviette[ocr]);
  • audio (MP3, WAV, โ€ฆ) โ†’ Whisper, enabled when OPENAI_API_KEY is set;
  • video (MP4, WebM, MOV, โ€ฆ) โ†’ TwelveLabs Pegasus (a searchable text
    description), enabled when TWELVELABS_API_KEY is set.

The routing is overridable per file pattern via the parser: config section
(first matching rule wins; unmatched files use the defaults above) โ€” e.g.
route images to a vision model instead of OCR, or set a custom video prompt.
Routing changes are fingerprint-guarded like splitter changes. Expensive
parses (video) are cached on disk, so restarts cost nothing.

Still not supported: encrypted / password-protected PDFs; Google-native files
(Docs/Sheets/Slides) from a gdrive source โ€” they have no binary download
and would need an export step (TODO); any format the installed unstructured
version cannot handle. Unsupported files are skipped and produce no chunks.


7. Architecture

Inside the indexer, documents move through a Pathway dataflow โ€” every stage
below is an incremental operator, so a change to one file re-runs only that
file's slice of the graph:

Why only_metadata? The filesystem connector only ever puts paths and
metadata
into the graph, never file contents โ€” so 1 GB of PDFs costs a few KB in
the pipeline. Text is extracted on demand inside the parse UDF.

Deletion handling. The parse UDF is non-deterministic, so Pathway memoizes
its output and, when a file is removed, re-emits that stored text negated
without re-reading the (now gone) file; the retraction flows through chunking and
embedding and the sink deletes exactly the matching vectors.


8. Scaling

The indexer and the server are separate processes that share only the vector
database
:

  • Server. Stateless and read-only against the vector DB โ€” run as many
    instances as you like behind a load balancer, across machines or availability
    zones. Because it is fully async (coroutines, not a thread pool), each instance
    handles many concurrent network-bound retrieval calls efficiently. Each
    instance also serves the chat UI (static, zero overhead), so the UI scales
    with the API for free.
  • Frontend (optional split tier). When the UI must live on a different
    host than the API, serviette frontend is a separate, stateless web tier that
    serves the same chat page and proxies to the API at frontend.api_url
    server-side (the browser connects to the frontend, which calls the API).
  • Indexer. Runs as its own long-lived process and shards across worker
    processes with one config line (indexer.workers, via pathway spawn) โ€”
    the published benchmarks run with 8; its persistence lets it resume
    without re-embedding.
  • Vector database. Scaling and replicating the vector DB itself is the
    user's responsibility and follows that database's own guidance. (The embedded
    DuckDB backend is the exception: it lives in one file next to the indexer and
    is meant for local / single-node setups.)

Engine observability: with indexer.monitoring_http_port set, every worker
process serves the Pathway engine's built-in endpoints on
127.0.0.1:(port + worker index) โ€” GET /metrics (Prometheus: input/output
latency gauges, per-operator row counters) and GET /status.


Security & exposing the server

The API server binds to 127.0.0.1 by default: on a laptop, serviette up
serves only you. It deliberately ships no built-in authentication โ€”
that is a perimeter concern, and a reverse proxy does it better than any
hand-rolled API key (TLS, key rotation, SSO, rate limits, audit logs come
for free). This is the norm for tools of this class: Qdrant self-hosted
ships with auth off, Ollama has none at all โ€” and internet scans keep
finding thousands of Ollama instances exposed by a careless 0.0.0.0.
Don't join them.

To serve beyond localhost:

  1. Set the bind address explicitly โ€” it is an intentional decision:

    yaml
    server:
      host: 0.0.0.0     # containers / remote access; keep 127.0.0.1 otherwise
      port: 8989
  2. Put an authenticating reverse proxy in front. A complete example with
    Caddy (TLS is automatic):

    text
    rag.example.com {
        basic_auth {
            # generate the hash with: caddy hash-password
            alice $2a$14$...bcrypt-hash...
        }
        reverse_proxy 127.0.0.1:8989
    }

    With this in place serviette itself can stay on 127.0.0.1 โ€” only the proxy
    is exposed. Any equivalent (nginx + auth_basic, oauth2-proxy, Cloudflare
    Access, a VPN like Tailscale) works the same way.

CORS is deliberately not emitted by default: serviette's own UIs never
need it (the embedded chat is same-origin; the standalone frontend proxies
server-side, so frontend.api_url may point at a backend on another host โ€”
or another fleet โ€” without any browser-visible cross-origin traffic), and
the absence of CORS headers is what stops arbitrary web pages from reading
responses off localhost instances. If you build your own browser frontend
that calls the API directly from another origin, opt in with an explicit
allowlist:

yaml
server:
  cors_origins: ["https://app.example.com"]   # never "*" unless you truly mean it

The engine's monitoring endpoints (indexer.monitoring_http_port) are
loopback-only by construction and cannot be exposed directly.


Appendix: regenerating the demo GIF

The README demo GIF is rendered with Pillow (no terminal recorder or browser
needed), so it regenerates deterministically anywhere:

bash
pip install pillow
python docs/generate_demos.py     # -> docs/assets/demo.gif

The frames show real output: the terminal lines are those of a recorded
serviette demo run (heartbeats thinned), the answers are what the model
produced on the bundled corpus before and after the sed edit, and the header
statistics are the real /api/v1/stats values. They live as constants in the
script (SCRIPT_UP, SCRIPT_EDIT, ANSWER_*, STATS_*) โ€” when the output
of demo/up or the chat UI changes, re-record and update them rather than
inventing lines.