Indexing a corpus
-
Corpus = a folder
A corpus can be a repo, docs tree, mono-repo subtree, or any folder you point at.
-
Persisted in Postgres
Chunk rows live in Postgres; dense and sparse vectors live in a per-corpus Qdrant generation.
-
Optional graph context
Neo4j can store additional context to improve cross-file retrieval.
Quickstart Searching Indexing pipeline (deep dive)
Use stable corpus ids
Use lowercase slugs like myapp, docs, customer-a. Avoid spaces and special characters.
What indexing does
Indexing turns a folder into a set of retrieval primitives:
- Chunks (text/code spans) with file paths and line ranges — rows in Postgres
- Dense embeddings (vector search) in a per-corpus Qdrant generation
- Sparse index (IDF-modified BM25 via fastembed
Qdrant/bm25) in the same generation - Graph context (optional) stored in Neo4j
- Code graph (optional,
graph_indexing.build_code_graph) — module/class/function entities withcontains/inherits/imports/callsedges in Neo4j - Chunk provenance — every chunk carries typed provenance (extraction method; for Docling PDFs, cited pages plus normalized layout regions) that powers the source document viewer
- Figure descriptions (optional,
indexing.figures.enabled) — charts and drawings inside Docling-converted PDFs are described by a vision model and become retrievable chunks anchored to their page and bounding box; the chunk holding a described figure is stampedchunk_kind: "figure"so retrieval can prefer or filter figure evidence by metadata - Cross-file code-graph edges (
imports, andinherits/callsthat resolve to another file) are held back and written once after every file of the run is in Neo4j, so both endpoints exist under their real labels in either index order — no placeholder node is ever created for a target, and a call to an imported class resolves to the class node
Corpora indexed before provenance capture
Chunks from older runs report provenance as not captured, and rich documents (docx/pptx/xlsx/html) show a "not captured" state in the source document viewer until you re-index.
Graph indexing is policy-derived
There is no separate semantic-KG toggle. With graph indexing enabled, external document corpora run semantic entity extraction and code corpora can select the AST policy (graph_indexing.build_code_graph); runtime-managed corpora (Recall, Codex sessions) are excluded. A semantic run also requires a reviewed graph schema — RAG → Indexing walks you through generate → review → approve, and the API refuses a run without the approved hash. See the Indexing pipeline.
flowchart LR
A["Folder"] --> L["Load"]
L --> C["Chunk"]
C --> E["Embed"]
C --> S["Sparse index"]
E --> Q["Qdrant generation"]
S --> Q
C --> G["Neo4j (optional)"] Optional: AST code graph (structural code context)
For code corpora, ragweld can additionally build an AST code graph while indexing. It is off by default (graph_indexing.build_code_graph=false); enable it per corpus and re-index.
What lands in Neo4j:
- one module entity per Python, TypeScript, or JavaScript source file
- class and function/method entities with qualname, line range, and first-line signature
contains,inherits,imports, andcallsrelationships between them- every entity linked to the chunk that defines it, so graph retrieval can expand a hit to its callers, callees, base classes, and importing modules instead of only neighbouring chunks
Concept diagram (this mechanism only — the full fused pipeline is on the generated retrieval-pipeline page):
flowchart LR
SRC["Source file\n(Python / TypeScript / JavaScript)"] --> TS["tree-sitter AST pass\n(server/indexing/code_graph.py)"]
TS --> MOD["module entity"]
TS --> CLS["class entity"]
TS --> FN["function / method entity"]
MOD --> UPS["GraphRAG upsert\nper-file entities + intra-file edges"]
CLS --> UPS
FN --> UPS
MOD -->|"imports (cross-file)"| DEF["Deferred cross-file edges"]
CLS -->|"inherits (cross-file)"| DEF
FN -->|"calls (cross-file)"| DEF
UPS --> N4J["Neo4j"]
DEF --> UPS2["Relationship-only upsert\nafter the last file of the run"]
UPS2 --> N4J Conservative resolution
Imports and calls only produce edges when the target is defined inside the corpus or the import explicitly resolves to a corpus file. Everything else is counted as unresolved rather than guessed, keeping the graph high-signal.
Enable per corpus, then re-index
The code graph is built during indexing, so toggling build_code_graph has no effect until the corpus is re-indexed. It only pays for code corpora — leave it off for prose-only corpora.
Optional: Figure descriptions for PDFs (Docling picture enrichment)
For document corpora, ragweld can additionally describe figures — charts, diagrams, photos and engineering drawings inside Docling-converted PDFs — so they become retrievable chunks. It is off by default (indexing.figures.enabled=false) because every described figure is a vision-model call through the LiteLLM gateway.
What happens when it is on:
- Docling detects picture regions on each page and (with
indexing.figures.classify) records the figure kind — chart, diagram, logo, photo - Logos, signatures, icons (
indexing.figures.skip_classes) and figures belowindexing.figures.min_area_fractionare skipped for description — the skip applies when the classifier's confident (>= 50%) prediction names one of these classes, not when a listed class merely appears somewhere in the classifier's full prediction list; when the classifier ran, skipped pictures still render aFigure (logo)-style header (or their caption when one exists) plus the image placeholder, so the class name stays searchable text - Everything else is cropped at
indexing.figures.images_scaleand sent to the vision alias (indexing.figures.vision_model) for a structured description - The description becomes a retrievable chunk anchored to the figure's page and normalized bounding box, so a citation boxes the figure in the source document viewer
Concept diagram (figure enrichment only — the full fused pipeline is on the generated retrieval-pipeline page):
flowchart LR
PDF["Docling-converted PDF page"] --> DET["Picture region detection"]
DET --> CLS{"Classify\n(indexing.figures.classify)"}
CLS -->|"logo / signature / icon"| SKIP["Skip:\ncaption-only text"]
CLS -->|"chart / diagram / photo"| AREA{"Area filter\n(indexing.figures.min_area_fraction)"}
AREA -->|"too small"| SKIP
AREA -->|"passes"| CROP["Figure crop\n(indexing.figures.images_scale)"]
CROP --> VIS["Vision alias\n(indexing.figures.vision_model)"]
VIS --> CHUNK["Figure-description chunk\n(page + bounding box)"]
CHUNK --> QD["Qdrant dense + sparse generation"]
CHUNK --> PG["Postgres chunk row\nwith provenance"] What the vision model returns
The vision reply is parsed into a structured FigureAnnotation (server/models/index.py): a vision-judged kind (diagram, chart, schematic, photo, table, drawing, other), a dense prose summary — which is the text that gets embedded — and transcribed lists of labels (callouts, axis labels, legend entries, part numbers), components, connections (A -> B relations), values (numbers with units exactly as printed), and references (sheet/figure/table/section cross-references). The annotation is persisted in Chunk.metadata["figure"], so part numbers and callouts stay searchable verbatim even when they never appeared in the chunk's prose.
Three details worth knowing:
- Only prose gets embedded. The indexer renders the annotation as prose-only markdown (summary plus labelled lists); the JSON schema itself never enters the embedded text.
- Malformed replies degrade, never fail the run. The parser in
server/indexing/figure_prompts.pyunwraps code-fenced replies, repair-parses malformed JSON (trailing commas, unescaped newlines, replies truncated mid-object) throughjson-repair, falls back tokind: otherfor unrecognized kinds, and turns a fully non-JSON reply into the plain-text summary — a weird description can make one chunk worse; it cannot fail indexing. - Nothing quietly disappears. A figure whose caption and summary are both blank keeps its structured lists (
Labels,Components,Connections,Values,References) in the chunk text; a picture the classifier caught but the vision alias skipped still renders aFigure (chart)-style header plus the image placeholder; and the prose block appears exactly once even when Docling carries the reply on both the neweritem.metashape and its legacy annotations. Rendering itself lives inserver/indexing/figure_serializer.py, which blocks Docling's meta-serialization block so the raw vision JSON can never reach the markdown. - A blank reply is a failure, not a description. A vision reply of
""— the gateway returned nothing, or a reasoning alias spent its wholemax_completion_tokensbudget on its internal reasoning trace before writing any JSON — is treated exactly like no description was ever attached: the picture falls back to the header-only block and is counted asfigures_failedin the run summary, never parsed into aFigureAnnotation.
Described figures become figure chunks
A chunk whose text is majority covered by a described figure's source span is stamped as a figure chunk during provenance stamping (server/indexing/provenance.py): its chunk metadata carries chunk_kind: "figure", the parsed FigureAnnotation JSON under figure, and figure_class when Docling's picture classifier resolved a class name. Three boundaries keep this honest:
- The annotation must dominate the chunk. If the figure span covers at least half the chunk's character range, the chunk is a figure chunk. A chunk that merely brushes a figure at its edge keeps the figure's page region in its provenance but stays a text chunk, so ordinary prose next to a figure is never mislabeled.
figure_classis only written when classification resolved. A described-but-unclassified figure getschunk_kindandfigurebut nofigure_classkey — no metadata backed by nothing.- Only the marked chunk carries the annotation. Text chunks next to a figure get no
figurekey, so retrieval-side filtering onchunk_kindcan't accidentally pick up prose.
A described figure is one atomic chunk. The indexer cuts documents through chunk_document_with_figures (server/indexing/figure_chunking.py), which hands the chunker the described figures' [char_start, char_end) ranges (Chunker.chunk_document) so each figure block — caption, prose summary, structured lists, and the trailing image placeholder — is emitted whole, not windowed by size. This is what keeps a citation from landing on a mid-word fragment of a figure description (" … the following week.\nLabels: …"): only the text between figures is windowed by the configured strategy, and a document with no described figures chunks exactly as chunk_file would. When a figure block exceeds chunking.max_chunk_tokens, it is split only at its Labels: / Components: / Connections: / Values: / References: section headings (packed to the token budget), never mid-word — every piece still reconstructs the block and stamps as a figure chunk.
The figure marking rides the vector payload
The Qdrant writer carries chunk_kind and figure in the point payload (server/retrieval/qdrant_store.py), so dense and sparse hits read back as figures without a Postgres hydration step — previously only graph-hydrated chunks carried the marking, and a figure hit on the dense or sparse leg looked like ordinary page text to the citation UI. figure_class is deliberately not carried in the payload: nothing downstream reads the local classifier's class at retrieval time. Corpora indexed before this change still hold the metadata in Postgres; re-index to mark dense/sparse figure hits.
For retrieval, this means figure evidence can be preferred or excluded by chunk metadata without text sniffing, and extraction triages every picture into exactly one of three run-summary counts (across both the live item.meta shape and Docling's deprecated item.annotations shape): figures_described (the vision call returned non-blank text), figures_failed (the vision call was attempted but the gateway returned nothing — an unreachable alias, or a reasoning alias that exhausted its max_completion_tokens budget on its internal trace before writing any JSON), and figures_skipped (never attempted: below min_area_fraction, a confident skip_classes prediction, or describe off).
To measure whether these figure chunks actually move retrieval on a document corpus, score a page-grounded question set with the figure grounding eval.
The counts and the cost survive the run
These counts — plus a figure_description_cost_usd ceiling priced from catalog pricing for the run’s vision alias over its full completion budget — are persisted on the run summary under GET /api/index/{corpus_id}/runs/latest, so they stay auditable after the terminal stream is gone. The Dashboard cost card reads this record, and the Dashboard → System Recent Index Runs panel shows the counts per corpus.
Profiles are protocol, not configuration
The two prompt templates (technical_figure, schematic) live in server/indexing/figure_prompts.py as code — they are the reply-schema contract between ragweld and the vision alias, not per-corpus config. You choose the profile with indexing.figures.prompt_profile; the schematic profile additionally asks the model to put drawing number, sheet and revision into references, connector/pin/signal designators into labels, and every drawn connection into connections as A -> B with units exactly as printed.
Knobs that matter:
| Knob | Default | What it does |
|---|---|---|
indexing.figures.enabled | false | Turn figure description on per corpus |
indexing.figures.describe | true | Send each figure to the vision alias (off = captions and classification only) |
indexing.figures.vision_model | z-ai.glm-5.3-flash | Gateway alias for descriptions; must be vision-capable in the model catalog |
indexing.figures.prompt_profile | technical_figure | technical_figure for reports; schematic adds drawing number, sheet, revision and connector conventions |
indexing.figures.images_scale | 2.0 | Raster scale for figure crops (≈144 DPI at 2.0) |
indexing.figures.min_area_fraction | 0.02 | Skip icons and decorative marks |
indexing.figures.max_completion_tokens | 2500 | Output token budget per description; includes any reasoning tokens a reasoning vision alias spends before its JSON reply |
indexing.figures.concurrency | 4 | Parallel vision calls while converting one document |
indexing.figures.timeout_s | 90 | Per-figure vision call timeout (seconds) |
Enable it per corpus:
- Sectional PATCH is validated by Pydantic; re-index the corpus to describe figures
Figures & Vision card in the UI
The RAG → Indexing tab has a Figures & Vision component card with the same knobs in one place: enable/classify/describe toggles, a vision-alias picker filtered to vision-capable catalog aliases, the prompt profile, image scale, min area fraction, skip classes, concurrency, per-figure timeout, and the completion-token budget. The card warns in place when the selected alias is missing or not flagged vision-capable — the same condition that makes the run refuse to start with 409 figure_vision_alias — and the skip-classes field accepts a comma-separated list that is trimmed, de-duplicated and lower-cased to match Docling's classifier output.
Deep links from global search
A global-search hit (Ctrl+K) for any indexing.figures.* setting opens RAG → Indexing with this card selected and highlights the matching control, instead of landing on a raw Admin registry row (web/src/config/configDeepLinks.ts). The ?component=<id> link parameter is a one-shot navigation aid: it is applied and then stripped from the URL, so a shared or reloaded link cannot stick and re-open the card against the operator's will — pick a different card, leave the subtab and come back, and it stays where you left it. Inside the /rag dock the card still opens, but the URL is not rewritten.
Vision costs are per figure
A dense scanned PDF can hold hundreds of figures. Run /api/index/estimate first — the estimate includes the figure cost before you commit. There is no per-file figure cap: bound cost with min_area_fraction, skip_classes, and max_completion_tokens, and point hard scanned schematics at a stronger vision alias per corpus.
Automated end-to-end coverage
The whole figure workflow is exercised by web/tests/e2e/exhaustive/figure_workflow.spec.ts against a live stack with zero mocking: figures enabled from this very tab, estimate pricing (including the "cancelling starts no run" guarantee), a real index whose replayed run log reports figures_described, badged citations with boxed thumbnails, the source viewer's Figure description panel, and the field-clamping / commit-on-blur / Escape / nested-PATCH deep-merge behavior of these controls. See Testing for how the exhaustive suite is wired.
How the estimate prices figures
POST /api/index/estimate counts the PDF pages in scope (real page counts via pypdfium2, skipping files it cannot open), multiplies by the shipped heuristic of 0.4 describable figures per page (rounded; omitted entirely when it rounds to zero), and prices the result from the model catalog:
| Estimate field | Meaning |
|---|---|
estimated_figures | Figures expected to be described — PDF pages × 0.4 (rounded), only when indexing.figures.enabled and describe are on and the corpus has PDFs; omitted entirely when the heuristic rounds to zero |
figure_description_cost_usd | Vision-call cost for those figures: ~1,200 input tokens per figure (image crop at images_scale=2.0 plus the prompt) plus indexing.figures.max_completion_tokens output tokens, priced from data/models.json for indexing.figures.vision_model |
total_cost_usd | Embedding + (optional) semantic KG + (optional) figures, or null when any priced component lacks catalog pricing |
estimated_seconds_figures | Estimated figure-phase wall clock — the same figure count at ~20 s per vision call, divided by indexing.figures.concurrency; folded into the total time range |
Reading the numbers
The figure line is an estimate of an estimate: the 0.4 figures-per-page factor is a planning heuristic, not a measured count. Use it to decide whether to enable figure description on a large scanned corpus; the run summary's figures_described / figures_failed / figures_undescribed counts are the ground truth after indexing. If the figure line is missing entirely, either figures are disabled, describe is off, the corpus has no PDFs in scope, or the page count is too small for the heuristic to round up to a single figure.
In the RAG → Indexing tab the cost breakdown appears as Embed $X + Semantic KG $Y + Figures $Z (~N figures) next to the total, and the time estimate splits as Embed ~X + Semantic KG ~Y + Figures ~Z — the Embed line is the remainder of the total, so enabling figures no longer silently inflates it.
If you're not sure
Leave it off for text-heavy corpora. Turn it on for report/drawing corpora where "which chart shows X?" is a real question, start with the defaults, and check the run summary's described / failed / skipped counts before widening the filters.
Before you index: estimate size/time (optional)
Use the estimate endpoint to catch “oops, this repo is huge” early:
curl -sS -X POST "http://127.0.0.1:58012/api/index/estimate" \
-H "Content-Type: application/json" \
-d '{
"corpus_id": "demo",
"repo_path": "/absolute/path/to/your/project",
"force_reindex": false
}' | jq .
The estimate measures your corpus — and it is the consent gate
Tokens and chunks are measured, not divided out of bytes: POST /api/index/estimate samples files across every format in the corpus, runs them through the configured chunker, and scales by byte share (server/indexing/estimate.py). The dialog shows a point estimate with a band — Tokens (est): 362,000 (317,000–407,000) — plus how many files were sampled and how long the measurement took.
Three things can happen instead of a number:
- The estimator is warming. The first call after a service restart pays for loading the chunker's tokenizer (~27 s), so the endpoint answers immediately with nothing measured and
warmup_seconds_remainingfor the wait message; the UI shows "Preparing the estimator" and asks again. The Indexing tab warms the tokenizer from its status reads, so this is usually done before you click. - The sample is insufficient. If a file format was never measured, or the error band saturated past
indexing.estimate.max_relative_error(default0.9), the endpoint refuses with the reason and the real file inventory rather than extrapolating a guess — a cold run once measured 8 bytes of 8.5 MB and reported 15,437 tokens for a 3,531,477-token corpus, which is exactly what the refusal exists to prevent. - The estimate fails (for example, a registered relative path that no longer resolves — relative paths resolve against the project root). Index Now blocks: the run is not started, and an error banner names the corpus and the path that was looked for. No run starts without consent.
If the first estimate after a restart still times out, click Index Now again — the tokenizer is warm by then.
Start indexing
curl -sS -X POST "http://127.0.0.1:58012/api/index" \
-H "Content-Type: application/json" \
-d '{
"corpus_id": "demo",
"repo_path": "/absolute/path/to/your/project",
"force_reindex": false
}' | jq .
Monitor progress
curl -sS "http://127.0.0.1:58012/api/index/demo/status" | jq .
curl -sS "http://127.0.0.1:58012/api/index/demo/stats" | jq .
In the UI, this typically maps to RAG → Indexing and Dashboard → Storage.
Reading the run report
The replayed log comes from GET /api/index/{corpus_id}/runs/{run_id}/events, which returns an IndexRunEventPage — the most recent events, the run's real total, and where the slice starts. The header therefore reports what the run recorded, never the cap it asked for: a run whose log holds 1,284 events reads "showing the most recent 500 of 1,284 events" instead of "500 replayed events".
Two things the tab does so the signal survives the replay:
- Conversion heartbeats collapse. A long Docling conversion emits a
Converting <file>: still running (Ns elapsed)beat every few minutes, which used to bury everything around it — the figure summary included — under dozens of identical lines. Only the last beat per file is kept, labelled[N progress notices], so the figure summary and per-file events stay readable. - Figure outcomes are listed per document. When a figure-enabled run finishes, the tab shows a Figures this run failed to describe panel (or "Figures this run filtered out, as configured" when nothing failed), one row per document with failed / filtered-out / described counts, from the run's own per-document
figure_outcomeevents. "Failed" means the vision call was made and the gateway returned nothing — check the alias andindexing.figures.max_completion_tokens, then re-run with Force reindex. "Filtered out" means the picture never reached the vision call (indexing.figures.skip_classes,min_area_fraction, orclassify) — the configured rules working, not a fault. The event also carries per-figure detail for the non-described pictures (FigureOutcomeinserver/models/index.py— the Doclingself_ref, the 1-based page, the classifier class when one resolved, and a reason), so the panel can name which figures failed or were filtered out rather than only counting them.
Runs started outside the tab are mirrored, not lost
The RAG → Indexing tab used to only show progress for runs it started. That is no longer the case: the tab polls the corpus status endpoint every few seconds, so a run begun anywhere — a POST /api/index call from a script, another operator's browser, or a scheduled automation — appears in this tab exactly like a local one:
- the progress bar and current file update live
- the run's event log streams into the terminal pane (replayed from the start, then appended as new events arrive)
- Start is disabled and Stop is available, so you can still cancel a run you didn't begin
- the status panel adds a note: started outside this tab (API, another operator, or a schedule) — progress mirrored from the server
If a run you started in this tab is in flight, its own event stream owns the UI and the polling stays quiet — there is no double-reporting.
Concept diagram (the run-adoption mechanism only — the full fused retrieval pipeline is on the generated retrieval-pipeline page):
flowchart LR
subgraph s_sources["Run origins"]
UI["Indexing tab\nStart button"]
API["POST /api/index\n(scripts, CI, schedules)"]
OTHER["Another operator's browser"]
end
subgraph s_server["Server"]
IDX["Indexer\nruns latest"]
LATEST["GET /api/index/{corpus_id}/runs/latest"]
EVENTS["GET /api/index/{corpus_id}/runs/{run_id}/events"]
STATUS["GET /api/index/{corpus_id}/status"]
end
subgraph s_tab["Indexing tab (polling loop, every 3s)"]
POLL["Poll corpus status"]
ADOPT{"Run in flight\nand not started here?"}
MIRROR["Mirror progress,\ncurrent file, events"]
STOP["Stop button"]
DONE["On completion:\nrefresh stats + replay"]
end
UI --> IDX
API --> IDX
OTHER --> IDX
IDX --> STATUS
IDX --> LATEST
LATEST --> EVENTS
POLL --> STATUS
STATUS --> ADOPT
ADOPT -->|"yes"| MIRROR
MIRROR --> EVENTS
MIRROR --> STOP
ADOPT -->|"no (local stream owns UI)"| DONE
ADOPT -->|"run ended"| DONE Watch an API-triggered index from the UI
- Kick off indexing from a terminal:
curl -sS -X POST "http://127.0.0.1:58012/api/index" ... - Open RAG → Indexing with the same corpus selected. Within a few seconds you'll see the progress bar moving, the current file, and the "started outside this tab" note.
- The terminal pane replays the run's events from the beginning, so you don't need to have been watching when it started.
If you'd rather check programmatically, the run summary and events are plain GETs: /api/index/{corpus_id}/runs/latest and /api/index/{corpus_id}/runs/{run_id}/events?limit=500.
When a run goes quiet: the document extractor queue
Docling conversion (rich documents and PDFs — the same path that powers the figure descriptions above) is serialized process-wide: only one index run converts at a time, and a second run's files queue behind it while its status still reads indexing. On a busy box that wait can stretch for many minutes, so the run log now narrates both silences instead of leaving a queued run looking like a hang:
- Queued waits. After roughly 15 seconds waiting on the extractor, the run logs
Waiting for the document extractor: another index run is converting (<corpus> run <run id>) — queued Ns, and repeats the notice every ~60 seconds with the measured elapsed wait (not a repeated constant, so a 20-minute queue never reads as "queued 15s" twenty times). The acquisition itself is logged too (Document extractor acquired after Ns), so the gap is accounted for. - Long conversions. One scanned PDF can hold the extractor for tens of minutes. Past roughly 60 seconds inside a single conversion, the run logs
Converting <file>: still running (Ns elapsed)— the first beat answers "is it wedged?", and every beat after it repeats at a wider interval (~5 minutes) so a 40-minute conversion narrates itself a handful of times instead of writing 40 identical lines that bury every other event in the run log. A slow file never reads as a wedged worker, and the log around it stays readable.
Concept diagram (the process-wide extractor lock only — the full fused pipeline is on the generated retrieval-pipeline page):
flowchart LR
subgraph s_runs["Index runs (per-corpus fence)"]
RA["Run A\n(scanned PDF)"]
RB["Run B\n(queued)"]
end
subgraph s_lock["Process-wide Docling extractor\n(server/api/index.py)"]
L["Extraction lock\n(one conversion at a time)"]
end
subgraph s_log["Run log (JSONL events)"]
WAIT["Run B:\n'Waiting for the document extractor...'\nrepeats with the measured elapsed wait"]
ACQ["Run B:\n'Document extractor acquired after Ns'"]
BEAT["Run A:\n'Converting file:\nstill running (Ns elapsed)'"]
end
RA -->|"acquires"| L
RB -->|"waits on"| L
RB -->|"every ~60s while waiting"| WAIT
RB -->|"once free"| ACQ
L -->|"held by A, conversion otherwise silent"| BEAT Indexing looks hung: status is indexing, nothing is progressing
- Open the run's event log (RAG → Indexing terminal pane, or
GET /api/index/{corpus_id}/runs/{run_id}/events). Waiting for the document extractor … — queued Ns: your run is healthy and waiting its turn behind another corpus's Docling conversion. The notices repeat with the measured elapsed wait, so a long queue stays visible.Converting <file>: still running (Ns elapsed): the conversion itself is alive — large scanned PDFs are simply slow. Compare the elapsed time against the corpus before intervening.- Neither message and no recent events: make a second request (start/stop/delete); it answers
409naming the holding run's id, its fence phase (buildingorretiring) and the last step that run reported. A fence whose heartbeat is older thanindexing.index_run_lease_secondsis treated as crashed and taken over automatically.
Reindexing safely
Common reasons to reindex:
- you changed chunking rules
- you changed embedding model/dimensions
- you changed inclusion/exclusion patterns
- you upgraded graph building logic
Recommended workflow:
- Confirm the corpus is not currently indexing (
/api/index/<corpus>/status) - Decide whether you need a full rebuild (
force_reindex=true) - Start indexing and monitor
- Validate with a few known-good queries after completion
Embeddings are not always compatible
If you change embedding dimensions or switch providers/models, you usually need a full reindex. Mixing incompatible embeddings can silently degrade retrieval quality.
The knobs that matter (where to tune)
You tune indexing through config (Pydantic-first). For deep reference, see:
Here’s the short list of “most likely to matter” knobs:
| Goal | Knobs to look at |
|---|---|
| Better recall | chunk size/overlap, candidate top-k, include more file types |
| Better precision | tighter chunking, better reranking, raise confidence gates |
| Faster indexing | larger batches, skip graph build, skip expensive summarization |
| Lower cost | deterministic embeddings, smaller models, disable optional stages |
Troubleshooting indexing
Indexing never reaches complete
- Check
/api/readyfirst (DB connectivity). - Look at backend logs (in UI: Infrastructure → Docker or terminal output).
- If you see repeated failures on one file, temporarily exclude that file type and re-run.
Indexing is slow
- Large corpora + cloud embeddings will be bound by provider latency.
- On Apple Silicon, local/MLX paths may be faster for some stages.
- Disable optional graph stages until you have baseline search working.
I’m missing chunks / the index looks empty
- Verify the
repo_pathexists inside the environment that’s indexing (host vs container path mismatch is the classic failure). - Confirm you’re querying the correct
corpus_id(corpora are isolated).
Figure descriptions never appear
- Confirm
indexing.figures.enabledistruefor this corpus and the corpus was re-indexed after enabling — figures are captured during indexing, not retroactively. - Check the run summary: figures below
min_area_fractionor inskip_classesare counted as skipped and keep caption-only text. - Watch the run's final
Figure summaryevent:figures_described,figures_failed(the vision call was attempted but the gateway returned nothing), andfigures_undescribed(never attempted). If description was enabled but no figure came back described while pictures existed, the run logs a warning — Docling absorbs a per-picture vision failure, so an unreachable alias otherwise produces a run that looks completely successful. The warning tells you which shape it is:figures_failed > 0points at the gateway, the alias, or theindexing.figures.max_completion_tokensbudget (the call was attempted and billed but came back empty), while an all-skipped run points back atmin_area_fractionandskip_classes. - Use
/api/index/estimatebefore re-indexing large PDF corpora; figure descriptions are priced per figure.
Starting the run returns 409 with code: figure_vision_alias
indexing.figures.vision_model is either not a vision-capable gateway alias in the model catalog, or it cannot be routed right now (for example, the LiteLLM gateway is disabled). ragweld refuses the run before it takes the per-corpus run fence, so nothing is claimed, leased, or staged. Fix the alias — pick a vision-capable alias from the model catalog — or turn indexing.figures.describe off, then start the run again.