Features
Every implemented and planned feature, each with a usage or output example and a link to the source on main.
Everything indx does, feature by feature. Click an entry for an example and the implementing file on main. Colored chips are ladder kinds. A dashed Planned badge marks work that is aimed for but not built — everything else is implemented on main. The order these are built in is on the roadmap.
Load a source
Section titled “Load a source”URI schemes come from installed loader distributions, not from indx itself. Add your own.
file: and data: URIs
indx-loader-file resolves local files and data URIs. Set INDX_LOADER_FILE_ROOTS to restrict file: to specific directories; a path outside them is a 422 source_forbidden. Unset means any path the process can read.
file:///absolute/path/report.pdfdata:application/pdf;base64,JVBERi0xLjcK…packages/indx-loader-file/src/indx_loader_file/loader.py
http: and https: URIs
indx-loader-http fetches remote sources. Private, loopback, and link-local hosts are refused by default (INDX_LOADER_HTTP_ALLOW_PRIVATE_HOSTS opts in), and the check repeats on every redirect hop, up to five. INDX_LOADER_HTTP_TIMEOUT_SECONDS bounds each request (default 30).
https://example.com/filings/annual-report.pdf s3: URIs
indx-loader-s3 fetches one object per URI through boto3, installed with the s3 extra. Explicit INDX_LOADER_S3_ACCESS_KEY_ID and INDX_LOADER_S3_SECRET_ACCESS_KEY win; when unset, boto3’s own chain applies so a role on EC2 or EKS works without copied secrets, which is why INDX_LOADER_S3_BUCKETS fences which buckets a URI may name. INDX_LOADER_S3_ENDPOINT_URL points it at an S3-compatible server, and the acceptance suite runs it against one.
s3://filings/2025/annual-report.pdfpackages/indx-loader-s3/src/indx_loader_s3/loader.py
packages/indx-loader-s3/tests/test_loader.py
packages/indx-loader-s3/tests/test_integration.py
tests/bdd/features/s3.feature — Scenario: Plan an object fetched from S3
Inline base64 sources
An inline source travels in the request body, so it works on a stock install — nothing is fetched. data is strict base64.
{ "request_id": "quickstart", "source": { "type": "inline", "media_type": "application/pdf", "data": "JVBERi0xLjcKJc…", "filename": "invoice.pdf" }}packages/indx-interfaces/src/indx_interfaces/sources.py
packages/indx-source/tests/test_source.py
tests/bdd/features/plan.feature — Scenario: Plan a native multi-page document from JSON
Multipart upload
The HTTP adapter also takes the file itself: a file part beside a JSON request part, on plan, encode and embed alike. The read stops one byte past INDX_MAX_INPUT_BYTES rather than buffering what the limit exists to refuse, so a large source never becomes a third larger on the way in.
curl -F 'request={"request_id":"quickstart"}' -F file=@report.pdf \ http://127.0.0.1:8000/v1/planpackages/indx-app-server/src/indx_app_server/app.py
packages/indx-app-server/tests/test_app.py
tests/bdd/features/plan.feature — Scenario: Plan a scanned multi-page upload
gs: and az: sources Planned
Loader distributions indx ships for Google Cloud Storage and Azure Blob Storage. The shape is the one indx-loader-s3 now has, so no first-party package gains an object-store import and anyone could have written these already — what is planned is only that indx ships them. Each owes the same things: credentials through INDX_* and a deliberate answer on whether the vendor SDK’s ambient ones are honoured; an allowed bucket or container list, the object-store INDX_LOADER_FILE_ROOTS; the SDK behind an extra, unavailable rather than broken without it. One object, not a prefix — fanning out over a prefix is the planned corpus run.
Observe
Section titled “Observe”Observation is cheap, local, and deterministic. Observers arrive from installed distributions too. Add your own.
PDF observation application/pdf
indx-observer-pdf reports each page’s text-layer state plus font, image, and empty signals, without rendering anything. It walks form XObjects and decompresses a content stream only when no font and no image were found first.
packages/indx-observer-pdf/src/indx_observer_pdf/observer.py
Encrypted PDFs
A PDF encrypted with an empty user password needs nothing typed — it needs a dependency preflight did not have. pypdf[crypto] travels with indx-observer-pdf, so 任天堂’s 有価証券報告書 opens and routes to native extraction like any other filing. It was one of the benchmark’s two route misses until it did; accuracy went from 86% to 93% over the same labels, leaving handwriting as the only one left.
A PDF that genuinely needs a password the caller does not have is a typed refusal rather than a crash, and any other failure raised while opening a source is source_unreadable rather than an unhandled 500.
{"error": {"type": "invalid_source", "code": "source_encrypted", "message": "source is encrypted and could not be opened: …", "param": "source"}}packages/indx-observer-pdf/src/indx_observer_pdf/observer.py
Image observation image/jpeg image/png
indx-observer-image declares an image as one page with no text layer and an image signal. It opens no bytes at all — the media type is the evidence.
packages/indx-observer-image/src/indx_observer_image/observer.py
Multi-page TIFF observation image/tiff
A scan archive’s native container: one file, many pages, which is what separates it from the single-page images indx-observer-image already declares. The same distribution observes it, and it is the one format there that opens its bytes at all — the chain of image file directories gives the page count without decoding a pixel, so observation stays inside preflight’s budget.
Reading is the OCR lane that already reads a scanned PDF page, which is also the catch it shares with image/jpeg and image/png: nothing free reads an image, so the reading path is behind an extra. Without --extra ocr a TIFF plans, descends the whole ladder and reaches a human — honest, and the reason the benchmark install carries the extra.
Every offset is bounds-checked and every visited directory remembered: a truncated file points past its own end and a looping chain would otherwise spin forever, and both are reachable from bytes a caller supplied. BigTIFF is refused rather than misread — its magic is 43, its offsets are eight bytes wide, and this walk would silently misparse one. A directory flagged reduced-resolution is the thumbnail a scanner stores beside a page, not a page: the walk reads that one tag to skip it, and the readers skip the same frame, so a page number names one image on both sides of the plan.
packages/indx-observer-image/src/indx_observer_image/observer.py
Plain text observation text/plain
No engine and no extra: the bytes decode or they do not, and text-extraction declares native-extraction because reading a text file is not a routing decision. The smallest possible demonstration that installing a distribution is what makes a media type plannable.
A form feed is what a plain-text file uses to say “new page”, so it is what separates pages — and a file without one is a single page, which is the honest reading rather than an invented pagination.
Encoding is INDX_TEXT_ENCODING, a comma-separated ordered list defaulting to utf-8-sig, each tried strictly with the first that decodes winning. Japanese public-sector CSV is overwhelmingly Shift_JIS — 気象庁 and 東京都オープンデータ both serve CP932 — so a deployment reading those sets utf-8-sig,cp932. A file matching none of the named encodings fails rather than being mangled into whichever was tried last.
indx-observer-text declares no sniff, and that is an answer rather than an omission. A .txt, a .csv and a .tsv are the same characters with different separators inside, and the only signature anyone could write — “does this decode as UTF-8” — would claim JSON, XML and HTML on the way past, ahead of the declared type that is actually right.
packages/indx-observer-text/src/indx_observer_text/observer.py
CSV and TSV observation text/csv text/tab-separated-values
The same distribution and the same absence of an engine. Neither format paginates, so a delimited file is one page — which is what benchmark case schema 1.2 was relaxed to admit, a case with no pages to override having nothing to exercise the two-page rule with.
The delimiter is deliberately not sniffed. The media type already names it, and nothing downstream consumes rows: indx-chunker-page takes a page as one string, so csv.Sniffer would be a call with no reader for its answer. Rows and cells become addressable under the planned table-structure work, not here.
$ uv run indx plan file://$PWD/quarterly.csv document [text-extraction, manual-review] # one page, no enginepackages/indx-capability-text-extraction/src/indx_capability_text_extraction/reader.py
packages/indx-capability-text-extraction/tests/test_provider.py
Office observation .docx .xlsx .pptx
Each is a zip of XML, so indx-observer-office opens no engine and renders nothing — and neither does the reader. zipfile and xml.etree are the whole stack, which is why the package layer they share, indx-ooxml, is a dependency-free library and no distribution declares a third-party OOXML library or an extra to install one behind. The page count is the division the format itself states: the worksheet count, the slide count, the w:sectPr count. A Word page is a layout outcome decided by whoever opens the file, so counting one would mean rendering.
All three begin PK\x03\x04, as does every .jar, .epub and .odt, so sniff reads the zip’s member list at the end of the file rather than a prefix — the case whole-content detection exists for.
$ uv run indx plan file://$PWD/report.xlsx document [office-extraction, manual-review] # 28 sheet tabspackages/indx-observer-office/src/indx_observer_office/observer.py
DXF observation and reading image/vnd.dxf
A CAD drawing saved as DXF is a native text layer: every TEXT and MTEXT, the attributes on a block reference such as a title block, and a dimension’s rendered measurement sit in the file at positions the file states. indx-observer-dxf counts its pages as the layouts a CAD program shows (the model space and each paper-space layout, in tab order) and indx-capability-dxf-extraction reads them on the free rung, each text located, so indx-chunker-lines gives a drawing the rectangles a PDF page gets. Both go through ezdxf, which reads binary and ASCII files of every version, the code page, MTEXT formatting and block transforms; a layout with geometry and no text is honestly missing, and since no rung renders a drawing it lands on manual review. The demo host draws the layout behind the rectangles through ezdxf’s SVG backend, on every host, with the drawing’s fonts replaced by a CJK font the host has, so Japanese renders where one is installed.
$ uv run indx plan file://$PWD/tests/fixtures/drawing-title-block.dxf document [dxf-extraction, manual-review] # 3 layouts: Model, Layout1, Sheet1$ uv run indx encode file://$PWD/tests/fixtures/drawing-title-block.dxf | jq '[.blocks[] | select(.kind=="chunk")] | {n: length, boxed: (map(select(.bbox != null)) | length)}'{ "n": 11, "boxed": 11 }packages/indx-dxf/src/indx_dxf/drawing.py
CAD beyond the text layer Planned
What a drawing carries besides its text: dimensions as geometry rather than as a rendered measurement, block references as the parts they are, and the layers a drafter used to mean something. DWG, which is what most CAD programs save, only through conversion to DXF or not at all; and whether any 3D format (STEP, IFC) is in scope is a decision, not a default. Sprint 2 writes that scope against the 2D/3D demand before anything is built.
Slide geometry .pptx
indx-chunker-pptx cuts a slide into the shapes it is made of, each at the box the file states: one chunk per text-bearing shape or table, one image chunk per raster picture, in document order. Group transforms are applied and a placeholder’s frame is inherited from the layout and master, so a title with no position of its own still lands where PowerPoint draws it. Symbols, connectors and container rectangles are not chunks, since a chunk is something a caller can retrieve; their geometry belongs to the process-chart parser.
A chunk without a rectangle says why, as a typed bbox_reason: no_geometry for a format with no positions, hidden for a shape the source hides, unresolved for a position the file did not state. The model refuses a chunk that is silent about it.
$ uv run indx encode file://$PWD/tests/fixtures/office-process-chart.pptx | jq '.blocks[] | select(.id=="page:2/chunk:9") | {text, bbox, bbox_reason}'{ "text": "非表示メモ", "bbox": null, "bbox_reason": "hidden" }packages/indx-ooxml/src/indx_ooxml/drawingml.py
Process chart signature .pptx
A 工程系統図 drawn with native PowerPoint shapes is read as a graph, not only as text. With signature_detection on, indx-capability-process-chart walks every slide’s shapes at plan time and matches a slide whose symbols (△ material, ○ operation, ◇ inspection, two nested △ assembly) are joined by two or more connectors across two or more symbol kinds; the share of symbols carrying a label only raises confidence. The match is page-scoped, so process-chart-parser leads the route for that slide alone and the deck keeps its generic route. The parser returns the text office-extraction would have and adds metadata.process_chart to the page block: nodes (symbol, picture, container, text, each with the rectangle a chunk uses) and edges (connection from the connectors’ own stCxn/endCxn references, oriented by the arrow when there is one and top to bottom otherwise; contains for a region’s rectangle). Which text labels which symbol is the one heuristic, and it is marked as one.
$ uv run indx plan --signatures file://$PWD/tests/fixtures/office-process-chart.pptx document [office-extraction, manual-review] page:2 [process-chart-parser, office-extraction, manual-review] # process-chart: connected_symbols, symbol_family, labelled_symbolspackages/indx-capability-process-chart/src/indx_capability_process_chart/chart.py
packages/indx-capability-process-chart/tests/test_provider.py
OCR line geometry .pdf .tiff .png
A page read by recognition used to arrive as one chunk with no positions, because the OCR reader joined the engine’s lines into text and threw their boxes away. Now generic-ocr states each recognized line on PageOutput.lines, a port-model field that never reaches the wire, and indx-chunker-lines cuts them into one chunk per line at its rectangle, for any media type and without a second engine run. A scanned page gets the rectangles a text layer gets, so the playground draws a scan the way it draws a PDF and a retriever indexes its lines rather than its page. A reader that states no lines leaves the page to the one-chunk floor as before.
$ uv run indx encode file://$PWD/scan.pdf | jq '[.blocks[] | select(.kind=="chunk")] | {n: length, boxed: (map(select(.bbox != null)) | length)}'{ "n": 119, "boxed": 119 }packages/indx-chunker-lines/src/indx_chunker_lines/chunker.py
Email observation message/rfc822
.eml through the standard library: headers and body are read, and each attachment is named rather than opened. Recursing into an attachment turns one source into many, which is the corpus run’s problem and not an observer’s — so an unread attachment is named in the output rather than silently dropped.
Observation parses headers only, which is what keeps it inside preflight’s budget and what makes bytes carrying no headers a typed refusal rather than a message with a file for a body. Five headers reach the block — From, To, Cc, Date, Subject — because everything a mail server stamps on the way past is transport, and burying a subject line under Received chains is how a vector stops matching what anyone searches for. An HTML-only body is stripped to its visible text with html.parser.
From: Aiko Tanaka <aiko@example.co.jp>Subject: 第1四半期の請求書
Attached is the quarterly invoice.
Attachments:- invoice-2026q1.pdf (application/pdf)packages/indx-capability-email-extraction/src/indx_capability_email_extraction/reader.py
packages/indx-observer-email/src/indx_observer_email/observer.py
A plan is decided from what the observers reported, never by running a route. Identical inputs yield an identical plan_id.
The routing ladder with declared fallbacks
Every plan escalates over kinds: native text layer → OCR → VLM → manual review. Every rung below the selected one stays in the plan as an ordered fallback.
mixed-multipage.pdf document [native-extraction, generic-ocr, generic-vlm, manual-review] page:2 [generic-ocr, generic-vlm, manual-review] # scanned pagepackages/indx-router/src/indx_router/policy.py
packages/indx-router/tests/test_policy.py
tests/bdd/features/encode.feature — Scenario: Encode without supplying a plan
Signature detection and nomination
Opt in with --signatures (CLI) or signature_detection: true (HTTP/Python). The invoice signature matches when at least two of its four regex signals hit, in English or Japanese, and nominates the parser ahead of the ladder — never in place of it.
$ uv run indx plan --signatures file://$PWD/tests/fixtures/invoice-anthropic.pdf document [invoice-parser, native-extraction, generic-ocr, generic-vlm, manual-review]packages/indx-capability-invoice/src/indx_capability_invoice/provider.py
packages/indx-router/tests/test_signatures.py
packages/indx-capability-invoice/tests/test_provider.py
tests/bdd/features/signatures.feature — Scenario: A recognized invoice is routed to its specialist
A parser-only format is a loud unsatisfied plan
A parser reaches a route through nomination and no other door, and signature_detection defaults to false. So installing a specialist for a format nothing else reads used to convert a 415 into a silent manual-review route — a human queue at $2 and a day per page, with the installed parser sitting unused and nothing in the plan saying so.
That case is now an unsatisfied plan whose constraint names both the parser and the flag that would reach it, and the executor refuses unsatisfied plans, so encode says the same thing. The constraint fires only when detection never ran: a detector that was consulted and matched nothing is the system working, and manual review is then the honest answer.
routing: application/vnd.acme.contract is read only by parser acme-contract-parser,and a parser reaches a route only through signature nomination-- plan with signature_detection=truepackages/indx-router/src/indx_router/policy.py
Business constraints
Five fields shrink the ladder before it runs, and two more name the reader outright (below). data_residency refuses capabilities on external devices; an impossible combination is reported as an unsatisfied plan, not an error.
{ "constraints": { "minimum_quality": 0.6, "deadline_ms": 30000, "maximum_cost_usd": 0.05, "gpu_allowed": false }}Cost, latency and quality before anything runs
Every plan carries what the decided route is expected to cost, take and be worth — the numbers the constraints above filter against, published as the plan’s own price. They are keyed by kind rather than by capability ID, so a third-party OCR distribution is priced like OCR, and a deployment writes its real figures down in INDX_ROUTING_ECONOMICS. The defaults are an admitted assumption with an override, not a measurement.
{"estimates": {"cost_usd": 0.0005, "latency_ms": 400, "quality": 0.8}}Supplied plans, replayed exactly
A stored plan can be handed back to encode(). The executor verifies the source digest, media type and snapshot; a mismatch is a 409 plan_conflict, never a silent re-plan.
plan = service.plan(PlanRequest(...))result = service.encode(EncodeRequest(..., plan=plan))packages/indx-executor/tests/test_executor.py
tests/bdd/features/encode.feature — Scenario: A plan decided for another source is refused
Name the reader in the request
capability_ids is an allowlist on the constraints and fallbacks_allowed turns fallbacks off. The allowlist filters every route a page could take, including nominated parsers and manual review, and the ladder still orders what it admits. With both set, the plan names one reader per page. A page nothing on the list reads comes back unsatisfied naming capability_ids, and so does an ID the snapshot does not hold. Execution is unchanged because it already runs only what the plan named: without fallbacks, a failed read is the existing 503 naming the page and the reader. If you leave both fields out, the ladder decides, as before.
{ "constraints": { "capability_ids": ["native-extraction"], "fallbacks_allowed": false }}packages/indx-router/src/indx_router/policy.py
packages/indx-router/tests/test_policy.py
developer/decisions — ADR-0063
Recipes and similar-format reuse Planned
A recipe is examples plus a schema, stored per recognized format — in-context, no fine-tuning. Past cases are looked up by document signature, so a new format that resembles a known one sets up fast.
Read pages
Section titled “Read pages” native text layer native-extraction
Extracts an existing PDF text layer on CPU. No extra, no model, near-zero cost — the ladder’s first rung.
packages/indx-capability-native-extraction/src/indx_capability_native_extraction/provider.py
packages/indx-capability-native-extraction/tests/test_provider.py
native text layer office-extraction
Reads .docx, .xlsx and .pptx on CPU with no dependency at all — the characters are already in the file. A worksheet comes back as tab-separated rows with blank cells holding their column, a slide as its runs in document order, a Word section as one line per paragraph. Sheet and slide order comes from the referencing part’s relationships, never from member names: sheet1.xml is not required to be the first sheet.
A chart sheet is a tab, so it is a page, and it holds a picture rather than cells — it comes back failed with a reason and the ladder descends, because an empty string would claim a blank sheet had been read.
this sheet holds a chart rather than cellspackages/indx-capability-office-extraction/src/indx_capability_office_extraction/reader.py
packages/indx-capability-office-extraction/tests/test_provider.py
ocr generic-ocr –extra ocr
PP-OCR through onnxruntime, on CPU, for PDFs and images. Without the extra it appears as an unavailable descriptor that names what is missing, and the ladder descends past it.
unavailable_reason: "install the 'ocr' extra; missing: rapidocr"packages/indx-capability-generic-ocr/src/indx_capability_generic_ocr/provider.py
vlm generic-vlm –extra vlm …
Any vision model through LiteLLM. Needs the extra and INDX_VLM_MODEL. Runs on GPU locally, or as an external device when hosted — which is what data_residency filters on.
packages/indx-capability-generic-vlm/src/indx_capability_generic_vlm/provider.py
manual review manual-review
The terminal rung. Declares no media types because a human can read anything; it cannot fail over, which is what keeps unreadable pages visible instead of dropped.
packages/indx-capability-manual-review/src/indx_capability_manual_review/provider.py
packages/indx-capability-manual-review/tests/test_provider.py
Output validation, and fallback per outstanding page
Fallback used to fire on absence or an exception and never on bad output — so a capability that declared failed for a page was counted as having answered it, kept the page, and stopped the ladder. PageOutput.status and reason exist precisely so a reader need not signal failure by silence, and nothing read them.
Three checks now, ordered by how much each assumes. Absence assumes nothing. A declared failed assumes nothing either — the capability said so itself. A self-reported confidence under INDX_VALIDATION_MIN_CONFIDENCE assumes a number, which is why that floor defaults to 0.0 and ships off: nothing here has measured a threshold against a labelled corpus. unreadable is deliberately not a refusal — it is a verdict about the content rather than the attempt, and failing over from the terminal rung would answer every scanned page on a default install with a 503.
The ladder descends per outstanding page, not per group. A capability that read eight of ten pages keeps those eight and sends two down; retrying the whole group would spend a vision model on pages OCR had already read, which is the cost routing exists to avoid.
packages/indx-executor/src/indx_executor/validation.py
packages/indx-executor/src/indx_executor/settings.py
Read difficult enterprise data Planned
An eligible reading path for scans, handwriting, spreadsheets, drawings and large files. The target is ten media types, and one counts only when this install observes it, some capability reads it, and one admitted benchmark case scores it — a typed 415 is an honest answer and is not coverage.
Twelve media types now observe and read, which is every type the target names plus two slots of slack: application/pdf, image/jpeg, image/png, image/tiff, text/plain, text/csv, text/tab-separated-values, .docx, .xlsx, .pptx, message/rfc822 and image/vnd.dxf.
The benchmark computes the coverage figure rather than leaving it to be counted by hand, and it reads 5 — application/pdf, .xlsx, .pptx, text/csv and image/jpeg. It read 2 for two slices while all eleven types were already readable, which was the rule working rather than a regression: a type counts only when an admitted benchmark case scores it, and nine of the eleven were scored by nothing.
Three of those nine are admitted now — デジタル庁’s 重点計画 概要 for .pptx, 警察庁’s 警備業 statistics for text/csv, and a 国土交通省 土地分類図 scan for image/jpeg. Six remain, and for four of them the blocker is that the document does not appear to exist publicly: ten Japanese government .docx files all declare one section where the page rule needs two, both public text/plain resources carry no form feed, and the national open-data catalogue holds no TSV and no TIFF at all. A page that asserted 11 by hand would be wrong in exactly the way computing it prevents.
.dwg and .xdw are recorded as documented refusals rather than reading paths, because neither has an open reader. Handwriting is the benchmark’s one route miss.
Measure the Office readers against a library Planned
office-extraction walks zipfile and xml.etree by hand, which made a free rung free and kept the default install dependency-free. What it is not is a measured answer: nobody has compared the text it produces against what openpyxl, python-docx and python-pptx produce from the same bytes, and the places hand-walking is plausibly worse are already visible. Number formats — a cell comes out as its stored value, so a date is Excel’s serial and a percentage its fraction. Merged cells — a merged header reads as one label followed by blank columns. Speaker notes, headers, footers, footnotes, comments and text boxes, which live in parts the reader was never given. Formula cells, where the cached value is right only for a file Excel last saved. Revision marks and field codes.
Measure first, against the labelled subset the benchmark still owes — “the library’s output looks more thorough” is a preference and not a result. If a library wins it goes behind an extra on this distribution or into a second one with its own capability ID, never as a plain dependency, because the free rung has to stay installable with nothing. And whatever reads, the page enumeration may not move: a library that skips chart sheets or follows member names would silently disagree with the plan the document was routed under.
packages/indx-capability-office-extraction/src/indx_capability_office_extraction/reader.py
OCR corrections and recovered lines, reported per block Planned
generic-ocr returns what the recognizer read, and a misread (受理目 for 受理日) or a line it dropped goes through silently. The target reports both on the block that carries them: ocr.lines for how many recognized lines it joined, ocr.corrections as pairs of what was read and what the block now says, and ocr.recovered for a line restored from a second look. A correction rewrites the block’s text, so it is exactly the case ADR-0030’s revisit trigger names: entity offsets must index the corrected text, not the recognizer’s.
PDF line chunker
indx-chunker-pdf merges PDFium text runs into visual lines, one chunk per line with a normalized bounding box. A real chunk block, truncated:
{ "id": "page:1/chunk:1", "kind": "chunk", "parent_id": "page:1", "bbox": [0.889, 0.944, 0.949, 0.954], "text": "Page 1 of 1", "embeddings": [{"embedding_space_id": "default-text", "embedder_id": "hashed-text", "vector": [0.0, …]}], "provenance": {"capability_id": "native-extraction", "device": "cpu", "fallback_index": 0}}Page fallback chunker
indx-chunker-page is the floor: one chunk per readable page, any media type, no bbox. It sets fallback = True, which sorts it after every other installed chunker.
Region blocks are refused, not dropped
granularities: ["region"] is a 422 unsupported_granularity naming what this installation does produce. It used to be a 200 carrying a document block and no regions, with nothing saying why — and a caller could not tell “this install draws no regions” from “this document has none”.
The refusal runs before the source is fetched, so a request nothing can answer costs no transfer, and the message enumerates document, page, chunk the way the 415 for an unresolvable scheme enumerates the schemes it resolves. Producing regions is still the other half of the answer and is still Planned: RegionEvidence stays unproduced until a measurement shows a region changes a route, and inventing one here would publish an address nothing decided. A chunk’s bbox is the retrieval answer to that gap, not the addressing one.
{"error": {"type": "unsupported_granularity", "code": "unsupported_granularity", "message": "no installed capability produces region blocks; this installation produces document, page, chunk", "param": "granularities"}}packages/indx-executor/src/indx_executor/service.py
Chunk-size control Planned
Today nothing lets a caller or a deployment ask for coarser or finer chunks than the installed chunkers produce. A native PDF gets line-grained chunks whether they are headed for a 128-token embedding model or a screen.
Chunker selection beyond list order Planned
Who draws a boundary is decided by list order alone today — installed, then built-in, then fallback, first answer per page wins. That is the observer rule reused, and it is enough while exactly one non-fallback chunker exists. Nothing scopes a chunker to a media type the way SourceLoader declares its schemes, and nothing lets a deployment prefer one of two.
Two non-fallback chunkers claiming the same pages resolve by discovery order, first-wins and quiet. Revisit when a real second chunker exists, not before: designing the tiebreak ahead of the tie is guessing. Distinct from chunk-size control above, which is the caller’s lever rather than the deployment’s ordering.
Name the chunker in the request Planned
The entry above is the gap; this is the shape of the answer, and it is the classifier’s shape rather than a new one. Chunker gains an id, the installed IDs ride the capability snapshot outside its content hash — so installing a chunker still moves no plan — and EncodeRequest names them in the order they should be asked. An ID nothing installed declares is a 422 that enumerates the ones that are, raised before the source is fetched.
The default stays exactly what happens today: name nothing and the installed order decides, floor last. That is what makes this an override rather than a new obligation on every caller — a request states a preference only where it has one, and the deployment’s default is the honest answer everywhere else.
Chunking touches no plan, so none of this moves POLICY_VERSION or the snapshot ID. It does move openapi.json and the generated client, which is why it waits for a second chunker to make the choice real.
Sentence and paragraph chunk boundaries Planned
indx-chunker-pdf cuts on visual lines, which is where the glyphs sit and not where the meaning ends: a wrapped sentence becomes two chunks and a paragraph becomes a dozen, so each vector carries a fragment. Merging lines into sentences and paragraphs — the same PDFium runs, the same CPU, still one bbox per chunk — is the candidate for a better default. Distinct from chunk-size control above, which is the lever; this is the default that lever would adjust. Which boundary retrieves better is a measurement, not a preference.
The sales page draws the target: one paragraph block per paragraph, with the box around all of its lines and the count of lines it joined. encode returns the lines.
Table structure blocks Planned
A table drawn on a page is not a spreadsheet file, and the two are separate problems. A .xlsx is read now and comes back as tab-separated rows, one page per sheet tab, with the grid flattened; a .csv and a .tsv are read as one page of text with the delimiter left where the document put it, because nothing downstream consumes rows yet. The rows and cells inside a PDF or a scan are read today, but survive only as the chunker’s lines in reading order, with the grid gone.
The sales page draws the target: one table block with its columns, and cells that each carry a row, a column, their own box and, where the text is an amount or a date, a normalized value beside the printed one (0円 → 0 JPY). A table a paragraph points to (“次表”) names that paragraph under referenced_by.
Heading blocks and a section path on every block Planned
A heading is a line today, indistinguishable from the paragraph under it. The target is a heading block with a level, and a section on every block: the path of headings above it (別紙 › 第1 認定の基準 › 次表), so a search hit or an agent’s citation says where in the document it sits, not only on which page. On a PDF this is font size, weight and numbering read from the same PDFium runs; on Office it is the style the file already names. It is also what makes a paragraph block’s boundaries honest, because a heading ends one.
Page furniture: running headers and footers marked, kept out of search Planned
A running header such as (参考:改正後全文) or a page number is a chunk like any other today, so it is embedded and a search can return it. The target is a page_header or page_footer block, kept in the output so the page is still whole, and excluded from embedding and search. Recognising it is a repetition across pages at the same position, which is a document-level pass rather than a per-page one.
A block that continues across a page break Planned
A paragraph that runs from page 22 onto page 23 is two unrelated chunks today. The target keeps it as one reading: the block on page 22 names continues_on, the one on page 23 names continues_from, and each keeps its own page and box so provenance is not blurred. It needs paragraph blocks first, and a document-level pass, because a chunker is asked one page at a time.
A diagram as a block, with a box per symbol and connector Planned
The process chart’s graph is read today and written to the page block’s metadata["process_chart"]; no chunk carries it, and no node or edge has a box on the page. The target is a diagram block with its diagram_type, its box, and nodes and edges that each have their own box, so a person can point at a symbol on the slide and see its node. Legend rows resolve onto the nodes they explain (含侵 → 真空含浸機, the node’s equipment), and the block lists the exports it can be written as. A ChunkPiece carries text or an image today, never a structure, so this is a contract change.
default-text hashed space
A 256-dimension character-n-gram space with no model behind it — pure arithmetic, so a stock install can embed and search deterministically. Cosine metric, L2 normalization.
packages/indx-capability-embedding-hashed/src/indx_capability_embedding_hashed/provider.py
packages/indx-capability-embedding-hashed/tests/test_hashed.py
tests/bdd/features/embed.feature — Scenario: Embed a text query in a declared space
minilm-multilingual and clip-vit-b32 –extra fastembed
MiniLM (384 dimensions, multilingual text) and CLIP (512 dimensions). CLIP’s image lane answers both queries and the document side — the latter for pages nothing could read as text, which the PDF chunker renders. Text wins wherever a page has both.
uv run indx embed --space minilm-multilingual "annual recurring revenue"packages/indx-capability-embedding-fastembed/src/indx_capability_embedding_fastembed/provider.py
packages/indx-capability-embedding-fastembed/tests/test_engines.py
hosted-text –extra hosted …
Any hosted embedding model through LiteLLM. Needs INDX_EMBED_MODEL and INDX_EMBED_DIMENSION; unconfigured, it advertises no space at all rather than a broken one.
packages/indx-capability-embedding-hosted/src/indx_capability_embedding_hosted/provider.py
packages/indx-capability-embedding-hosted/tests/test_hosted.py
Document-side visual embeddings –extra fastembed
A page no capability could read as text is rendered by indx-chunker-pdf into one image chunk and embedded by CLIP’s vision lane, so it has a vector instead of nothing. Before this it left a page block carrying its reason and no retrievable content at all, because document embedding only ever vectorizes chunks and a page without text produced none.
The measurement came first and is committed as a test: over two admitted benchmark documents, page-image vectors answer a visual query correctly where the same pages’ extracted text does not — on the 富山県 drawing, 803 characters of Japanese title block leave “an engineering drawing” nearer a page of receipts than the drawing itself. Rendering is confined to pages a reader reported failed or unreadable; a page that read as an empty string was read, and is blank.
packages/indx-chunker-pdf/src/indx_chunker_pdf/chunker.py
packages/indx-capability-embedding-fastembed/tests/test_clip_compatibility.py
Enrich
Section titled “Enrich”Document classification, enabled per request
A classifier says what a document is — its type, the unit that produced it, the industry it belongs to — as facets of ranked labels on the document block’s metadata under classification. It is a plugin port cloned from the language detector and asked with text, with one difference that shapes the rest: a classifier costs a call, so nothing runs that the request did not name. document_ids enables installed classifiers in the order they are asked, the first with an opinion on a facet wins it, and the installed IDs are advertised on the capability snapshot outside its hash. A classifier that declares the facets it can answer is skipped whole once an earlier one has taken all of them, so enabling two lanes for one facet pays for one call and not two. An ID nothing declares is a 422 naming the ones that are.
The classifier is handed the text and bounds it itself if it needs to. Sampling is not a request field and not a deployment default — it belongs to the implementation, which is the only party that knows whether it has a token window or a bill. This port is one of five: the same answer at page and chunk granularity, and entity extraction as the other return shape.
uv run indx encode --classifier words file://$PWD/tests/fixtures/invoice-anthropic.pdf{"id": "document", "metadata": {"classification": {"document_type": [{"label": "invoice", "confidence": 0.5}]}}}packages/indx-executor/src/indx_executor/classification.py
packages/indx-executor/tests/test_executor.py
Document classifiers
Section titled “Document classifiers”Three implementations of the port above, each its own distribution and each enabled by its ID. They share one taxonomy file format, so an operator maintains one label table for all three.
Word signatures words with Japanese and English business taxonomies
The classifier a default install carries: a label is a list of words and a threshold, and a document carries the label when enough of the words occur in its text — the invoice signature’s rule generalized to a table an operator can write. No model, no extra, a casefolded substring scan on CPU, which is also the admitted limit: 「金融」 fires inside 「金融庁」, and the confidence is the share of the list that matched, a ratio and not a calibrated probability.
Three taxonomies ship, each label named in both languages: document_type (請求書 invoice, 見積書 quotation, 契約書 contract, 有価証券報告書 annual securities report, 稟議書 approval request and fifteen more), business_unit (営業 sales, 経理・財務 finance, 人事 HR, 法務 legal and eight more) and industry (製造業, 金融, 不動産, 建設, 官公庁・自治体 and nine more). INDX_CLASSIFIER_WORDS_LABELS points at a JSON file that replaces them; the other two classifiers read the same file and use only the names.
$ uv run indx encode --classifier words file://$PWD/tests/fixtures/invoice-japanese.pdf document classification: {"document_type": [{"label": "invoice", "confidence": 0.38}]}packages/indx-classifier-words/src/indx_classifier_words/classifier.py
packages/indx-classifier-words/src/indx_classifier_words/taxonomy.py
Zero-shot classification zeroshot –extra zeroshot
MoritzLaurer/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7 — MIT, 0.3B parameters, 0.80 accuracy on Japanese XNLI, English MNLI in training — run as the transformers zero-shot pipeline written out over onnxruntime: each label becomes one hypothesis (This document is a invoice (請求書).) against the sample, and the entailment probability is the label’s confidence. The ONNX export the Xenova mirror ships means no torch in the lock; the runtime is what OCR and the embeddings already use. Weights download on first use, never during discovery.
Two admitted limits. The token window is narrower than most documents, so the model truncates what it was handed and reads the head of it. And the shipped test that classifies a Japanese and an English invoice runs only where the weights are cached; CI installs no extras and downloads nothing.
$ uv run indx encode --classifier zeroshot file://$PWD/tests/fixtures/invoice-japanese.pdf document classification: {"document_type": [{"label": "invoice", "confidence": 0.83}, …]}packages/indx-classifier-zeroshot/src/indx_classifier_zeroshot/engine.py
packages/indx-classifier-zeroshot/src/indx_classifier_zeroshot/classifier.py
LLM classification llm –extra llm …
Any model LiteLLM reaches, given the taxonomy and the text and asked for one JSON object per facet. Needs the extra and INDX_CLASSIFIER_LLM_MODEL; unconfigured, it advertises no classifier at all. A hosted model is an external device, and a request carrying data_residency that enables it is refused before the source is fetched rather than classified elsewhere — refused, not skipped, because the caller asked. What the vendor charged reaches usage.cost_usd.
INDX_CLASSIFIER_LLM_MODEL=anthropic/claude-sonnet-5 uv run indx encode --classifier llm file://$PWD/report.pdfpackages/indx-classifier-llm/src/indx_classifier_llm/classifier.py
Classifiers per page and per chunk
The same classifier answer at a finer unit. PageClassifier is asked once per page and ChunkClassifier once per chunk, returning the same facets of ranked labels through the same classify(text) — only the unit and the provider hook change. A filing whose cover page is a form and whose body is correspondence has no single document type, and that is the case the document-level port cannot state.
Page answers land on each page block’s metadata under classification; chunk answers land on the document block under chunk_classification, keyed by chunk block ID, because chunk blocks carry no metadata of their own. The first classifier with an opinion wins a facet for that unit, not for the document — which is the merge rule a single port with a target field could not have got right.
A chunk classifier requires chunk granularity: naming one without asking for CHUNK is a 422 before the source is fetched, since there would be nothing to classify.
All three shipped classifiers, words, zeroshot and llm, are declared through the page and chunk hooks as well as the document one: one object, three units, and the caller chooses the unit by the field it names. The playground’s “Classify” selector does the same.
Entity extraction over pages and chunks
PageEntityExtractor and ChunkEntityExtractor find the names in a text and say where they are: a label mapped to EntitySpan entries carrying the surface text, a [start, end) character range, and a confidence. This is the second axis of the port grid — a classifier says what a text is, an extractor says what is named in it. There is deliberately no document-level extractor, because pages already tile a document with no gaps.
A span is a character offset into the text of the block that names it. Every block already carries text, so a caller resolves any span against data it already received — no offset table, no coordinate rewriting, nothing new on the wire. Characters rather than bytes is load-bearing: in UTF-8 Japanese the two differ by a factor of three.
Spans ride the document block under a third reserved key, entities, keyed by the block each was found in; chunk blocks stay inert. Beside them the executor writes the aggregation: a count per (label, NFKC-normalized surface form), with the raw surface forms kept. Normalization merges 「ABC株式会社」 with ABC株式会社 and stops there — a count is not an identity, coreference is out of scope, and 「山田太郎」 and 「山田」 stay two entries.
{"id": "document", "metadata": {"entities": { "by_block": {"page:1": {"organization": [{"text": "ABC株式会社", "start": 12, "end": 19, "confidence": 0.94}]}}, "counts": {"organization": [{"surface": "ABC株式会社", "count": 3, "forms": ["ABC株式会社", "ABC株式会社"]}]}}}}developer/protocols/entity-extractors
NER for Japanese and English, in four lanes
Four distributions of the extractor ports, cut where the dependency is, the way the embedding and classifier families already are.
indx-ner-patterns — no extra, both languages, in a default install: money, dates, 郵便番号, invoice and 登録番号, email, phone, plus a deployment’s own gazetteer. indx-ner-vibrato — --extra ja, Japanese, using vibrato for morphological analysis, where 固有名詞 tags person, organization and location directly and Token.start()/.end() are already character offsets. indx-ner-onnx — --extra ner, both languages, a token-classification model on onnxruntime. indx-ner-llm — --extra llm, both languages, reusing the lane the LLM classifier already has.
Where it stands. indx-ner-patterns ships: one extractor, patterns, declared for pages and for chunks, finding email, 登録番号, invoice numbers, dates in either calendar, money, 郵便番号 and phone numbers, plus whatever a deployment lists in INDX_NER_PATTERNS_GAZETTEER. indx-ner-vibrato ships too, behind --extra ja: IPADIC’s 固有名詞 tags become person, organization and location spans, with a surname and a given name that touch merged into one person, and the dictionary fetched once into INDX_NER_VIBRATO_CACHE unless INDX_NER_VIBRATO_DICTIONARY names one. indx-ner-onnx ships behind --extra ner: a token-classification checkpoint on onnxruntime, by default multilingual BERT fine-tuned for person, organization, location and date over ten languages, with Japanese covered by pretraining and not by fine-tuning, so what it finds there is transfer that nothing here has measured. The text is shown in windows of INDX_NER_ONNX_WINDOW_CHARACTERS and every span comes back in the input’s coordinates. indx-ner-llm ships behind --extra llm as the extractor ner-llm (the classifier already holds llm, and the five ports share one namespace), advertised only with INDX_NER_LLM_MODEL, asking a chat model for surfaces under the labels INDX_NER_LLM_LABELS names and locating every occurrence in the text itself, because a span has to index the block’s text exactly and a model’s counting does not. All four lanes are delivered, and the benchmark now measures them against each other: on 30 labelled entities over six scopes, ner-llm reads 80%, patterns 50%, onnx 27% and vibrato 3%, each lane run alone over the same plan.
Three constraints worth stating before any of it is built. IPADIC is the only shipped vibrato dictionary that tags organizations — every UniDic build has 人名 and 地名 and no 組織 at all — so it is effectively forced at 7.7 MB down and 47.8 MB resident. Vibrato’s tokenize() never releases the GIL, so tokenization is serialized across threads: one instance per process, or a full dictionary copy per thread. And there is no linux aarch64 wheel, so ARM Linux falls back to the sdist and needs a Rust toolchain.
The zeroshot ONNX stack does not generalize to this. Its run(premise, hypotheses) is NLI to the bone — entailment/contradiction label keys, pair encoding, only_first truncation, and a logits loop assuming [batch, num_labels] where token classification emits [batch, seq_len, num_labels]. The download, session and feed-filter plumbing transfers; the head is new.
The accuracy ordering is measured, on forms. Schema 1.4 of benchmarks/case.schema.json carries expected_entities, and the report scores each installed lane alone against them. The hosted model reads the most and is the only lane that costs money, and six of its misses are phone and registration numbers its default labels do not ask for; among the free lanes the patterns lane wins on this corpus because a receipt and a filing’s cover page state dates, amounts, phone and registration numbers in fixed shapes; the model lanes win where a person or an organisation is named in prose, and vibrato’s low number is mostly granularity, since IPADIC tags 任天堂 and 株式会社 as two nouns where a person reads one organisation. Thirty entities on six scopes is small on purpose. economics.quality is still a table constant.
An extractor lane per language Planned
A request maps languages to lanes, and the executor runs a lane only on the pages the detector gave that language, the answer it already writes under the block’s languages. Today every lane named runs on every page, and the first to answer a label wins it.
Sampling is the implementation’s, not the contract’s
The DocumentSampler port that stood here is withdrawn. The plan was to make what a classifier is shown a port of its own, with spread, head, random, whole and fixed as distributions of it, paired per classifier by the request. The port split went the other way, and the reason is that the bound was stated in pages, which means nothing to a chunk-targeted implementation, and the joined output it produced cannot carry character spans at all.
So sampling left the contract instead of being promoted out of it. TextSample, classification.sample, the two contract ceilings, the five INDX_CLASSIFICATION_SAMPLE_* variables, the executor’s sample() and --classification-sample are gone. A classifier is handed the full text of its unit — every readable page, whole and in order — and bounds it itself, from its own settings, using excerpt in indx-interfaces: the head of the text cut at a nearby whitespace boundary, which is where a document says what it is.
The cost landed on the implementations, and it is one number where it is a bill. indx-classifier-llm cuts at INDX_CLASSIFIER_LLM_MAX_CHARACTERS (default 6,000) before the call; indx-classifier-zeroshot already read no further than its token window, so the model sees the head of the text as before; and indx-classifier-words scans everything, because a word list costs nothing per character worth capping.
packages/indx-executor/src/indx_executor/classification.py
Classify a corpus on CPU Planned
The classification half is built above and the word-signature classifier is CPU-only by construction; what remains is the corpus half. Standard inputs organized across a corpus, with the CPU-only rate and total cost reported for corpus-scale runs, waits on the corpus run below.
Enrichment: a summary and tags per document, page or chunk
The third return shape beside labels and spans. An Enrichment carries a summary, prose in the text’s own language, and tags, an open vocabulary the enricher coins rather than a taxonomy it picks from, each part optional. DocumentEnricher, PageEnricher and ChunkEnricher are the classifier’s three units over that shape, enabled per unit under enrichment.{document,page,chunk}_ids, refused the classifier’s three ways before the fetch, advertised on snapshot.enrichers outside the hash. Each part is won by the first enabled enricher with an opinion on it, so a request naming ["extractive", "enrich-llm"] takes its summary from the floor and its tags from the model.
{"enrichment": {"document_ids": ["extractive", "enrich-llm"]}}"metadata": {"enrichment": { "summary": "While we prefer electronic payment methods, any checks must be sent to the address below, NOT to our San Francisco office.", "tags": [{"label": "billing", "confidence": 0.9}]}}Two lanes. extractive needs no extra and returns the sentences that carry most of the unit’s own vocabulary, verbatim and in document order, so what the summary says is what the document says; it coins no tags, because a tag is a word the text need not contain and inventing one from term frequency would publish a claim nothing measures. enrich-llm –extra llm asks a chat model for both parts in one call, under its own INDX_ENRICH_LLM_* prefix so a deployment may summarise with a different model than it classifies with, and follows the LLM classifier’s device rule. A page enricher writes to each page block, a chunk enricher to the document block’s chunk_enrichment, and both keys are reserved.
developer/decisions — ADR-0037
packages/indx-enrich-extractive/src/indx_enrich_extractive/enricher.py
Document metadata, derived and caller-supplied
Two halves of one hole, both filled. The document block now says what it was and where it came from, and a caller’s own labels survive the round trip.
The derived half splits by what each dict is for. Provenance gains media_type and the source_uri — or the filename an inline upload named — beside the plan bindings every block already carried, because an exported row has to say where it came from without the plan beside it and a digest is not something anyone searches for. Metadata carries the caller’s labels and the detected languages. That split is the glossary’s own rule: provenance is where a block came from, metadata is what was extracted out of it.
The caller’s half is metadata on EncodeRequest: an owner, a tenant, a sensitivity classification, any JSON object. indx carries it and enforces none of it — who may read a vector afterwards is the index’s question, not the router’s. It is deliberately not on PlanRequest: plan_id is the hash of every field of the plan it is decided into, so a tenant label would give two identical documents two different plans for a value that routes nothing.
sensitivity is a caller label and nothing indx derives, and a document “type” beyond the media type is classification — built since, above, and written under its own reserved classification key rather than here. POLICY_VERSION did not move and the capability snapshot ID is byte-identical before and after: none of this is a routing decision.
{ "id": "document", "kind": "document", "metadata": {"tenant": "acme", "sensitivity": "internal", "languages": [{"language": "ja", "confidence": 0.98}]}, "provenance": {"media_type": "application/pdf", "source_uri": "s3://filings/2025/report.pdf", "plan_id": "sha256:…", "source_digest": "sha256:…"}}packages/indx-executor/src/indx_executor/service.py
packages/indx-executor/tests/test_executor.py
tests/bdd/features/metadata.feature — Scenario: A caller’s own labels travel with the source
Language detection per page and per document –extra lang
indx-language-lingua names the languages each readable page is written in, through lingua. Every page block carries its own answer and the document block carries the average, highest confidence first.
It arrives through a new plugin port rather than as an import, and it is asked with text rather than bytes — which is why it is not an observer. Preflight never decodes content, and a language is a fact about characters. A language_hint sat unassigned on the router’s private preflight context from the second slice to this one for exactly that reason; it is deleted rather than finally filled.
The document average is weighted by how much text each page’s score was computed over. A flat mean lets a title page holding six words outvote a chapter — the same mistake weighting already fixed for OCR’s self-reported confidence.
Two admitted limits. A text under 20 characters gets no answer at all, because a statistical detector scores a handful of characters confidently and wrongly. And INDX_LANGUAGE_MINIMUM_CONFIDENCE (default 0.05) is not decoration: the engine returns a value for every one of the 75 languages it was built with, so a deployment that knows its corpus names it in INDX_LANGUAGE_CANDIDATES and pays for neither the memory nor the extra ways to be wrong.
$ uv run indx encode --metadata '{"tenant":"acme"}' file://$PWD/report.pdf document languages: [{"language": "ja", "confidence": 0.98}] page:1 languages: [{"language": "ja", "confidence": 1.0}]packages/indx-language-lingua/src/indx_language_lingua/detector.py
The language a format already declares Planned
OOXML states dc:language and a PDF catalog can carry /Lang. Where a format says so, that is a better signal than a statistical guess over the characters — and reading it is a second LanguageDetector distribution rather than a change to the port, which is what makes it cheap.
It is the follow-up if the statistical answer proves weak on short documents, and it was never a reason to have skipped that one: ten of the eleven readable formats declare nothing at all, and a text under 20 characters gets no statistical answer by design.
Deferred, as of 2026-09-08, until that measurement exists. The labelled set does not yet score language detection, so nothing says the statistical answer is weak, and the detector would cost an ID, an advertisement and a request field for a signal no measurement has asked for. It leaves Phase 4 of the build order and waits on its trigger with the other unscheduled items.
Defined terms per block Planned
A regulation defines its words where it first uses them (勧告保健所 is the health centre that issued the admission notice), and every later clause depends on that meaning. Nothing reads that today. The target is defined_terms on the block that defines them, each a term and what it means, and marks on the blocks that use a defined or significant term, so an agent answering from a later clause can carry the definition with it.
Cross-references resolved to their target Planned
次表, 法第22条: a clause points at a table, another clause or a law, and today the words are text and nothing more. The target is references on the pointing block, each the text as printed and the target it resolves to, a block ID inside the document or a named external provision, and referenced_by on the block pointed at. It needs heading and table blocks to point at, and span anchoring settled, because a reference is a span.
Image blocks with a caption and what they depict Planned
indx-chunker-pptx cuts a slide’s picture into an image chunk and clip-vit-b32 can embed it, but nothing says what the picture shows. The target is an image block with a caption (the text the document places beside it), a description and a depicts list (真空含浸機, コイル), so the photo is findable by words and an agent can cite it. A description is a model call, so it sits behind an extra and runs only when a request names it, like the enrichers.
Export
Section titled “Export”Agent-readable export Planned
Processed content exported to a customer-controlled index, with the embedding space and query encoder identified so the vectors can be searched later.
Draw.io and Mermaid diagram output .pptx
A structured diagram out, not only text and boxes. The process-chart parser writes the graph – symbols joined by connectors – to the page block’s metadata["process_chart"], and indx_capability_process_chart.drawio.to_drawio() writes that graph as a .drawio file a person opens in diagrams.net and keeps editing: a symbol becomes its diagrams.net preset, a contains edge becomes parentage so dragging a region moves the line it groups, and a connector drawn without an arrowhead keeps its lack of one. The file is a function of the chart alone – no timestamp, no agent string, no compression – so tests/fixtures/office-process-chart.drawio is compared byte for byte and the shape cannot drift. to_mermaid() beside it writes the same graph as Mermaid source, where tri, circle and diam are the triangle, circle and diamond the convention is drawn with. The playground’s Chart tab draws the chart with Mermaid and offers both files, the drawing as SVG and as PNG, through the demo host’s POST /chart/{format}; a deployment of indx serve has no export endpoint and draws nothing, and ADR-0058 and ADR-0059 say why and when that changes. The first of the customer-specific output formats a recipe is meant to teach.
developer/decisions — ADR-0058
Model vendors
Section titled “Model vendors”Bring your own model vendor …
The two model-backed lanes go through LiteLLM. Anthropic, OpenAI, Ollama, and any other LiteLLM-supported vendor work today: INDX_VLM_MODEL picks the vision model, INDX_EMBED_MODEL the hosted embedding model. No indx code changes — a model id and credentials.
INDX_VLM_MODEL=anthropic/claude-sonnet-5 # or openai/gpt-4o, ollama/llava, …INDX_EMBED_MODEL=openai/text-embedding-3-smallINDX_EMBED_DIMENSION=1536packages/indx-capability-generic-vlm/src/indx_capability_generic_vlm/settings.py
packages/indx-capability-generic-vlm/tests/test_provider.py
packages/indx-capability-embedding-hosted/tests/test_hosted.py
One ID per allowed model Planned
Each LLM lane advertises one classifier, extractor or enricher ID per model in a list setting, so classification.document_ids and its siblings pick the model as they pick the lane, and the deployment’s list is the allowlist the blueprint’s Deployment screen shows. Today each lane takes one model from one variable, INDX_CLASSIFIER_LLM_MODEL, INDX_NER_LLM_MODEL or INDX_ENRICH_LLM_MODEL.
Cloud-native AI services per target Planned
The same lanes pointed at the AI service the customer’s cloud already offers: Bedrock on AWS, Azure OpenAI on Azure, Google AI on GCP. LiteLLM speaks to all three, so each is a model id in the variables above rather than code, and a deployment template carries them as its variables. Not every configuration has to be built; each has to be shown to work, which is what makes the foundation cloud-flexible rather than cloud-bound.
Call it
Section titled “Call it”Four operations, three interfaces
The same contract through Python, the CLI, and HTTP. A truncated real encode result:
uv run indx capabilitiesuv run indx plan file:///absolute/path/report.pdfuv run indx encode file:///absolute/path/report.pdfuv run indx embed --space default-text "annual recurring revenue"{ "blocks": [{"id": "document", "kind": "document", "text": "Invoice\nInvoice number MRFL4LVY-0006\n…", "status": "completed"}], "trace": {"events": [{"planned_capability_id": "invoice-parser", "actual_capability_id": "invoice-parser", "device": "cpu", "status": "completed", "fallback_index": 0, "latency_ms": 3}]}, "usage": {"bytes": 68446, "pages": 1, "cost_usd": 0.0, "latency_ms": 8}}packages/indx/tests/test_facade.py
packages/indx-app-cli/tests/test_cli.py
Typed failures
One envelope, distinguishable refusals. A URI scheme no loader resolves is 415 unsupported_source. A media type no capability reads is 415 without that code. A media type no observer can look at is 422. A block granularity nothing installed produces is a 422 unsupported_granularity. Plus 409 plan_conflict, 413 input_too_large, 503 capability_unavailable.
{"error": {"type": "unsupported_media_type", "code": "unsupported_source", "message": "no installed loader resolves ftp:; this installation resolves data, file, http, https, s3", "param": "source.uri.uri"}}packages/indx-interfaces/src/indx_interfaces/errors.py
packages/indx-interfaces/tests/test_contracts.py
Say which component answered, for all of them
A trace does planned-versus-actual for capabilities, and planned_capability_id beside actual_capability_id is how a fallback is visible. A capability is the only component with a planned side to compare against, and it used to be the only one attributed at all. trace.components now names the rest: the loader that fetched the bytes, the sniffer that named the media type, the chunker that cut each page, the detector that answered each page’s languages and the classifier that won each facet — each by the distribution that declared it, which is the unit an operator installs and the only name most of them have.
{"trace": { "events": [{"planned_capability_id": "native-extraction", "actual_capability_id": "generic-ocr", "fallback_index": 1}], "components": [{"role": "loader", "distribution": "indx-loader-file"}, {"role": "sniffer", "distribution": "indx-observer-pdf"}, {"role": "chunker", "distribution": "indx-chunker-pdf", "pages": [1, 2]}, {"role": "chunker", "distribution": "indx-chunker-page", "pages": [3]}, {"role": "classifier", "distribution": "indx-classifier-words", "id": "words", "facets": ["document_type"]}]}}Two chunkers split one document, and the result says so. Every chunk block still reports native-extraction in its provenance — the capability that read the page — and the distribution that decided where the chunk begins is on the trace beside it. An inline source of a declared type names no loader and no sniffer, because nothing fetched or recognized it, and a refused encode names what fetched and recognized the source it could not route.
The observer is named on the plan side, where the observation happens. plan() returns a PlanResult: the hashed RoutePlan under plan, and beside it the loader, sniffer and observer that produced it, in the same components shape. An encode that planned for itself carries the planner’s observer onto its own trace, so a planless encode is attributable end to end; an encode handed a plan names none, because that observation already happened. Attribution comes before selection, because a caller cannot name what the system will not identify, and now both sides are named.
packages/indx-interfaces/src/indx_interfaces/encoding.py
Deadlines, cancellation and correlation
INDX_REQUEST_TIMEOUT_SECONDS bounds a request; unset or 0 means no deadline, and past it a request answers 504 request_timeout. A client that hangs up trips the same mechanism from the other direction — a watcher polls request.is_disconnected() and flips the deadline with code client_disconnected, which only works because facade calls run in a thread pool and leave the event loop free to poll.
The deadline is cooperative, not preemptive: nothing cancels a running call mid-flight. check_deadline() runs from Router.plan once the source has loaded, before each capability attempt in the executor’s ladder, and before each embedder call — never inside a reader, so a capability already running when the ceiling passes finishes rather than being interrupted.
Correlation and request identity are two different things in two different places. X-Request-ID — the caller’s own header, or one generated when absent — is on every response including the 500, and on the one JSON log line each request writes. request_id in the envelope is the caller’s value from the request body, echoed back so a client can match a response to what it sent. Neither substitutes for the other.
packages/indx-interfaces/src/indx_interfaces/context.py
packages/indx-app-server/src/indx_app_server/app.py
Every setting in one file per distribution
Configuration is environment variables, all INDX_*, and every one is a field on an IndxSettings subclass in the owning distribution’s settings.py and nowhere else — so “what can this be configured with” is one file rather than a grep across fifteen packages. os.environ and os.getenv are banned by a lint rule, so a new reader has to start there.
load() rebuilds every message from the field location and the reason alone, in one place, so a credential cannot travel into a 422 a caller reads. It also made the wording one format instead of three: what used to be “must be positive”, “must be at least” and “must be a number” across twelve hand-rolled readers is now one shape. An empty or whitespace-only value means the default everywhere, which was the behaviour in one reader and a parse error in four.
A third-party capability claims INDX_<VENDOR>_*, and an unavailable descriptor is where it names the variable it is missing — the precedent generic-vlm and hosted-text already set.
INDX_OCR_DPI: Input should be greater than 0unavailable_reason: "no vision model configured; set INDX_VLM_MODEL"Runs with no outbound call
A default install reaches nothing. indx-loader-file resolves from disk, both observers open no network, and native extraction, OCR and the hashed space run on local CPU. The lanes that do reach a vendor are the ones you install and name — indx-loader-http by scheme, generic-vlm and hosted-text behind an extra and a model env var — and data_residency refuses capabilities on external devices, so a constrained request cannot escalate onto one by accident.
packages/indx-router/src/indx_router/policy.py
Corpus runs and incremental results Planned
Four single-document operations cannot classify a corpus. A corpus run would report progress, be cancellable, keep one failed source from failing the whole run, and total the CPU-only rate and cost per 1,000 documents the benchmark asks for — with unreadable content named in the manifest rather than missing from it. The same gap at one document’s scale is a long encode that returns nothing until its last page finishes: blocks would arrive as they complete.
Web app
Section titled “Web app”The web app is the product blueprint, served from this site as a mock. One sub-section per screen, and under it one entry per feature the screen offers, stated as what a person can do. A Planned feature has nothing usable behind it; a WIP feature is one the service already answers through the API or the playground while the screen does not exist. None is implemented, because the web app does not exist yet; the roadmap says what comes first.
Overview
Section titled “Overview”The first screen after sign-in: the workspace, what it holds and how it improves. blueprint — Overview · roadmap
Workspace and environment Planned
The tenant’s name, the environment it runs in, and a status pill that says every system is up. /health answers the pill today; the tenant and the sign-in are operational maturity.
Space summary cards WIP
The first three embedding spaces with their record count, dimensions and declared features, each opening its detail. The spaces and their dimensions are on the capability snapshot; the counts need the data store.
Endpoint list with a try-it link WIP
Every endpoint built on the workspace, its path and the space it serves, each with a link into the playground. The four operations exist; user-created endpoints do not.
The improvement cycle Planned
Ingest, add meaning and teach, verify and use, drawn as a timeline that opens the few-shot library. It needs the library.
Embedding spaces
Section titled “Embedding spaces”Layer 01. Models, data, features and versions, managed in one place. blueprint — Embedding spaces · roadmap
Space cards WIP
A card per space: name, version, what it holds, modality, records, dimensions, features, and where it computes (CPU and local, or an approved model). The four spaces indx ships fill the model half; records and versions need the store.
Create a space Planned
Name a space and choose the model it may use from the environment’s allowlist.
Space detail WIP
Metric, model and version policy for one space. The metric and the model are advertised per space today; the policy, switch only after an evaluation, needs versions.
Versions per space Planned
A space version is a stored binding of a model and a feature schema over the records embedded under it, and it switches only after a candidate scores better. Today the only version is the snapshot ID of the install.
Data store
Section titled “Data store”Layer 01. Documents, vectors, properties and relations, traced from the same record. blueprint — Data store · roadmap
Records table Planned
Every ingested document with its type, supplier, modality and an indexed status. Nothing persists today; export and corpus runs are what would fill it.
Filter by name, tag or supplier Planned
One box that narrows the table by file name, tag or supplier value.
Provenance chain WIP
A record traced from its source file through page and chunk to the space version that embedded it. Block IDs, the source digest and the trace’s component names already say this per response; the chain needs a store to point into.
Related records Planned
Records that share a property, the same supplier or the same contract, listed beside the one open.
Ingest data Planned
Add a document to the store from the same screen. Upload exists in the playground; ingesting into a store does not.
Features & tags
Section titled “Features & tags”Layer 01. Business axes declared on a space, used to filter, classify and extract. blueprint — Features & tags · roadmap
Feature schema per space Planned
A table of the space’s features: name, type (category, number, entity, text, vector), values and source. Nothing declares a schema today.
Add a feature Planned
Name a feature, choose its type and describe its values.
Feature sources WIP
Each feature names where it comes from: a person, a classifier, extraction or a vision encoder. All four already answer per request as caller metadata, classification facets, entity spans and signatures; the screen binds them to declared features.
features — classifiers, entity extraction, enrichment, document metadata
Filter by feature value Planned
Narrow records by supplier = Kitagawa or document_type = Invoice, the same axes reused for classification and extraction. Needs the store.
Playground
Section titled “Playground”Layer 02. Try a feature on a document, verify the result, then keep it. blueprint — Playground · roadmap
Feature picker WIP
Semantic search, information extraction, auto tagging, layout detection, few-shot detection, relationships, OCR routing and parser routing, chosen by name. The playground runs most of them request-first, as plan and encode options; relationships and few-shot detection need the store and the library.
Space and version selector WIP
Which space, and which version of it, a run embeds in. The space is embedding_space_ids today; the version needs spaces.
Sample or upload WIP
Run on a committed sample or on a file dropped into the page.
Result with evidence WIP
A ranked hit with its score, an extracted field with the region it came from, a detected box drawn over the page, a routing decision with its reason and fallback, each naming the space and the environment used.
Add as approved example Planned
One click keeps a verified result as an example in the few-shot library.
Make it an endpoint Planned
Publish the run, its feature, space and version, as a named endpoint.
The run on top, the page and its output side by side Planned
The playground puts the run’s form in one column and the result in the other, and the result stacks the page, its chunk list and its route. The sales page shows the layout the playground should have: the inputs across the top, then the page on the left and the structured output on the right, at the same height. The form becomes one row (sample or upload, the lanes, run), and the tabs (pages, embeddings, search, entities, tags, chart) move to the output column.
Hover a block on the page, read its output Planned
The playground draws chunk boxes on the page and lists the chunks below it, and the two do not talk. On the sales page, hovering a block shows the JSON indx hands over for it, and hovering an entry in the output lights its box. The playground does the same over what encode returns today, a line chunk and its provenance, and gains finer targets as the blocks do: a table cell, a diagram’s node or connector.
A route header per page Planned
The route is a line under the page today. The sales page states it above the page, in the order it happened: the page was scanned, what was found (a text layer, no text layer, a process-chart signature), the parser it was read with and the fallbacks kept. Every field is already on the plan and the page block’s provenance.
Few-shot library
Section titled “Few-shot library”Layer 02. Approved examples, evaluated before they are promoted, shared across OCR, classification and parsers. blueprint — Few-shot library · roadmap
Approved examples, evaluation set, active version Planned
Three tiles: how many examples are approved, how large the held-out evaluation set is, and which version is the reference. The evaluation set exists as the benchmark’s labelled cases, in the repository rather than the product.
Example library by document type Planned
Examples grouped by the case they teach, a standard invoice, a scanned one, a handwritten slip, a multi-table one, each reviewed by a person. Recipes, examples plus a schema per recognised format, are what a group is.
Add an example Planned
Pick a document and register the expected value or judgment.
Evaluate a candidate Planned
Score the current version and a candidate over the evaluation set, side by side, with regressions counted.
Approve and promote Planned
Make the winning candidate the space’s reference version.
Endpoints
Section titled “Endpoints”Layer 02. The foundation and each application served as its own API. blueprint — Endpoints · roadmap
Embedding endpoint WIP
/v1/embed, bound to a space version. The endpoint exists; the version pin needs spaces.
Search endpoint Planned
Search over the store with a feature filter. The playground searches within one encoded document today; search across a store needs the store.
Extraction and detection endpoints WIP
Named endpoints for information extraction and layout detection. Both run today as options on encode.
Create an endpoint Planned
Name an endpoint and bind it to a space version, a feature and an execution environment.
SDK and HTTP samples WIP
A code panel for each endpoint, Python and HTTP, with copy. The HTTP contract and a generated TypeScript client exist; the Python client is a proposal.
Deployment
Section titled “Deployment”Workspace. The same foundation on a managed cloud, in your cloud, or on your own machines. blueprint — Deployment · roadmap
Choose a target WIP
INDX Cloud, a private cloud or self-hosted. The image and the private-cloud target exist; the managed cloud needs tenants.
Generic Terraform templates per cloud Planned
Beside infra/aws, an infra/azure and an infra/gcp with the same shape: a registry, the one image, one container on one port, a perimeter, and the model lanes as variables. Generic by design: a customer’s networking, security and AI-service setup is paid implementation work and is not in the template. Validated in CI for the three, and one of them deployed by hand for the Oct 1 checkpoint.
Execution policy WIP
Per target: the data boundary, CPU by default, external models on an allowlist only, and an unapproved fallback refused into manual review. data_residency and device are honoured per request and the ladder ends at a person today; the screen reads it from /v1/capabilities.
Model allowlist WIP
Embeddings, OCR and LLMs allowed separately, each with its status. Which extras are installed decides it per install today; the blueprint decides it per space.
Re-embed before a model switch Planned
A model update creates a new space version, re-embeds and evaluates it, and only then switches. Needs versions.
Planned
Section titled “Planned”Cross-cutting work, acknowledged and unscheduled. The outcome framing is on goals and non-goals.
Operational maturity Planned
Authentication, rate limiting, metrics beyond the per-request log line, packaged distribution (PyPI, a container image, a chart), and the client-side manners a remote API owes: retries and an idempotency key. Acknowledged and unscheduled; the HTTP adapter stays a thin transport until the backbone’s baselines are measured.
Purpose-specific benchmarks and a comparison table Planned
The technical advantage expressed in numbers, on seven axes rather than one score: parsing accuracy, routing quality, speed, resource requirements, cost, few-shot performance, and complex documents and diagrams. The benchmark already scores every reader and extractor on the labelled cases and reports latency and cost from usage; the table maps each axis to what runs today, numbers the ones it can against the nearest alternative, and names the labelled data the others need.
A disk cache for chat-model calls Planned
Every lane that speaks to a chat model reaches it through indx-llm, so one cache there serves the LLM classifier, the LLM extractor, the LLM enricher and the vision reader at once. It is for development: the same page asked of the same model with the same prompt answers from disk the second time, so a test suite or a benchmark rerun neither waits on a vendor nor pays it twice. Off by default, because a cache hides the thing a production deployment most needs to notice, a model whose answers changed.
One SQLite file, standard library only, which is what keeps indx-llm dependency-free (ADR-0038). The key is the content digest of the model id, the messages and every argument that reaches the call, so a different temperature or a different image is a different entry; the value is the reply as LiteLLM returned it. Least recently used, bounded by INDX_LLM_CACHE_MAX_ENTRIES (default 10000) on a settings model, with the file named by INDX_LLM_CACHE and no file meaning no cache. A hit reports what the call originally cost beside a cached flag, so a rerun’s actual_cost_usd still says what the vendor would have billed and a reader can tell a free answer from a cheap one.
Deferred architecture Planned
Region optimization, broad modality coverage, RAG export, persistence, and separate router/executor deployments. These follow only after the first slice has measured baselines — and the persistence decision now has a named trigger: a recipe store and an export target are the first two things that want state. Hook frameworks are deferred too.