> ## 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.

# Deep & fact search

> Search over a workspace with cited evidence — 2 endpoints

One search concept, two implementations, two endpoints — the endpoint picks the
implementation, so neither request body carries an implementation selector.
Deep search separately accepts a quality/cost tier for its main agent:

* **Deep search** (`deep-search`) runs an **agent loop** — the agent searches
  the index, reads and queries files, lists folders, and runs read-only
  commands, deciding what to retrieve next. It can search for minutes and can
  be continued as a conversation.
* **Fact search** (`fact-search`) runs a **fixed pipeline** instead: one
  retrieval pass (hybrid search, plus the knowledge graph when the workspace has
  one), with reciprocal-rank fusion when both legs return candidates. A
  workspace without KG candidates keeps the hybrid ranking unchanged. The
  pipeline then directly reads matched pages or reuses retrieved passages for
  prose (specialized engines remain for tables and images), followed by one
  answering pass. It returns in seconds and is single-shot — there is no
  conversation to continue.

Both return the identical result shape (an `IntelligentSearchResult` on a job
with `kind: "intelligent_search"`), so nothing but latency and thoroughness
changes for a caller. Use fact search for factual lookups the documents answer
directly; use deep search for multi-step questions that need an agent to chain
several retrievals.

<Note>
  The former combined endpoint `POST /v1/workspaces/{id}/intelligent-search`
  (implementation selected by an `effort` field) has been removed. Calls to it
  return `404`; select the implementation by calling one of the two endpoints
  below instead.
</Note>

***

## Deep search

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

**Query parameters:**

* **`wait_seconds`** (optional, default 0, max 300) — Most runs take longer than any sync window; callers normally poll the returned job.

**Headers:**

* **`Idempotency-Key`** (optional)

**Request body:**

```json theme={"dark"}
{
  "query": "What is the company's revenue by geographic segment for FY2025 versus FY2024?",
  "context": "Focus on the audited financial statements",
  "tier": "low",
  "allow_clarification": false,
  "path_prefix": null,
  "session_id": null
}
```

**Body fields:**

* **`query`** (required, max 4096 chars) — The question to answer.
* **`context`** (optional, max 20000 chars) — Free-form background for the agent. Rides the LLM prompt only; does not change the access filter or which files are retrieved.
* **`tier`** (optional, default `low`) — Quality/cost tier for the main agent: `low` uses `navi-dev`, `medium` uses `qwen3p8-27b`, and `high` uses `kimi-k3`. The tabular QA engine behind `query_tables` answers on the same model, so a question over spreadsheets is reasoned about and written into SQL by one engine. Other supporting model calls inside retrieval tools retain their own defaults. A tier whose model is unavailable in the deployment returns `422`; it never silently falls back.
* **`allow_clarification`** (optional, default `false`) — Let the agent finish the run with **one clarifying question** instead of an answer, when the query is genuinely ambiguous and the ambiguity materially changes the answer. The rejected result carries the question in `clarification` (with `answer`, `interpretation` and `reasoning` null and `evidences` empty); answer it by re-asking in the same thread via the result's `session_id`. Off — the default — the agent always answers with its best reading of the query, stated in the result's `interpretation`.
* **`path_prefix`** (optional) — Restrict all tool calls to files under this prefix. Composes with the access filter (intersection). Omit for the whole workspace.
* **`session_id`** (optional) — Continue a prior conversation. Pass the `session_id` returned by an earlier result to add a follow-up in the same thread. Omit to start a new thread. Only the API key that started the thread may continue it.

**Response (202 Accepted):** A `Job` with `kind: "intelligent_search"`.

```bash theme={"dark"}
curl -X POST "$NDI_BASE_URL/v1/workspaces/$WS/deep-search" \
  -H "X-API-Key: $NDI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "What are the main revenue drivers and year-over-year trends?",
    "tier": "high"
  }'
```

***

## Fact search

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

**Query parameters:**

* **`wait_seconds`** (optional, default 0, max 300) — Fact search typically finishes in seconds, so a `wait_seconds` of 60 usually returns the terminal job inline.

**Headers:**

* **`Idempotency-Key`** (optional)

**Request body:**

```json theme={"dark"}
{
  "query": "What payment terms does the Acme master services agreement specify?",
  "path_prefix": null,
  "top_k": 6
}
```

**Body fields:**

* **`query`** (required, max 4096 chars) — The question to answer.
* **`context`** (optional, max 20000 chars) — Free-form background for the answering pass. Rides the LLM prompt only; does not change the access filter or which files are retrieved.
* **`path_prefix`** (optional) — Restrict retrieval to files under this prefix. Composes with the access filter (intersection). Omit for the whole workspace.
* **`top_k`** (optional, 1–50, default 6) — How many evidences to return, best first. A cap on the answer's citation list, not on how much the pipeline reads: retrieval width is fixed, so a larger `top_k` returns more of what was already found rather than searching further. Values far above the default trade citation count for reliability — the whole list is composed in one pass.

There is no `session_id` field — fact search keeps no conversation, its result
always carries `session_id: null`, and a body naming one is refused with `422`.
There is no `allow_clarification` either: the fixed pipeline has no agent who
could ask back, so a fact-search run always answers and its result always
carries `clarification: null`.

`top_k` is fact-search only: the agent loop authors its own evidence list, so
deep search refuses the field rather than accepting and ignoring it.

**Response (202 Accepted):** A `Job` with `kind: "intelligent_search"` — the
same job and result contract as deep search.

```bash theme={"dark"}
curl -X POST "$NDI_BASE_URL/v1/workspaces/$WS/fact-search?wait_seconds=60" \
  -H "X-API-Key: $NDI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "What payment terms does the Acme master services agreement specify?"
  }'
```

***

## Result

Poll `GET /v1/jobs/{job_id}` until `status` is `"succeeded"` or `"failed"`. The
result shape is identical for both endpoints.

On success, `result` is an `IntelligentSearchResult`:

```json theme={"dark"}
{
  "result_type": "intelligent_search",
  "evidences": [
    {
      "source_path": "financials/annual-report-2025.pdf",
      "source_file_id": "550e8400-e29b-41d4-a716-446655440004",
      "page": 42,
      "quote": "Revenue by segment: North America $2.1B (+8% YoY), Europe $1.4B (-2% YoY), Asia-Pacific $900M (+15% YoY).",
      "relevance": "Directly provides the geographic revenue breakdown for FY2025 with FY2024 comparison."
    }
  ],
  "reasoning": "Found segment revenue in the MD&A and confirmed it in the notes. Both sources agree on the North America growth; the Europe decline is attributed to FX.",
  "answer": "FY2025 total revenue grew 7% to $4.4B. North America: $2.1B (+8%), Europe: $1.4B (-2%), Asia-Pacific: $900M (+15%).",
  "interpretation": "Read 'revenue breakdown' as the geographic segment split — the only breakdown the filings carry — for FY2025 with FY2024 comparatives.",
  "clarification": null,
  "coverage": {"restricted_candidates": 0, "labels_required": []},
  "candidates_seen": 120,
  "steps_used": 4,
  "exhausted": false,
  "tool_calls": [
    {"tool": "hybrid_search", "arguments_digest": "a3f2b1c0d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1", "hits": 12, "duration_ms": 210},
    {"tool": "read_file", "arguments_digest": "b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5", "hits": 0, "duration_ms": 85}
  ],
  "session_id": "550e8400-e29b-41d4-a716-446655440012"
}
```

**Result fields:**

* **`evidences`** — Ordered by importance. Each `SearchEvidence` item:
  * **`source_path`** — The uploaded file the quote belongs to (e.g. `reports/policy.pdf`). This is the file to ground the quote against. Retrieval happens over workspace-internal representations of your files, but those are an implementation detail — every citation is resolved server-side to the uploaded file it belongs to, and a citation that cannot be resolved to a file visible to you (deleted mid-run, access-refused, hallucinated) is **dropped** rather than published under a path you cannot use.
  * **`source_file_id`** — The id of the file at `source_path`, resolved server-side. Always set on newly finalized results; `null` only on results stored before unresolvable citations were dropped.
  * **`page`** (optional) — 1-based page number, when one was seen.
  * **`quote`** — The key passage, as retrieved from the file.
  * **`relevance`** — A one-sentence account of how this supports the query.
* **`reasoning`** — The run's account of how the pieces of evidence chain together.
* **`answer`** — A direct answer to the query, grounded in the evidences. Every run answers: the field is always set on a newly finalized run, `null` only when the run rejected the query with a `clarification` (and on results stored before answers became unconditional).
* **`interpretation`** — The run's reading of your query: its assumptions, how it resolved ambiguous terms, the period or scope it took as implied. Stated on every answered run, even one that took the query at face value — no interpretation is silent, so a misreading is visible and you can re-query with the ambiguity resolved. `null` on runs rejected with a `clarification`.
* **`clarification`** — Non-null exactly when the run rejected the query as genuinely ambiguous instead of answering — only possible when the request set `allow_clarification` (deep search only): one direct question back, with `answer`, `interpretation` and `reasoning` null and `evidences` empty. Answer it by re-asking in the same thread via the result's `session_id`. Fact search never clarifies — always `null` there.
* **`coverage`** — Access-label reporting: how many candidates were withheld and which labels would unlock them.
* **`steps_used`** — Number of retrieval steps: agent tool calls for deep search, logical pipeline steps for fact search.
* **`exhausted`** — `true` when a deep-search step budget ran out before the agent finished — still a successful response, not a failure. Always `false` for fact search, whose pipeline has no step budget.
* **`tool_calls`** — One entry per retrieval step. Each `ToolCallTrace`: `tool` (tool name), `arguments_digest` (hex digest of the arguments — not raw arguments, to avoid a side channel around `coverage`), `hits` (result count), `duration_ms` (wall time).
* **`session_id`** — The conversation ID for a deep-search run; pass it as `session_id` on the next deep-search request to continue the thread. Always `null` for fact search.

***

## Locating a quote in the original document

Both searches are retrieval only — they never ground. The evidence tells you
which uploaded file a quote belongs to (`source_path`) and, when seen, its
page — but not where on the page it sits.

When you need a region (page + bounding box for visual formats, sheet/range for
spreadsheets), ground the citations you care about yourself with
document-operations `ground`. The evidence carries both halves of that call: the
file to read (`source_file_id`, the id of the file at `source_path`) and the text
to locate (`quote`).

```json theme={"dark"}
POST /v1/ground
{
  "source": {
    "type": "workspace_file",
    "workspace_id": "550e8400-e29b-41d4-a716-446655440000",
    "file_id": "<evidence.source_file_id>"
  },
  "targets": [{ "id": "1", "text": "<evidence.quote>", "hint": "page 4" }],
  "options": { "max_matches": 1 }
}
```

One call covers up to 30 quotes you took from the same file — `targets` batches
under a single `source`, capped at 30, so a file you cited more often than that
takes a second call for the remainder — and the cited `page` is a useful
`hint`. A quote is retrieved from a parsed rendering of the file, so it may
carry markdown the uploaded document does not contain (table tags, escaped
entities, `--- Page N ---` markers); strip that before sending it as target
text.

Doing this outside the search job means you pay for locators only on the evidence
you actually display, and the search job finishes as soon as the run does.

***

## Multi-turn sessions (deep search only)

A session is a persistent conversation thread. The `session_id` from any
deep-search result is its thread ID. To ask a follow-up:

```bash theme={"dark"}
# First turn
RESULT=$(curl -s -X POST "$NDI_BASE_URL/v1/workspaces/$WS/deep-search" \
  -H "X-API-Key: $NDI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": "What are the main revenue drivers?", "allow_clarification": true}')

JOB=$(echo "$RESULT" | jq -r '.job_id')
# ... poll until succeeded ...
SESSION=$(curl -s "$NDI_BASE_URL/v1/jobs/$JOB" -H "X-API-Key: $NDI_API_KEY" | jq -r '.result.session_id')

# Second turn — continue the same thread
curl -X POST "$NDI_BASE_URL/v1/workspaces/$WS/deep-search" \
  -H "X-API-Key: $NDI_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"query\": \"Which segment has the highest margin?\", \"session_id\": \"$SESSION\"}"
```

The same loop answers a clarification: when a result carries a non-null
`clarification` instead of an answer, send the missing detail as the next
query on the same `session_id` and the agent resumes with the thread's full
context.

* A thread is only accessible by the API key that started it.
* Sending a follow-up while the previous turn is still running returns `409 session_busy`.
* A fact-search job's id anchors no thread: passing one as a deep-search
  `session_id` returns `404 session_not_found`.

***

## Errors

* **`invalid_request`** (422) — An unknown tier, a tier whose mapped model is unavailable in this deployment, or a fact-search body with a `session_id` (fact search keeps no conversation).
* **`session_not_found`** (404) — No deep-search session with that ID in this workspace, or the ID belongs to a follow-up job (not the thread root) or to a fact-search job.
* **`session_busy`** (409) — The session is still answering a previous turn. Wait for that job to finish.
* **`access_denied`** (403) — Session belongs to a different API key.
