> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ndi.nace.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Filtered search

> Corpus-wide filter, rank, and count questions answered from the metadata catalog — 1 endpoint

Fact search finds localized evidence; deep search explores files agentically.
**Filtered search** answers the third kind of question — corpus-wide
filter/rank/count requests such as *"all invoices over \$500"*, *"top five
invoices by spend"*, or *"how many unpaid invoices reference a PO"* — from a
**structured metadata catalog** built at ingestion: typed, evidence-backed
fields per logical component (a document section, a spreadsheet tab, a
recording) of every ingested file. File content is read only for residual
semantic conditions (*"…that mention a copper purchase"*), and only over
catalog-selected candidates.

The result is a **table**: typed columns, matching components as rows (each
named by its uploaded file), an exact `total_matches`, and a citation per
extracted value.

```
POST /v1/workspaces/{workspace_id}/filtered-search
```

One endpoint, a discriminated body. `{"type": "query", ...}` starts a search;
`{"type": "page", ...}` fetches the next page of an earlier one.

**Query parameters:**

* **`wait_seconds`** (optional, default 0, max 300) — initial queries run an agent and usually outlast a sync window; page requests typically finish inside it.

**Headers:**

* **`Idempotency-Key`** (optional) — replays match on the full canonical body **and** the calling API key.

## Start a search

**Request body:**

```json theme={"dark"}
{
  "type": "query",
  "query": "all invoices over $500",
  "context": "Amounts are stated in USD on these invoices.",
  "path_prefix": "ap_url_paid/",
  "allow_clarification": false
}
```

* **`query`** — the filter/rank/count request, in plain words.
* **`context`** (optional) — free-form background for the interpretation; it does not alter the access filter.
* **`path_prefix`** (optional) — narrow to a folder subtree. Intersects with the access filter, never overrides it.
* **`allow_clarification`** (optional, default `false`) — let the run finish with **one clarifying question** instead of a result, when the request is genuinely ambiguous and the ambiguity materially changes the result. The rejected result carries the question in `clarification` — no rows, no `applied_query`, `exhaustive` false. Filtered search keeps no session, so answer it by sending a new initial query with the ambiguity resolved. Off — the default — the run always executes, stating its reading in `interpretation`.

Returns `202` with a job of `kind: "filtered_search"`; poll `GET /v1/jobs/{id}`
for the `FilteredSearchResult`.

**Result** (abridged):

```json theme={"dark"}
{
  "result_type": "filtered_search",
  "columns": [{ "field": "total_amount", "kind": "money", "description": "The invoice grand total including tax…", "repeated": false }],
  "rows": [
    {
      "source_path": "ap_url_paid/AP-PAID-INV-002.pdf",
      "source_file_id": "8b1c…",
      "component": "invoice",
      "component_kind": "document",
      "document_type": "Invoice",
      "page_start": 1,
      "page_end": 2,
      "cells": [
        {
          "field": "total_amount",
          "status": "found",
          "value": { "kind": "money", "amount": "750.25", "currency": "USD" },
          "citation": { "location": { "kind": "page_region", "page": 1 }, "source_text": "Total due: USD 750.25" }
        }
      ]
    }
  ],
  "total_matches": 28,
  "next_cursor": "eyJqIjoi…",
  "exhaustive": true,
  "catalog_drifted": false,
  "executed_at": "2026-08-18T12:00:00Z",
  "applied_query": "SELECT record_id FROM invoice WHERE (total_amount > 500) ORDER BY total_amount DESC, record_id",
  "interpretation": "Read 'over $500' as total_amount > 500; all compared amounts are USD.",
  "coverage": { "restricted_candidates": 0, "labels_required": [] }
}
```

Result shapes follow the question: a **count** question returns the exact
`total_matches` with no rows; a **top-N** question returns exactly N rows with
the pre-cutoff total; a **list** returns a bounded first page (≤ 200 rows) plus
`next_cursor` — matching more than one page never requires narrowing the query.

## Page a result

**Request body:**

```json theme={"dark"}
{ "type": "page", "cursor": "<the next_cursor of an earlier result>" }
```

Pages are **live**: each page re-executes the original query against the
*current* catalog under your *current* access — no agent runs, so pages are
fast. Concurrent ingestion, deletion, or relabeling may shift later pages (rows
can repeat or be skipped across page boundaries); every result carries its own
`executed_at`, and `catalog_drifted: true` says the catalog changed since the
query was validated. Only the API key that ran the original query may page it;
an expired or purged parent job returns `invalid_request` — re-run the query.

## Reading the result honestly

* **`exhaustive`** is the one completeness signal. `true` means every eligible
  component in the tables the query touched was completely cataloged and the
  whole qualifying set was evaluated — totals are a census and a zero-match
  result proves absence. When `false`, the matches shown are valid, but
  "all"-style claims, `total_matches` as a census, and zero-match conclusions
  are not guaranteed (uncataloged files in scope, degraded extractions, or a
  residual content condition — which can prove presence, never absence).
* **`interpretation`** is the agent's reading of your request — thresholds,
  field choices, assumptions. A misreading is visible there; re-ask with
  tighter wording.
* **`clarification`** is non-null exactly when the run asked a clarifying
  question instead of executing — only possible when the initial query set
  `allow_clarification`. A rejected result carries no rows, no `applied_query`,
  no `interpretation`, and `exhaustive: false`. There is no session to continue:
  send a **new** initial query that answers the question.
* **Money is never converted.** Every amount carries the ISO currency the
  source stated, and cross-currency comparison or aggregation is unsupported
  in this version: when amounts are compared or ranked, the interpretation
  states the common-currency assumption.
* **Value statuses are data honesty**: a cell's `status` of `not_found` means
  the component was examined and the field is absent (usable as a filter —
  "invoices with no PO"); `ambiguous` means present but not normalizable
  (e.g. money without a currency); `unexamined` means the extraction could
  not say.
* **`coverage`** is the standard access-withholding block: results are
  label-filtered first, and the narrowing is reported, never silent.
* **`component` is actionable.** A row's `component` is the same name the file
  tools take: pass it as `component=` to `qa-file` / `read-file` (or in
  `query-tables`' `components` for a sheet row) to work on exactly that part of
  the file. Several document rows can share one section name — `page_start` /
  `page_end` tell them apart. The one exception is a `media` row: recordings
  are unnamed in the file tools, so its `component` (the file stem) is
  display-only and QA takes the whole file.

## Coverage and re-ingestion

The catalog is built at ingestion. Files ingested before filtered search
existed remain valid but **uncovered** — they block `exhaustive` for every
query over their scope until they are re-ingested (`POST
/v1/workspaces/{id}/ingestions` over the same selection re-ingests in place).
