# Knowledge base

An agent with no knowledge base answers from what the model happens to remember about your business, which is nothing. Attach one and it answers from your documents instead — searched on every turn, inside the same turn, without the caller waiting for it.

## When you need one

The symptom is specific: the agent is **confidently wrong** about your prices, your policies, your opening hours or your product range. No amount of prompt-writing fixes that, because you are asking a model to recall something it never knew. If instead the agent is answering in the wrong *order*, that is a [flow](https://voice.sphoro.com/docs/flows) problem, and if it needs a fact only your database has — this caller's order status — that is a [function](https://voice.sphoro.com/docs/functions).

## Step 1 · Create a base

`POST /v1/knowledge_bases`

**Shell**

```bash
curl -s -X POST https://voice.sphoro.com/v1/knowledge_bases \
  -H "Authorization: Bearer $SPHORO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Acme Clinic policies", "language": "en"}'
```

**Response201 Created**

```json
{
  "id": "kb_1e5ba0d984f7665b",
  "name": "Acme Clinic policies",
  "language": "en",
  "document_count": 0,
  "created_at": "2026-09-08T09:20:11Z"
}
```

| Field | Type | Description |
| --- | --- | --- |
| `name` required | string | What you call it. |
| `description` optional | string | Yours. |
| `language` optional | string | The language the documents are written in. Worth setting: it is used when the documents and the caller are not in the same language. |
| `project_id` optional | string | The cost centre ingestion is attributed to. See [projects](https://voice.sphoro.com/docs/projects). |
| `metadata` optional | object | String keys to string values. Yours. |

## Step 2 · Put documents in it

`POST /v1/knowledge_bases/{id}/documents`

**Text**

```bash
curl -s -X POST https://voice.sphoro.com/v1/knowledge_bases/$KB_ID/documents \
  -H "Authorization: Bearer $SPHORO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Cancellation policy",
    "text": "Appointments cancelled less than 24 hours before the slot are charged a late cancellation fee of 500 rupees. Cancellations more than 24 hours ahead are free."
  }'
```

**A page**

```bash
curl -s -X POST https://voice.sphoro.com/v1/knowledge_bases/$KB_ID/documents \
  -H "Authorization: Bearer $SPHORO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://acme.example/policies/cancellations"}'
```

**A website**

```bash
curl -s -X POST https://voice.sphoro.com/v1/knowledge_bases/$KB_ID/documents \
  -H "Authorization: Bearer $SPHORO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://acme.example/help", "max_pages": 200}'
```

**A file**

```bash
# A PDF or DOCX, base64 in "content", with the real filename.
curl -s -X POST https://voice.sphoro.com/v1/knowledge_bases/$KB_ID/documents \
  -H "Authorization: Bearer $SPHORO_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"filename\": \"handbook.pdf\",
    \"content\": \"$(base64 < handbook.pdf | tr -d '\n')\"
  }"
```

| Field | Type | Description |
| --- | --- | --- |
| `text` optional | string | The document, inline. One of `text`, `content` or `url` is required. |
| `content` optional | string, base64 | A file's bytes. Send `filename` with it — the extension is how the right extractor is chosen. |
| `filename` optional | string | The file's real name, including its extension. |
| `url` optional | string | A page to fetch, or the entry point for a crawl when `max_pages` is set. |
| `max_pages` optional | integer | Turns a single fetch into a crawl. Defaults to 200 pages and a depth of 3. `robots.txt` is honoured and each host is rate-limited. |
| `title` optional | string | Shown in retrieval results and prefixed onto every chunk. Supply one for inline text — it is a large part of what makes a chunk findable. |
| `language` optional | string | Overrides the base's language for this document. |
| `metadata` optional | object | String keys to string values, returned with every chunk retrieved from this document and filterable at search time. |

### Ingestion is a queued job

The request answers `202 Accepted` and the work happens on a worker — a two-hundred-page crawl is not something to hold an HTTP connection open for. Poll the document list until the documents appear, or subscribe to `knowledge_base.document.ingested`.

`GET /v1/knowledge_bases/{id}/documents`

**Shell**

```bash
curl -s https://voice.sphoro.com/v1/knowledge_bases/$KB_ID/documents \
  -H "Authorization: Bearer $SPHORO_API_KEY"
```

> **Do not attach a base to a live agent and assume it is ready.** Between the `202` and the last document landing, the agent is grounded against a partial corpus and will answer as though the missing pages do not exist — which reads as the agent being wrong rather than the ingest being unfinished.

### What the extractors will and will not do

Each of these will otherwise surprise you at the worst possible moment.

| Source | What you get |
| --- | --- |
| A **scanned** PDF | **Nothing.** It is a picture of a document. Ingestion succeeds with zero chunks, which is accurate rather than a failure — check the document count. |
| A multi-column PDF | Text in reading order, which is not always visual order. Academic papers and complex tables come out worst. |
| A DOCX with tracked changes | The accepted text. Comments and rejected revisions are ignored. |
| A JavaScript-rendered page | Whatever is in the served HTML. There is no headless browser, so a single-page app usually yields its loading state. |
| A crawl that hits its budget | It stops at `max_pages` and tells you how many it saw. It will not exceed the budget to finish a site. |

## Step 3 · Attach it to the agent

`PATCH /v1/agents/{id}`

**Shell**

```bash
curl -s -X PATCH https://voice.sphoro.com/v1/agents/$AGENT_ID \
  -H "Authorization: Bearer $SPHORO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"knowledge_base_ids": ["'"$KB_ID"'"]}'
```

> **Attaching a base is the switch.** There is no separate "enable retrieval" flag, and naming `search_knowledge_base` in `tools` does nothing on its own — the tool exists because a base is attached. Send `[]` to detach every base. Several bases attached at once are searched together on each turn.

## Step 4 · Check what it retrieves, before a caller does

Search the base directly with the question you are worried about. This is the single most useful thing on this page: it turns "the agent gave a wrong answer" into "the right chunk is not being retrieved" or "the right chunk is retrieved and the prompt ignored it", which are different problems with different fixes.

`POST /v1/knowledge_bases/{id}/search`

**Shell**

```bash
curl -s -X POST https://voice.sphoro.com/v1/knowledge_bases/$KB_ID/search \
  -H "Authorization: Bearer $SPHORO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": "what happens if I cancel the day before", "top_k": 5}'
```

**Response200 OK**

```json
{
  "results": [
    {
      "text": "Cancellation policy › Late cancellations — Appointments cancelled less than 24 hours before the slot are charged a late cancellation fee of 500 rupees.",
      "title": "Cancellation policy",
      "section": "Late cancellations",
      "document_id": "doc_7c31f0a2",
      "score": 0.81,
      "vector_score": 0.78,
      "lexical_score": 0.86
    }
  ]
}
```

| Field | Type | Description |
| --- | --- | --- |
| `query` required | string | Ask it the way a caller would, not the way your documents are written. That gap is what you are testing for. |
| `top_k` optional | integer | How many chunks to return. |
| `filter` optional | object | Restrict to documents whose `metadata` matches — the way to search one product line inside a shared base. |

Both scores are returned because they fail differently. A high `lexical_score` with a low `vector_score` means the caller happened to use your document's words; the reverse means the meaning matched but the wording did not. A question that scores low on both is one your corpus does not answer, however confidently the agent replies.

## How a document becomes an answer

```
document ──▶ extract ──▶ chunk ──▶ embed ──▶ index
                                                   │
caller's question ──▶ embed ──▶ search ──▶ rank ──▶ floor ──▶ token budget
                                                   │
                                                   ▼
                                     grounding block in the prompt
```

Chunking is structure-aware rather than a fixed character window: whole paragraphs, lists and tables are packed together up to a budget, sentences are never split, and a chunk resets at a heading so it cannot straddle two topics. Each chunk is then prefixed with its document title and section path — which is the step that matters most and is the easiest to underestimate. A chunk reading `"₹500"` is unmatchable by anybody; the same chunk stored as `"Cancellation policy › Late cancellations — a late cancellation is charged ₹500"` is found by somebody asking about cancellation fees.

## Retrieval inside a live call

Grounding happens *inside* the turn, which puts it squarely inside the latency budget. Two consequences worth designing around:

- **It is bounded.** `retrieval_timeout_ms` caps it — 50 to 2000ms, under `autonomy: "custom"`. See [agent reference](https://voice.sphoro.com/docs/agents).
- **It fails open.** A knowledge base that is slow or unreachable costs the caller a less-grounded reply, never silence on a live call. This is deliberate: grounding improves an answer, it is not a precondition for one.

## Writing documents that retrieve well

| Do | Because |
| --- | --- |
| Give every document a real **title**, and use headings. | Both are prefixed onto chunks, and they are most of what makes a chunk findable. |
| Write the question your caller asks, not only the answer. | "What happens if I cancel late?" in the document matches "what if I cancel late" from a caller. A heading reading "Clause 4.2" matches nothing. |
| Use your callers' vocabulary alongside your own. | Somebody says "doctor", your handbook says "physician". Put both in. |
| Keep one topic per section. | Chunks reset at headings, so a section covering three things produces chunks that answer none of them well. |
| Split a 200-page handbook into documents by subject. | Filtering and deleting are both per-document, and a corpus you cannot prune is one you stop trusting. |

## Keeping it current

`DELETE /v1/knowledge_bases/{id}/documents/{doc}`

`DELETE /v1/knowledge_bases/{id}`

**Shell**

```bash
curl -s -X DELETE https://voice.sphoro.com/v1/knowledge_bases/$KB_ID/documents/$DOC_ID \
  -H "Authorization: Bearer $SPHORO_API_KEY"
```

There is no in-place edit of a document: replace it by deleting and re-ingesting. Re-crawling a site adds documents rather than reconciling them, so a base fed by a nightly crawl wants the old documents removed first — otherwise last month's prices are still in there, still retrievable, and still perfectly confident.

> **Prune before you tune.** Almost every "retrieval got worse" report turns out to be a corpus that has grown a second, older copy of the same page. Near-identical documents compete with each other, and the one that wins is not reliably the current one. Sphoro Voice scores chunks, not recency.
