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) unlessserver.serve_frontendis 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).
pip install "serviette[local]" --extra-index-url https://download.pytorch.org/whl/cpuThe 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. (Withuv, add --index-strategy unsafe-best-match as well.) If you intend to
embed with OpenAI instead, a plain pip install serviette is enough (pickopenai 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:
serviette wizardIt 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):
# 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:3000indexer, 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):
curl -X POST http://localhost:8989/api/v1/retrieve \
-H 'Content-Type: application/json' \
-d '{"query": "how does persistence work?", "k": 5}'{
"results": [
{"text": "...", "metadata": {"path": "/data/docs/guide.pdf", ...}, "score": 0.95}
]
}If you configured an llm section, the /rag endpoint also answers questions:
curl -X POST http://localhost:8989/api/v1/rag \
-H 'Content-Type: application/json' \
-d '{"query": "summarize the onboarding guide", "k": 5}'{ "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:
curl 'http://localhost:8989/api/v1/documents?q=report&case_sensitive=false'{ "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:
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:
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 perglob.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 pointservice_user_credentials_fileat 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) viaendpoint+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 andpip install "serviette[sharepoint]".pyfilesystemโ anything the
PyFilesystem2 library opens: FTP, SFTP,
WebDAV, even ZIP/TAR archives โ selected byfs_url(e.g.ftp://user:pass@host/dir,zip://./docs.zip). Installpip 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 runsprepare_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 AtlasvectorSearch index), sizing the vector dimension fromvector_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)
vector_db:
type: duckdb
path: ./embeddings.duckdb
table: serviette_embeddingsNo 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'sdetach_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. fortext-embedding-3-small (1536 dims):
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. themilvus 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 andmetadata 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-stringmetadata are stored as record metadata. The API key comes from api_key
or $PINECONE_API_KEY.
MongoDB Atlas Vector Search
The indexer writes one document per chunk (pw.io.mongodb, snapshot mode).
The collection and the Atlas vectorSearch index (default namevector_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 samepersistence/ also hosts the parse cache (runtime_calls/, viapw.udfs.DefaultCache โ diskcache, LRU-bounded byindexer.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 withSERVIETTE_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 toPypdfParser;- 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_KEYis set; - video (MP4, WebM, MOV, โฆ) โ TwelveLabs Pegasus (a searchable text
description), enabled whenTWELVELABS_API_KEYis 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 frontendis a separate, stateless web tier that
serves the same chat page and proxies to the API atfrontend.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, viapathway 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 on127.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:
Set the bind address explicitly โ it is an intentional decision:
yamlserver: host: 0.0.0.0 # containers / remote access; keep 127.0.0.1 otherwise port: 8989Put an authenticating reverse proxy in front. A complete example with
Caddy (TLS is automatic):textrag.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:
server:
cors_origins: ["https://app.example.com"] # never "*" unless you truly mean itThe 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:
pip install pillow
python docs/generate_demos.py # -> docs/assets/demo.gifThe frames show real output: the terminal lines are those of a recordedserviette 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.