Skip to content

Usage

Three ways to drive the system: the CLI, the warm HTTP API, and the console. They share one pipeline and one config.

Agent-facing query surface

The warm API exposes live capability and provider discovery, stable source lookup, and bounded comparison trees in addition to single /search and /query calls. Fetch /schema and /providers at runtime instead of copying preset or backend lists into an agent prompt.

CLI

python main.py index                       # build indexes from data/*.jsonl
python main.py index --append <file>.jsonl # add a source family (auto-rebuilds sparse)
python main.py index --rebuild-into <name> [--embedding <provider>] [--limit N]  # re-embed the active collection's corpus into a NEW collection; --embedding = an embedding.providers name or local (default embedding.provider); resumable, never writes the active one; --limit N = a smoke build, left unservable
python main.py stamp [--collection NAME] [--force]  # verify which embedder built a collection (re-embeds a sample), then record it; :8051 refuses a mismatch
python main.py ingest-pdfs [--pages "1-50,60"] [--chunking heading|fixed|document|none] [--include-files "a.pdf,b.pdf"] [--output data/<name>_chunks.jsonl]
python main.py ingest-notebooks [--include-path "<scope>"] [--include-files "a.py,b.ipynb"] [--output data/<name>_chunks.jsonl] [--force-domain ml] [--force-tags "a,b"]
python main.py ingest-code --include-path "<subtree>" --output data/<name>_chunks.jsonl [--include-files "x.sql"] [--force-domain swe] [--force-tags "a,b"]  # a scoped run needs its own output
python main.py ingest-md --include-path "<scope>" --output data/<name>_chunks.jsonl [--force-domain nlp] [--force-tags "a,b"]  # scoped md parse (guarded)
python main.py ingest-canvas [--include-path "<scope>" --output data/<name>_chunks.jsonl] [--context-depth 0|1|2] [--max-chunk-size N] [--force-tags "a,b"]  # Obsidian .canvas files; a scoped run needs its own output
python main.py ocr-studio-ingest --doc "<vault-relative PDF or image>" --output data/ocrstudio_<name>_chunks.jsonl [--pages done|all]  # one document's OCR Studio pages
python main.py graph-embed [--limit N]     # rebuild graph mode's own seed index from scratch (needs graph.embedding_provider)
python main.py stamp-provenance --dry-run|--yes  # metadata-only: mark stored rows the provenance rule calls generated (--yes: servers down; :8051 refuses to start under drop/route until it has run once)
python main.py route-pointers [--out PATH] # build route mode's pointer sidecar (each generated chunk -> the primary pages it cites); restart :8051 afterwards
python main.py relocate --map rename_map.jsonl [--dry-run|--verify]  # metadata-only move of indexed files to new vault paths, no re-embed (servers down, then rebuild_bm25.py)
python main.py fetch-web --urls "https://…" [--backend auto|requests|crawl4ai|scrapling|crawlee] [--format md|pdf]
python main.py convert-files --files "report.docx" [--ocr-pages "1-4,9"]       # markitdown → .md
python main.py query "<question>" [--preset code|concept|synthesis] [--top-k N] [--max-tokens N]
python main.py chat                        # interactive REPL
python main.py bench run --configs ladder  # score labelled questions per pipeline configuration (also: report | split | validate | sample)

OCR engines (--ocr-engine, also per job in the console): auto — probe for Tesseract and use it if present (the default); tesseract — pin the classic in-process binary; paddle — a PaddleOCR service reached over HTTP (pdf.paddle_ocr), much stronger on skewed, multi-column and non-Latin scans and still seconds per page on CPU; vlm — a document vision model over HTTP (pdf.vlm_ocr) for the hardest material; none — no OCR, scanned pages stay sparse. The two HTTP lanes run out-of-process on purpose, so heavy OCR dependencies never enter the query environment and can be shut down when idle. Run GET /api/import/ocr_scan (the 🔎 button) first — it reports which pages actually lack extractable text, so you only OCR those.

The paddle lane itself picks its engine with pdf.paddle_ocr.pipeline: ocr (the default — PP-OCRv6 detect-and-recognise, plain text, seconds per page) and vl (PaddleOCR-VL, a document VLM that returns markdown with LaTeX formulas and real tables, at roughly 30x the cost for no gain on ordinary prose). Reach for vl on a chapter where the equations and tables are the content; leave it alone for scanned prose. It needs the GPU image, and a sidecar that cannot serve it says so rather than quietly returning the other engine's output.

Chunking strategies (--chunking, also selectable per run in the console): heading — paragraph packing that follows the text's structure (default); fixed — strict sliding window, for OCR'd / wall-of-text sources; document — element-aware packing that never cuts inside a fenced code block, table, or list (one element larger than the chunk size on its own falls back to the sliding window); none — no splitting at all (one chunk per section; embedding models truncate very long inputs, so use deliberately). Small sections chunk identically under every strategy.

HTTP API (:8051)

The warm endpoint keeps the indexes and models hot, so queries never pay startup cost.

curl -s -X POST http://127.0.0.1:8051/query \
  -H "Content-Type: application/json" \
  -d '{"q": "What does the retention policy say about backups?", "preset": "concept"}'
curl -s -X POST http://127.0.0.1:8051/search \
  -H "Content-Type: application/json" \
  -d '{"q": "docker networking", "top_k": 7}'
curl -s -X POST http://127.0.0.1:8051/compare \
  -H "Content-Type: application/json" \
  -d '{
    "q": "What does the retention policy say about backups?",
    "mode": "search",
    "branches": [
      {"id": "baseline", "label": "Config baseline", "auto_preset": false},
      {"id": "concept", "preset": "concept"},
      {"id": "lexical", "rerank": "lexical"}
    ]
  }'
curl -s http://127.0.0.1:8051/providers
# Live-only is the safe default and reverts on restart.
curl -s -X POST http://127.0.0.1:8051/config \
  -d '{"rerank_top_k": 10}'

Set persist:true only with operator authorization; it rewrites config.yaml. Prefer the management Settings API for deliberate persistent changes.

Every /query response identifies what actually ran. Every source has a stable evidence id, and generated answers report the resolved backend/model rather than leaving the caller to infer them from config:

{
  "sources": [
    {
      "id": "<stable-evidence-id>",
      "origin_id": "<retrieval-id>",
      "lookup_available": true,
      "n": 1,
      "label": "<source label>",
      "cited": true
    }
  ],
  "retrieval": {
    "preset": "code",
    "rerank_top_k": 10,
    "hyde_used": false,
    "reranker_model": "<resolved-reranker>"
  },
  "generation": {
    "backend": "<provider-registry-name>",
    "protocol": "<wire-protocol>",
    "model": "<resolved-model>",
    "usage": {}
  }
}

GET /history returns the last /search + /query calls (newest first, in-memory): the question, the knobs the caller explicitly set, the full retrieval echo of what actually ran, confidence and timing — so an agent tuning hyperparameters can see what it already tried instead of re-deriving it.

GET /compare/options returns ready-to-post /compare branches for each dimension (presets, rerank methods, generation backends) together with the live branch caps — so building a comparison never means joining /schema and /providers by hand.

GET /chunks/{chunk_id} fetches the current evidence record behind a stable id returned from /search, /query, or /compare when lookup_available is true. Parent-expanded sections use a parent:<id> evidence id and also report the indexed child as origin_id, so overlap is computed from the text each branch actually received. Live Omnisearch excerpts use a content-derived live:<hash> evidence id and set lookup_available to false because they are not stored index records. GET /schema advertises the current request fields, branch limits, preset registry, and endpoint map.

Comparison-tree semantics

POST /compare accepts a question, mode: search|query, and a bounded list of named branches. Read the live /schema for the current limits. A branch can override the same retrieval controls as /search; query-mode branches may additionally override the configured provider and model.

The response contains every branch plus comparison: common and branch-unique source ids, per-branch ranks, rank spread, and pairwise overlap. It intentionally does not compare raw scores, because cross-encoder logits, lexical scores, HTTP reranker scores, and fused RRF values are on different scales.

Branches that differ only by provider/model reuse one exact evidence set. This makes a generation-backend comparison about answer behavior instead of retrieval noise. A generation failure stays on that branch; a retrieval/reranker failure marks the branch as a retrieval error without discarding successful siblings.

fetch-web --format pdf prints the fully rendered page through headless Chromium (LaTeX, tables and highlighted code exactly as the site shows them) instead of converting to markdown — the right lane for math- or code-heavy sources, and the output ingests through the PDF lane with real page numbers. It needs Playwright's Chromium once: python -m playwright install chromium.

For the markdown path, --backend auto tries crawl4ai, then scrapling, then plain requests, using whatever is installed. crawlee is opt-in: auto never picks it. It renders the page with Crawlee's PlaywrightCrawler in the same Chromium, then the HTML goes through the same markitdown conversion as every other backend. Asking for it without the package installed, or when the crawl fails, is an error, never a quiet switch to another backend.

Presets and per-query knobs

Named override bundles in config.yaml, selectable per query with no restart — the warm pipeline is never mutated:

retrieval:
  presets:
    code:      {rerank_top_k: 10, use_hyde: false, dense_top_k: 40, sparse_top_k: 40, boost_code: true}
    concept:   {rerank_top_k: 5,  use_hyde: false}
    synthesis: {rerank_top_k: 10, use_hyde: false, dense_top_k: 30, sparse_top_k: 30, parent_context: true, neighbor_context: true}
Knob Effect
preset Apply a named bundle (code / concept / synthesis).
auto_preset false suppresses implicit code-intent preset selection for a config-only baseline. Explicit preset still wins.
top_k / rerank_top_k How many reranked chunks reach the generator.
dense_top_k / sparse_top_k Candidate-pool width per lane before fusion.
use_hyde / hype Toggle query expansion.
rerank_instruction The ranking criterion in plain language: "prefer worked procedures over definitions". Applied only to the text the reranker scores against, so it reorders the candidate pool and can never remove from it. Ignored by lexical / none / laya; the retrieval echo reports whether it was actually applied. Config default: retrieval.rerank_instruction.
rerank Rerank method for this call: cross_encoder (in-process semantic scoring), http (configured external /v1/rerank service), lexical (model-free query-term coverage), none (raw fused order), or laya (experimental: a fine-tuned scorer, refused unless retrieval.laya.enabled and never swapped for another method). Config default: retrieval.rerank_mode.
lane_weights / lanes / metadata_boost Fusion controls. lane_weights rescales each lane's RRF term; lanes restricts the call to a subset of the lanes (one outside the list does not run); metadata_boost forces the course/domain/tag boost on or off. See Lane weights.
scope_lanes / max_per_file / decompose scope_lanes forces the scope lanes on or off; max_per_file caps how many chunks of one source file reach the final list; decompose splits a multi-part question into separate searches with one LLM call. See Ask and search.
gate / gate_threshold / gate_scorer The optional post-rerank relevance gate: chunks scoring below the threshold are dropped, and when none pass /query abstains with the fixed "nothing relevant" answer and no LLM call. Off by default. See Relevance gate.
parent_context / neighbor_context E2 small-to-big, post-rerank: swap note chunks for their full section / append a PDF hit's adjacent pages. Carried by the synthesis preset; per-call override beats preset beats config.
generated_mode drop keeps generated chunks (maps, summaries an LLM wrote) out of this call's evidence; route does too and lets them find the primary pages they cite, each such source carrying found_via. off is config only (retrieval.generated_mode). Unset follows the configured mode. See Route mode.
source_file / pages Restrict every lane to one file's primary chunks, and with pages: [first, last] to a page range of it. GET /outline lists the files. Need retrieval.sparse_backend: bm25s; the echo's file_filter says whether anything matched.
max_tokens Cap the answer length.
provider / model /query and query-mode /compare only: select a configured provider and optionally override its default model. Endpoints and secrets cannot be supplied per request.

A knob with a bad value (an unknown lane, a gate with no threshold, a refused rerank mode) is answered with HTTP 200 and an error field rather than a 400; a wrong type is a 422. See When a knob is wrong.

max_tokens and citations

A very small max_tokens can truncate the citation footer and drop the answer's confidence to UNKNOWN. Leave enough room (a few hundred tokens) for a cited answer.

Reranker failures are retrieval failures

A response beginning Reranking failed: did not reach generation. Preserve the underlying model error and inspect GET /config for the active reranker. In particular, BAAI/bge-reranker-base has a 512-token input limit; configuring it above that limit is invalid. Do not relabel this as a generation-provider outage.

Generation providers

Provider definitions live in the providers: registry in config.yaml. A request can name a configured backend, but it cannot inject a URL or secret. GET /providers returns the active backend plus each configured backend's protocol, endpoint, default model, secret environment-variable name, and readiness flags; it never returns secret values. available means the required key is present and type-compatible, not that a remote endpoint has been contacted.

Where a vendor sells both a subscription and pay-as-you-go API access, the two credentials are usually different products and are not interchangeable. A provider entry can declare api_key_prefix so the wrong key type is rejected before a query runs, rather than silently billing the wrong account. Put a key only in the environment variable the registry declares, or store it through the console's provider-key API. Restart the query service after changing a provider secret.

An entry marked vision: true declares that its model reads images, and only such an entry is ever sent one: the Graph tab's suggest-label attaches an image file node for it alone, and the LLM client refuses images for any other entry rather than letting an endpoint drop them without a word. Set it only after checking that the endpoint's model accepts images, and not on a router whose model can change from one request to the next.

The Corpus Ledger console (:8052)

Open http://127.0.0.1:8052. Every tab follows one design, drawn from the active theme's tokens: panels open with a small kicker line above their heading, actions carry icons from one icon set, and every confirmation or prompt is an in-page dialog in the theme, never a browser pop-up. The header names the active vault, the console reopens on the tab you left (the address bar carries it too, so a link opens that tab), and any list that grows with use scrolls inside its card. Tabs:

  • Ledger — the corpus at a glance: chunk, document and vector counts, disk use, the Index health card (below), each domain's share, every chunk file (with when it last changed, sorted newest first, oldest first or by chunk count), and recent jobs.
  • Query — the presets, the query history (kept per vault) and the question box sit above three sub-tabs: Answer, Compare and Graph (the last only while Graph RAG is switched on). Answer is Ask / Search with its knobs in labeled groups (pool sizes, extra lanes HyDE/HyPE/Omnisearch, rerank method, E2 parents/neighbors), plus copy and .md export (ready to paste into any Markdown editor). The Markdown + LaTeX Preview toggle renders the answer and every source chunk — math, tables and fenced code included. Compare, the Query comparison tree, keeps the same knobs (its branches start from them) and fans the current question across live-config presets and generation backends or the built-in rerank baselines, with evidence-only and generated-answer modes.
  • Documents — search, #tag filter, retag (domain / group label / tags — metadata-only, no re-embed), and delete from the index.
  • Vault — browse the mounted vault tree with each file's index status, stage domain and tag edits for indexed files and apply them in one pass (index metadata only, with one BM25 rebuild queued), and find any file by name. Vault files are never modified.
  • Ingest — a numbered flow: 1 · Add documents (upload to the inbox; every routable type — pdf, md, code, notebook — rides a default | custom lane, and every file can carry its own ⚙ settings — domain and tags for all kinds, chunking for pdf/md, OCR engine and page subset for PDFs — plus a destination folder: the file moves to its vault home when the job is queued, before parsing, so the index records the final path (blank = stays in the inbox, archives to _ingested). The batch defaults (domain / tags / chunking / destination, each with a 📁 picker) fill in for any file without its own ⚙ value. 👁 previews any md/PDF, with PDF page numbers visible for OCR-range picking. An inbox file's × asks, then deletes it from disk for good — nothing indexed is touched — unlike the OCR tab's ×, which moves a file to the vault's .trash); 2 · Fetch & convert (pull web links as .md via markitdown or as a printed .pdf of the rendered page via headless Chromium — LaTeX/tables/code intact; convert any upload — pdf/docx/pptx/xlsx/html — to .md, with optional per-page OCR; outputs stage in _converted with preview until promoted, and a staged file's × also asks, then deletes it for good); 3 · Jobs designer — one card per queued job (kind badge, lane, file chips, effective-settings line): files sharing identical effective settings batch together, differing ⚙ settings split automatically; then Queue the plan (everything shown) or one lane at a time (custom only / default only). Vault-wide passes live under Advanced in the same panel. Routing is kind-aware: .py/.R/.ipynb/.Rmd go to the notebook lane (ingest-notebooks owns them — it has the Python ast/# %% cell splitter), while .js/.ts/.sql/.go/… go to the code lane. 4 · Graph & canvases previews and queues the canvas lane for a scope, with its own chunking, node depth and metadata; the preview reports how much text a node depth duplicates before anything is written.
  • OCR — hand-guided OCR of one PDF or image from the vault, or an import (a typed page range limits the work, and every page keeps its real number): per-page clean-up (deskew, contrast, threshold, denoise, a healing brush), regions drawn by hand or proposed from the page layout or the PaddleOCR sidecar's lines, each region OCR'd by its own engine and language, a quality report per engine over the pages marked done, ingest of those pages (append, or replace what is indexed), and a zip export of the corrected markdown with its figures. The × on an imported or inbox row asks, then takes the file out of the queue by moving it into the vault's .trash under its own sub-path, refreshes the list, and closes the document if it was open (its unsaved page edits are saved first). Nothing is deleted: moving it back restores it with its OCR work. While the document is indexed the × is refused, and the message lists the indexed files with a shortcut to them in the Documents tab; remove them from the index first.
  • Graph — every .canvas in the vault: open one, edit its nodes, edges and labels, and save the edits into the file. A save is refused if the file changed on disk since it was opened, and the previous version is kept in canvas.backup_dir, outside the vault. Re-index removes the canvas's chunks from the index and its chunk files, then ingests it into its own chunk file and appends that (and rebuilds the graph seed index when one is configured). Graph-mode querying is a separate, experimental panel in the Query tab (Graph mode).
    • Suggest label, on a selected edge, asks a language model for that edge's label from the canvas as the editor holds it, unsaved edits included: both endpoint nodes (file nodes read from the vault, long ones summarised first), the arrow's direction, the canvas title, the groups and the labels around the edge. Pick the backend (an entry marked vision also sees image nodes; one without a usable key is listed but disabled, naming the missing variable or a wrong key type) and, optionally, a model and a line of guidance; the defaults are canvas.augment.provider and canvas.augment.model, else the generation backend. The suggestion fills the label box. Beside it: the relation, confidence and reason, alternative labels to apply in one click, tag chips that add or remove a #tag in the label, any warnings, the backend and routed model with the calls and tokens used, and what the model saw. Every change can be undone. A failed call shows the failing stage, the HTTP status, the backend and model, the error, and the model's raw reply. Only Save writes the file (Suggest label).
    • #tags in labels. Write them into a label (#regression). With canvas.label_tags: true (off by default), the next ingest of that canvas also stamps them as tags on the chunks of both nodes the edge joins: metadata only, with ids and text unchanged. The edge editor says which applies.
    • Remove from index (shown with nothing selected, when the canvas has indexed chunks) asks first, then deletes its chunks from the index as the Documents tab does and queues the BM25 rebuild, plus graph_embed when a graph embedder is set. It then offers a :8051 restart, which runs only once those jobs have finished. The .canvas file is never written, and unsaved edits in the editor survive.
    • Graph embedder, a panel under the canvas list, shows graph mode's own seed index: its verdict and the reason, what it was built with and how complete it is, a note when graph mode itself is off, and the embedders to choose from (an unavailable one is listed with its reason). Its button saves the choice through Settings, builds the index through the job queue (graph_embed), follows the job, and then offers the restart. Choosing the main embedder only saves the setting, and no new build starts while one is queued or running.
  • Jobs — watch long-running ingest / maintenance jobs: the active vault's by default, every vault's with all vaults.
  • Eval — the review queue for the bench's question sets (Eval review queue): progress per suite against its quota, the questions filtered by suite, status and split, and one question at a time with its gold texts and validator findings, to verify, reject or edit. Check retrieval runs the question through /search with HyDE off.
  • Settings — appearance, kept in the browser: dark and light preset shelves with the built-in themes (the warm Ledger pair + Material mint dark/light) and your own saved presets, each renamed or deleted from its chip; the header's theme button cycles through every theme on both shelves. The theme designer edits one row per design token (the colours, each with a colour picker; the three fonts; the corner radii), bound both ways to a box of the same CSS variables; Export current → CSS writes the active theme into that box. An edit starts from the theme in use and previews on the page at once; nothing is kept until Save as preset, under a name, on the dark or light shelf. Export all themes and Import themes… move every saved preset as one JSON file, and an import merges without overwriting. Font pickers for headings / body / mono (system serif and sans choices like Times New Roman, Georgia, Arial — or any installed font by name) apply over every theme. Then a vault switcher that remembers every vault ever opened together with its own settings (index paths and collection, every lane's chunk file, figure and backup folders, the inbox and tree root, and the embedder) and swaps the whole set atomically; and the editable config surface with 📁 folder pickers: vault root, Chroma / BM25 / chunks paths, an Embedding panel (the model, picked by name, and the collection it searches, kept consistent: choosing a model switches to a collection this vault built with it, choosing a collection switches to the model that built it, fields the provider ignores are greyed out, and a verdict line says whether :8051 will start), cross-encoder models, default rerank & chunking, generation endpoint, and the Graph tab's suggest-label backend and model, label-tag indexing and graph seed embedder. Saves rewrite config.yaml in place (comments preserved); nothing hot-applies — the response says which service to restart. Panels sit under the fields: Generation backends, listing the providers: registry with each backend's endpoint/model and whether its API-key environment variable is set and type-compatible (never the value), plus controls to activate a backend or write its declared key to the gitignored .env; Reranker, which suggests known-good cross-encoders with their measured cost and states what PyTorch can actually reach — a cuda device on a CPU-only torch build would otherwise fail silently into a much slower path; and OCR, which reports what each OCR engine can do right now and can warm a vision-OCR model before the first ingest page. A Fetch engines panel lists each web-import package with its installed version and requirements.txt pin; Check fetches the latest release and dry-runs the upgrade so every package it would change shows first, and Update queues that upgrade (plus its browser installs) as an engine_update job in the Jobs tab.
  • Info — diagrams of the query and the ingestion pipelines. Hovering, focusing or clicking a stage shows what it does, which config.yaml keys control it, and whether it is on in the live config. A Workspace map has one card per tab, saying what each is for.

Ops niceties: the header's ⟳ restart button relaunches the warm query API in one click (and webui.auto_restart_rag in Settings does it automatically after every successful index-changing job); the Ledger shows an Index health card — ✓ in sync when the dense (Chroma) and sparse (BM25) indexes hold the same corpus, ✗ drifted with the fix spelled out (the BM25 count comes from a sidecar written at build time — no giant unpickle); and the Query tab keeps a restorable history of your last 20 queries with their knobs.

For agents

manage_api exposes GET /api/schema — a machine-readable, permission-tiered map of every management operation (read / mutating / destructive) so an agent can discover what it may call before it calls it. The query service has its own GET /schema: pair the two maps rather than assuming a copied capability list. Ask/search/compare/inspect evidence or change warm retrieval defaults → :8051; manage the corpus, persistent install settings, or provider secrets → :8052.

The complete endpoint listing for both services is the API reference; the patterns for driving them efficiently from an LLM agent are in Agent integration. An agent that does not know the vault can start from GET /outline (what is indexed, by domain and course, with chunk and page counts) before it searches, and the noetrix-navigator skill (SKILL_NAVIGATOR.md) turns a multi-hop question into the right calls.

Agents that speak MCP can use the MCP server instead of raw HTTP. On Windows, rag mcp starts it over stdio for a client on the same machine (it prints nothing, because stdout is the protocol channel); python mcp_server.py --http serves it over HTTP on mcp.port. Start :8051 and :8052 first.

When the query API will not come back

GET /health on :8051 reports one of three states:

state Meaning What to do
ready Serving. —
loading Indexes and models still loading. Wait.
failed The pipeline could not be built from the current config. Act; waiting cannot fix it.

A failed service keeps answering: every retrieval endpoint returns 503 carrying the same reason, instead of refusing the connection and leaving the caller to guess. The console's restart button surfaces the startup log automatically, and GET /api/service/log on :8052 returns it on demand.

The usual cause is the setting changed most recently — a cross-encoder id that cannot be downloaded, an embedding model with a typo, an index path that moved. Before switching cross-encoder, POST /api/rerank/check (the Check button on each model row in Settings → Reranker) reports whether the id resolves and whether the weights are already cached, and where. Those are exactly the two conditions that decide whether the restart after the switch will succeed, and it downloads nothing to find out.

A stale Hugging Face login looks exactly like a missing model

If a token is present but expired or revoked — in HF_TOKEN, or in the stored login file under your cache directory — the hub answers 401 and huggingface_hub surfaces that as repository not found. Every public cross-encoder then appears not to exist, and the download at the next :8051 start fails the same way with the same misleading message.

The preflight retries anonymously and, when that succeeds, says explicitly which credential the hub rejected and where it came from. Fix or remove it.

Where 'cached' is looked up

There is no single model cache. sentence-transformers honours SENTENCE_TRANSFORMERS_HOME over HF_HOME, while HF_HUB_CACHE can be set machine-wide to override HF_HOME for huggingface_hub itself — so those can legitimately be three different directories. The preflight checks all of them and reports the snapshot path it found, and it requires an actual weights file: a metadata-only directory is a failed download, not a cached model.

Updating the scraper pins

The web-fetch lane (fetch-web, and the console's Fetch & convert panel) sits on a few scraper packages that requirements.txt pins exactly. They move together and a loose upgrade tends to break at call time rather than at install time, so a bump is a deliberate edit of the pins, never a pip install -U.

Pin Why it is spelled that way
scrapling[fetchers] The [fetchers] extra is required. Bare scrapling imports, but its Fetcher dies on the missing curl_cffi / browserforge the first time it is called.
crawl4ai The browser-rendering backend; it drives both playwright and patchright.
playwright, patchright patchright is the patched Playwright driver that crawl4ai and scrapling's stealth fetcher use. Keep the two on the same release and bump them together; on one release both want the same Chromium build.
markitdown[pdf,docx,pptx,xlsx] The HTML / any-file to markdown converter. Never [all]: that adds the Azure, audio-transcription and YouTube extras nothing here uses.
crawlee[playwright,beautifulsoup] The opt-in crawlee backend. It drives PlaywrightCrawler; [beautifulsoup] adds the HTML-only crawler, which the backend does not use yet.

After installing a new set, download both browsers, Playwright's first:

python -m playwright install chromium
python -m patchright install chromium

On the desktop rag.bat points PLAYWRIGHT_BROWSERS_PATH at the G: cache.

To check that a new set resolves together before touching the venv, compile the whole file for the runtime's Python, and again for Windows, the real runtime (a wheel that exists for Linux can be missing there). The output files are throwaways, so delete them rather than commit them; a failure names the conflicting pin:

uv pip compile requirements.txt --python-version 3.11 -o resolved-linux.txt
uv pip compile requirements.txt --python-version 3.11 --python-platform windows -o resolved-windows.txt

The Docker image installs the same requirements.txt, with deploy/docker/constraints.txt pinning the transitive ML packages, so a new set reaches it on the next image build (see Docker deployment). Its Dockerfile, deploy/docker/Dockerfile, downloads both browsers in the same order and sets CRAWLEE_DISABLE_BROWSER_SANDBOX=1: Crawlee, unlike plain Playwright, keeps Chromium's sandbox on, and the sandbox needs user namespaces that a default container does not grant, whether it runs as root or not.