Skip to main content

ws-rag-26

Composable RAG primitives on Qdrant, with three ways in: a Python library, a wsrag command, and an MCP server. Ingest documents, search them with metadata filters, and get plain dicts back.

The design bet: no service you have to run first. Qdrant runs embedded in your process, MarkItDown parses files without a container, and everything else is optional. A single pip install gets you from nothing to a working index.

Install

pip install ws-rag-26          # core: embedded Qdrant + MarkItDown
pip install "ws-rag-26[openai]"   # plus an embedding/LLM provider

Optional extras: openai, anthropic, google, ollama, groq, openrouter, huggingface, hybrid (sparse vectors via fastembed), unstructured (title-aware chunking via an unstructured-api container), all.

Configure

Three environment variables are enough:

QDRANT_PATH=./qdrant_data     # embedded mode — no server, no Docker
AI_PROVIDER=openai            # applies to both embeddings and the LLM
AI_KEY=sk-...

AI_PROVIDER and AI_KEY are fallbacks; AI_EMBEDDING_PROVIDER / AI_LLM_PROVIDER override them per role when you want a different model for each. A .env file is loaded automatically.

Set exactly one of QDRANT_PATH and QDRANT_URL. Both together is refused rather than resolved: the URL would win and the path — along with everything already stored in it — would be silently ignored, which reads as data loss.

Anything beyond the three variables goes in YAML or in keyword arguments:

rag = WsRag(
    vectorstore={"distance": "cosine"},   # or euclid / dot / manhattan
    splitter={"chunk_size": 1200},
    retriever={"strategy": "multi-query-hybrid", "top_k": 8},
)

distance is fixed when the collection is created and cannot be changed afterwards — pick it before your first ingestion.

Configuration merges in four layers, each winning over the one before:

built-in defaults  <  environment  <  YAML file  <  keyword arguments

Environment alone must be enough, because an MCP host can only inject env vars. A checked-in YAML file expresses a stronger intent than the ambient environment, so it wins; an argument passed in code wins over everything.

Use

from wsrag import WsRag

with WsRag() as rag:
    rag.ingest(["report.pdf", "notes.md"], domain="finance")

    hits = rag.search("What was Q3 revenue?", domain="finance", top_k=5)
    for hit in hits:
        print(hit["metadata"]["file_name"], "→", hit["content"][:120])

Results are plain dicts (content, metadata, and score when a strategy produces one), so they serialise to JSON without LangChain on the other end.

Use with — or call close(). In embedded mode Qdrant holds an exclusive lock on its directory until the client is released, including against the next run of the same script.

Ingesting

rag.ingest(["a.pdf"], domain="finance", sub_domain="payroll")
rag.ingest(["a.pdf"], force=True)                 # re-ingest regardless
rag.ingest_directory("./docs", recursive=True)    # filtered by loader.extensions
rag.ingest_bytes(uploaded, "report.pdf")          # for uploads and MCP clients

Every call returns a JSON-serialisable summary:

{"total": 2, "processed": 1, "skipped": 1, "failed": 0, "chunks_created": 12, "errors": []}

Re-ingestion is cheap and safe. Two levels of deduplication:

  1. The file hash — an unchanged file is hashed, recognised, and skipped before it is parsed, chunked or embedded. The read is not free; everything after it is.
  2. Chunk hashes — a document that changed in one paragraph re-embeds that paragraph, not the whole file. Chunks that vanished from the new version are deleted, so edited-away text stops being answerable.

Chunk identity keys on the document's source, which is why ingest_bytes records the file name you pass rather than the temp path it writes: a random path each time would make every re-upload look like a brand new document.

Searching

rag.search("query")                                  # config defaults
rag.search("query", top_k=10, strategy="multi-query-hybrid")
rag.search("query", filters={"domain": ["finance", "ops"], "fiscal_year": 2024})

Different fields AND together; a list within one field ORs. domain= and sub_domain= are shorthands that merge into filters.

Four strategies: basic, hybrid, multi-query-vector, multi-query-hybrid. The multi-query ones ask the LLM for rephrasings and fuse the results with Reciprocal Rank Fusion; without an LLM configured they degrade to a single query rather than failing. Note that dense-vs-hybrid is decided when the store is built (use_sparse plus fastembed), not per query — see the note in wsrag/retrieval/strategies.py.

Reranking (optional)

A cross-encoder reorders the retrieved candidates far more accurately than vector similarity can. Off by default — it costs an extra API call per search:

pip install "ws-rag-26[cohere]"
RERANK_KEY=...        # or COHERE_API_KEY
rag = WsRag(rerank={"enabled": True})
rag.search("query", top_k=5)                 # fetches 20, returns the best 5
rag.search("query", top_k=5, rerank=False)   # skip it for this call
rag.search("query", top_k=5, rerank=True)    # rerank even with enabled=False

With reranking on, the vector search over-fetches top_k × 4 candidates. That multiplier is the whole mechanism: a reranker handed exactly top_k hits can only permute them, so no document outside the original top_k could ever reach the answer.

Reranking never breaks retrieval. A rate limit, a network failure, an uninstalled SDK — each degrades to plain truncation, which is exactly what you would have got without it. It is a quality layer over results that are already correct, so it is never allowed to turn a working search into an error.

The one exception is rerank=True. Naming it explicitly is a request, not a preference, so if no reranker can be built for it — no API key, an unknown provider — you get a ConfigError saying which. Silence there would be indistinguishable from success. Configuration is the line: weather is not.

Metadata

Every chunk carries pipeline-owned system fields (source, file_name, file_hash, chunk_index, total_chunks, …) plus domain fields you declare. With an LLM configured, domain fields are extracted from the document; anything you pass explicitly is never re-derived.

Declare your own schema in YAML and point WS_RAG_METADATA_CONFIG at it:

fields:
  - name: domain
    type: string
    values: [finance, production, hr]
  - name: fiscal_year
    type: integer
  - name: parties
    type: list

Fields are deliberately flat. A hierarchy is expressed as parallel fields (domain + sub_domain), never as a path string — a path can only be prefix-matched, whereas parallel fields filter at any level independently.

Contextual retrieval (optional)

A chunk pulled out of a document loses what made it findable. Turning this on embeds each chunk together with a short LLM-written preamble saying what it is and where it sits, so the vector describes a self-contained passage:

rag = WsRag(contextual={"enabled": True})
rag.ingest(["handbook.pdf"], force=True)   # see below for why force

It costs one LLM call per chunk, not per document, which is why it is off by default — switching it on multiplies the price of loading a corpus by its chunk count. evals/ exists to measure whether that buys you anything on your own data before you pay for it.

Chunk identity is still taken from the original text, deliberately: an LLM preamble is not reproducible, and hashing it would make every re-ingestion look like a changed document. The consequence is that flipping this setting does not re-embed anything by itself — pass force=True once after changing it.

Introspection and maintenance

rag.stats()                      # collection + active component configuration
rag.list_domains()               # {"domain": [...], "sub_domain": [...]}
rag.field_values("domain")       # unique values, for building a filter UI
rag.extract_filters(query)       # what the LLM reads out of a query, no search
rag.get_filter_context(query)    # the same, rendered as a prompt block
rag.delete_by_filter({"domain": "finance"})
rag.delete_by_file_hash(file_hash)
rag.reset()                      # drop and recreate — irreversible

delete_by_filter refuses an empty filter: Qdrant reads an empty condition list as match everything, so forwarding one would wipe the collection while looking like a targeted delete.

Command line

wsrag ingest ./docs --recursive --domain finance
wsrag list-domains
wsrag search "Q3 revenue" --domain finance --top-k 5
wsrag field-values domain
wsrag stats

stdout is JSON, always — one object per run, {"ok": true, ...} or {"ok": false, "error": ...}. Logs and tracebacks go to stderr, and a failure also exits non-zero. Both signals are emitted because callers check different ones: a shell script reads $?, a model parses the JSON.

--filter KEY=VALUE is repeatable, and repeating a key ORs its values. Values are read as JSON scalars, which is how a field's type reaches Qdrant intact:

wsrag search "budget" --filter fiscal_year=2024 --filter domain=finance --filter domain=ops

fiscal_year=2024 is the number 2024, domain=finance is the string. This is not cosmetic — Qdrant's equality match compares by type, so filtering on the string "2024" against a payload holding the number 2024 matches nothing and reports it as zero results rather than as an error. When a field really does hold digits as text, quote inside the value: --filter code='"2024"'.

reset, delete-by-filter and delete-by-file-hash are deliberately not commands. The usual caller here is a model shelling out, and an irreversible operation whose blast radius is the whole collection is not something to hand one. They stay on the Python API.

MCP server

pip install "ws-rag-26[mcp]"
{
  "mcpServers": {
    "wsrag": {
      "command": "wsrag-mcp",
      "env": { "QDRANT_PATH": "/abs/path/qdrant_data", "AI_PROVIDER": "openai", "AI_KEY": "sk-..." }
    }
  }
}

Six tools: list_domains, get_field_values, search, ingest_document, ingest_text, get_stats. They are thin and deterministic on purpose — routing, query reformulation and synthesis stay with the host model, which is already an LLM and already has the conversation. Wrapping an agent inside a tool that an agent calls means two models negotiating through a JSON boundary: more latency, more cost, and a decision the user cannot see.

Configuration comes from environment variables only, because that is all an MCP host can inject. Embedded Qdrant locks its directory, so nothing else may hold the same path while the server runs — including the wsrag CLI. Run both against one QDRANT_PATH and whichever starts second fails; use QDRANT_URL if you need them side by side.

Claude Code

wsrag install              # ./.claude/ for this project (default)
wsrag install --user       # ~/.claude/ for every project
wsrag install --skill      # or --commands, to install just one

Two things land, and they do different jobs:

  • /wsrag-search — a skill carrying the working rules the tool descriptions have no room for: call list_domains before filtering, never guess a domain, what to try when a search comes back empty, why score is null and must never be reported as confidence. Its body loads only when triggered, and references/field-reference.md only when the model reaches for it, so none of it costs context until it is needed.
  • /ingest-document <path> and /search <question> — slash commands. Deterministic manual entry points for when you would rather type the operation than describe it.

They do not compete. The MCP tool descriptions say what exists, the skill says how to judge, the commands say do exactly this. Give a command a description in its frontmatter and the model can invoke it too; leave it out to keep it a human-only path.

A misconfigured server still starts. The problem is reported through the first tool call the model makes, naming the missing variable — a host that cannot start a server shows the user far less than a tool result can.

Embedded vs remote

Embedded (QDRANT_PATH) Remote (QDRANT_URL)
Setup none a Qdrant server
Processes exactly one at a time many
Payload indexes ignored (local Qdrant scans) used
Suits MCP server, notebook, CLI FastAPI with >1 worker

Multi-worker deployments must use QDRANT_URL — the embedded directory lock is per-process and there is no way around it.

docker-compose.yml brings up both optional services:

docker compose up -d qdrant        # a Qdrant server on :6333
docker compose up -d               # plus unstructured-api on :8000

Then set QDRANT_URL=http://localhost:6333 and remove QDRANT_PATH — wsrag refuses a configuration with both rather than silently picking one, because the loser takes every document stored in it out of sight.

Security note

A metadata filter is not a security boundary. Anyone who can call search can pass any filter, so every domain in a collection is readable by every caller. Isolate sensitive material in a separate collection, not behind a domain value.

Development

uv sync
uv run pytest                  # unit tests
uv run pytest -m integration   # real embedded Qdrant on a temp directory
uv run ruff check src tests

Integration tests are excluded from the default run. They spin up a real Qdrant and use deterministic hash-based embeddings, so they need no network and no model download.

uv run mypy                    # the suite runs clean; py.typed ships the types
uv build                       # sdist + wheel into dist/

License

MIT — see LICENSE. Changes are recorded in CHANGELOG.md.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

ws_rag_26-0.1.0.tar.gz (155.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

ws_rag_26-0.1.0-py3-none-any.whl (101.4 kB view details)

Uploaded Python 3

File details

Details for the file ws_rag_26-0.1.0.tar.gz.

File metadata

  • Download URL: ws_rag_26-0.1.0.tar.gz
  • Upload date:
  • Size: 155.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.10.4 {"installer":{"name":"uv","version":"0.10.4","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for ws_rag_26-0.1.0.tar.gz
Algorithm Hash digest
SHA256 0b7464db8e381e8e52b9ec321dc98875a478b07fdda341e2b4d1f027057b3d26
MD5 30e169ac6dc99cfaf2e13d88e29c501b
BLAKE2b-256 d2c9f02c69f50f04d3b8fb519d491af2cc558ab241885557194fd5c4e7a960e8

See more details on using hashes here.

File details

Details for the file ws_rag_26-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: ws_rag_26-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 101.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.10.4 {"installer":{"name":"uv","version":"0.10.4","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for ws_rag_26-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 1608ab12380d3f653472e4c03d1fad626c4740bd8ee3fde1a638ccff90729c60
MD5 d502f4548b50921c15672b60236840fd
BLAKE2b-256 8e17106dadf05b98a4214dbc894f563c879cdd862baf1fb76537d08c1b61188a

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page