# 中文（简体）: 让长文档的向量搜索更出色：Manticore Search 内置分块

嵌入模型只会读取文档开头的几百个 token，然后悄悄丢掉其余部分。现在 Manticore Search 会在 INSERT 时自动为你拆分长文档：只需在向量列上添加 chunk_strategy，并从五种策略中选择一种即可。不需要摄取流水线，也不需要分块库。在我们自己的手册上，深层内容的 recall@5 从 55% 提升到了 83%。

假设你正在为团队内部文档构建搜索：指南、运维手册、事故复盘。你有一张带有[自动嵌入](/blog/auto-embeddings/)的表：插入文本后，Manticore 会运行模型并为你填充向量列。（如果你还不了解这一点，可以先看 [Manticore 中的向量搜索](/blog/vector-search/)。）你加载了一篇 4,000 词的文档。插入成功。搜索可用。一切看起来都没问题。

但你选择的模型只有 512-token 输入窗口，而那篇文档大约有 5,000 个 token。模型只读了前 380 个词，就把另外 3,600 个词丢掉了。文档中此后的任何内容都永远无法被检索到，而且没有任何地方提醒你。这个嵌入也未必能代表整篇文档。

在此之前，你通常需要自己把文档拆成多个片段，为每个片段创建嵌入，然后如果想做文档搜索而不是分块搜索，还要想办法合并结果。现在 Manticore 可以在表定义中处理这件事：在 `CREATE TABLE` 的向量列上添加 `chunk_strategy`，Manticore 就会把每篇文档拆成块，为每个块生成嵌入，并搜索所有块：

```sql
DROP TABLE IF EXISTS docs;

CREATE TABLE docs (
  title text,
  content text,
  chunks float_vector_array knn_type='hnsw' hnsw_similarity='cosine'
    model_name='Xenova/all-MiniLM-L6-v2' from='title,content'
    chunk_strategy='sentence' max_tokens='256' overlap_tokens='32'
);
```

这就是整个功能。不需要摄取流水线，不需要分块库，不需要为块建立第二张表，也不需要用 `GROUP BY` 把命中的块折回文档。

## TL;DR

- **五种策略**：`truncate`（旧的默认策略）、`mean`、`fixed`、`recursive`、`sentence`。在由模型支持的向量列上通过 `chunk_strategy` 设置。
- **`truncate` 和 `mean`** 每篇文档生成一个向量，适用于 `float_vector` 列。**`fixed`、`recursive` 和 `sentence`** 会生成多个向量，因此需要 [`float_vector_array`](https://manual.manticoresearch.com/Creating_a_table/Data_types#Float-vector-array) 列。
- **一篇文档仍然是一个搜索结果。** 块会单独参与竞争，Manticore 只返回一次文档，并由 `knn_dist()` 报告到最近块的距离。`k` 统计的是文档数，不是块数。
- **调优参数**：`max_tokens`（块大小）、`overlap_tokens`（相邻块之间共享的 token）、`max_chunks`（每篇文档的上限）。
- **在 Manticore 手册上测得**（189 页，约 298k 词）：对于埋在模型窗口之后的内容，recall@5 从 **55.1% → 83.3%**，MRR 从 **0.44 → 0.70**，代价是约 2.5 倍 RAM 和约 4 倍摄取时间。
- **查询永远不会被分块。** 查询足够短，可以整体嵌入；只有存储的文档会被拆分。

## 用一个小例子说明问题

假设你有四篇文档：

1. **备份与恢复运维手册** — 大约 700 词，约 900 个 token。内容包括备份计划、保留策略、恢复演练、凭据、容量规划。*最后*一节说明如何轮换复制端口使用的 TLS 证书。
2. **监控与告警指南** — 不相关。
3. **CLI 入门** — 不相关。
4. **HTTP API 的 TLS 和证书** — 一篇很短的页面，*全文*都在讲证书，但从未提到轮换或复制。

你可以使用下面的命令创建表并添加这些文档。

所以，我们现在有的是：一张表，三个使用相同源文本的向量列，每种策略一个列。一次 `INSERT` 会填充全部三个列，因此比较条件完全一致：

```sql
DROP TABLE IF EXISTS docs;
CREATE TABLE docs (
  title text,
  body text,
  v_truncate float_vector       knn_type='hnsw' hnsw_similarity='cosine'
    model_name='Xenova/all-MiniLM-L6-v2' from='title,body',
  v_mean     float_vector       knn_type='hnsw' hnsw_similarity='cosine'
    model_name='Xenova/all-MiniLM-L6-v2' from='title,body' chunk_strategy='mean',
  v_sentence float_vector_array knn_type='hnsw' hnsw_similarity='cosine'
    model_name='Xenova/all-MiniLM-L6-v2' from='title,body'
    chunk_strategy='sentence' max_tokens='128' overlap_tokens='32'
);
```

<details style="margin: 1.5rem 0;">
<summary style="cursor: pointer;">插入四篇文档</summary>

```sql
INSERT INTO docs (id, title, body) VALUES
  (1, 'Backup and restore runbook',
   'Nightly backups run at 02:00 UTC from the standby node. The job snapshots every table directory, writes a manifest, and uploads the result to object storage. Retention is thirty daily copies, twelve monthly copies, and one yearly copy. A restore drill runs on the first Monday of each month against a scratch cluster. The drill counts as passed only when a full-text search over the restored data returns the same document count as production. Anything less is treated as a failed drill and investigated the same week. Before a restore, freeze the target cluster so that no writes land while files are being replaced. Copy the manifest first and verify its checksum. If the checksum does not match, stop: a partial restore is worse than no restore, because the cluster will start and silently serve half the corpus. After the files are in place, unfreeze and let replication catch up. Watch the queue depth. If it does not drain within ten minutes, the node is probably still reading from cold storage and needs a warm-up pass before it can serve traffic. Backup failures page the on-call engineer. The three most common causes are an expired object storage credential, a disk that filled up while the snapshot was being written, and a table left frozen by a previous failed run. All three are recoverable without data loss. Check the job log first, then the disk, then the freeze state of every table. Capacity planning for backups is boring but it matters. A daily copy of the search cluster is roughly the size of the data directory plus fifteen percent for the manifest and metadata. Multiply by the retention count, add the transfer cost, and you have the monthly bill. Most teams discover too late that the yearly copies dominate the storage line. Object storage lifecycle rules do most of the retention work. Daily copies move to infrequent access after seven days and expire after thirty. Monthly copies move to archive after sixty days. Yearly copies never expire automatically; deleting one is a manual action that requires a second approver. Credentials for the backup job live in the secret manager and are issued to a role, not to a person. The role can write new objects and list the bucket. It cannot delete, and it cannot read objects older than the current day. That last restriction is the cheapest defence against a compromised backup runner turning into a data exfiltration path. Documentation for each table lives next to its schema: what the table is for, who owns it, how large it is expected to get, and whether it can be rebuilt from an upstream source. A table that can be rebuilt does not need thirty daily copies. Roughly half of most clusters turns out to be derived data that nobody had marked as derived. Verification is not the same as the job exiting zero. The job can succeed while producing an unusable copy: an empty table, a truncated upload, a manifest that references a file that was never written. The verification step reads the manifest back, checks every referenced object exists and matches its recorded size, and compares row counts on three sampled tables against production. Rotating the replication TLS certificate is a separate procedure and the step people most often get wrong. The certificate that secures the replication port is not the same as the one the HTTP API uses, and replacing one does not replace the other. Generate the new key and signing request on the node that will be rotated first, sign them with the cluster certificate authority, and place the files next to the existing ones rather than on top of them. Then update the node configuration to point at the new paths and reload. Do one node at a time and confirm that the cluster reports every peer as synced before moving on. A half-rotated cluster where two nodes trust different authorities will keep accepting writes on both sides and diverge quietly. When every node has been rotated, remove the old key material and revoke the retired certificate at the authority.'),
  (2, 'Monitoring and alerting guide',
   'Every node exports metrics over an HTTP endpoint that a scraper collects once per fifteen seconds. The dashboards are grouped into four rows: traffic, latency, saturation, and errors. Traffic is queries per second broken down by table. Latency is the ninety-fifth and ninety-ninth percentile of query time, measured server side. Alerting is deliberately thin. Paging alerts fire on sustained error rate above one percent for five minutes, on ninety-ninth percentile latency above two seconds for ten minutes, and on a node dropping out of the cluster. Everything else is a ticket, not a page. Teams that page on every anomaly stop reading pages within a month. Log retention is fourteen days hot and ninety days cold. The query log records the query text, the table, the match count, and the elapsed time. Turning it on costs a few percent of throughput and is almost always worth it, because most performance investigations start with a slow query nobody knew was being issued.'),
  (3, 'Getting started with the CLI',
   'The command line client connects over the MySQL wire protocol, so any MySQL client works and you do not need to install anything special. Point it at port 9306 and you get an interactive shell. The shell understands the usual conveniences: history, tab completion of table names, and vertical output when a row is too wide for the terminal. Start by listing tables, then look at one with SHOW CREATE TABLE. The output is the exact statement that would recreate the table, including every option that was applied implicitly, which makes it the fastest way to find out what a table actually does rather than what someone documented two years ago. Bulk loading from the shell is possible but rarely what you want. For anything above a few thousand rows, use the HTTP bulk endpoint or one of the log shipper integrations, both of which batch and retry for you.'),
  (4, 'TLS and certificates for the HTTP API',
   'The HTTP API can be served over TLS. You supply a certificate, a private key, and optionally a chain file, and the listener starts speaking HTTPS instead of HTTP. Clients that present a certificate of their own can be authenticated by it, which is the usual way to lock an internal API down without putting a password in every config file. Certificates for the HTTP API come from wherever your organisation gets certificates: a public authority, an internal authority, or an automated issuer. The file format is PEM. Both the certificate and the key must be readable by the user the server runs as, and the key must not be world readable or the listener refuses to start. Debugging TLS problems is mostly about reading the handshake. A client that reports an unknown authority is missing the chain. A client that reports a hostname mismatch is connecting by an address that is not in the certificate. A client that hangs is usually talking TLS to a plaintext port.');
```

</details>

现在针对每种策略各问一次问题，而答案位于运维手册的最后一节：

```sql
SELECT title, knn_dist() FROM docs
WHERE knn(v_truncate, 4, 'how do I rotate the TLS certificate used for replication');

SELECT title, knn_dist() FROM docs
WHERE knn(v_mean, 4, 'how do I rotate the TLS certificate used for replication');

SELECT title, knn_dist() FROM docs
WHERE knn(v_sentence, 4, 'how do I rotate the TLS certificate used for replication');
```

| 策略 | 第 1 个结果 | 第 2 个结果 |
|---|---|---|
| `truncate`（默认） | **HTTP API 的 TLS 和证书** — 0.762 | 备份与恢复运维手册 — 0.936 |
| `mean` | 备份与恢复运维手册 — 0.656 | HTTP API 的 TLS 和证书 — 0.762 |
| `sentence`，128 tokens，32 overlap | 备份与恢复运维手册 — **0.254** | HTTP API 的 TLS 和证书 — 0.700 |

使用 `truncate` 时，真正回答问题的文档输给了一个只是*看起来*在讲证书的干扰项。运维手册的单个向量是由开头关于备份计划和恢复演练的页面生成的，因为模型只被允许读取这部分内容。

使用 `sentence` 分块后，运维手册会被存储为九个向量，而不是一个：

```sql
SELECT id, title, LENGTH(v_sentence) AS chunks FROM docs ORDER BY id ASC;
```

```text
+------+---------------------------------------+--------+
| id   | title                                 | chunks |
+------+---------------------------------------+--------+
|    1 | Backup and restore runbook            |      9 |
|    2 | Monitoring and alerting guide         |      2 |
|    3 | Getting started with the CLI          |      2 |
|    4 | TLS and certificates for the HTTP API |      2 |
+------+---------------------------------------+--------+
```

这九个向量中的一个就是证书轮换段落。它几乎完全匹配查询，因此该文档以很大优势胜出：0.254 对 0.700。

## 更多分块策略说明

| 策略 | 每篇文档的向量数 | 列类型 | 作用 |
|---|---|---|---|
| `truncate` | 1 | `float_vector` | 在模型窗口能容纳的范围内生成嵌入，并丢弃其余部分。这是旧版本中唯一可用的模式，现在仍是默认模式。 |
| `mean` | 1 | `float_vector` | 拆分整篇文档，为每个片段生成嵌入，再把它们平均为一个向量。 |
| `fixed` | N | `float_vector_array` | 固定为 `max_tokens` 个 token 的窗口。 |
| `recursive` | N | `float_vector_array` | 按分隔符层级拆分：先段落，再行，再句子，最后空格，并让每个片段保持在 `max_tokens` 以内。 |
| `sentence` | N | `float_vector_array` | 按句子边界（[Unicode UAX #29](https://www.unicode.org/reports/tr29/)）拆分，并打包到 `max_tokens` 为止。 |

重要区别不在于文本如何被切开，而在于一次匹配*意味着什么*。

每篇文档一个向量时，搜索问的是：**“这篇文档整体上是否与查询相似？”** 单个相关段落会被周围所有内容稀释，一篇覆盖五个主题的文档最终可能与任何一个主题都不太匹配。

每个块一个向量时，搜索问的是：**“这篇文档是否*包含*相似内容？”** 每个块都凭自身相关性竞争，Manticore 只返回一次文档，并按其最佳块计分。

### `truncate` — 文档较短时继续使用

```sql
title text,
v float_vector knn_type='hnsw' hnsw_similarity='cosine'
  model_name='Xenova/all-MiniLM-L6-v2' from='title'
```

这就是你已经在用的方式；`chunk_strategy='truncate'` 是默认值，你完全不必写出来。只要文本确实能放进模型窗口，它就是正确选择，也是最快、最省空间的选择：产品标题、短描述、标签、聊天消息、日志行、搜索查询、提交标题。

**能放下多少？** 比大多数人以为的多，也比他们希望的少。`all-MiniLM-L6-v2` 接收 512 个 token，约 380 个英文词。`text-embedding-3-small` 接收 8,192 个。如果你的第 95 百分位文档明显低于限制，就可以停在这里，继续使用 `truncate`。

**什么时候会出问题：** 任何长篇内容。文档页面、知识库文章、合同、转录稿、邮件线程、Wiki 页面、README 文件、事故复盘。

### `mean` — 一个向量，但覆盖整篇文档

```sql
v float_vector knn_type='hnsw' hnsw_similarity='cosine'
  model_name='Xenova/all-MiniLM-L6-v2' from='title,content'
  chunk_strategy='mean'
```

Manticore 会拆分文档，为每个块生成嵌入，然后把块向量平均成一个归一化向量。存储和搜索成本与 `truncate` 完全相同：每篇文档一个向量，一个 HNSW 节点，但不会丢弃任何内容。

**适用场景：**

- 你希望尾部内容也被考虑，但无法承受更多向量，例如索引 RAM 是硬约束的超大语料库。
- 列是普通 `float_vector`，且无法更改类型（例如你通过 `ALTER` 给现有表添加列，而多向量策略不支持这种方式）。
- 你的文档*只围绕一个主题*，只是比较长。比如单个产品的完整描述、一份菜谱、一条招聘信息。

**不适用场景：** 一篇文档覆盖多个互不相关的主题。把法律合同中的赔偿条款和付款条款平均在一起，会得到一个位于两者之间、但与两者都不接近的向量。在下面的基准测试中，`mean` 弥补了分块所能缩小差距的大约三分之一：这是实实在在的改进，但显然不是同一回事。

### `fixed` — 可预测，也最容易理解

```sql
v float_vector_array knn_type='hnsw' hnsw_similarity='cosine'
  model_name='Xenova/all-MiniLM-L6-v2' from='content'
  chunk_strategy='fixed' max_tokens='256' overlap_tokens='32'
```

每隔 `max_tokens` 个 token 切一次，不管文本此时处在什么位置。块数量是文档长度的直接函数，因此在加载任何内容之前就能预测索引大小。

**适用场景：** 文本没有可靠结构可利用：OCR 输出、丢失段落的抓取 HTML、没有标点的机器转录、日志转储、压缩后的内容。当你只是想用成本最低的方式避免截断时，它也是一个不错的默认选择。

**代价：** 边界可能落在句子中间，而从半个意思开始的块嵌入效果会很差。这正是 `overlap_tokens` 的用途，见下文。

### `recursive` — 通用散文文本的最佳默认选择

```sql
v float_vector_array knn_type='hnsw' hnsw_similarity='cosine'
  model_name='Xenova/all-MiniLM-L6-v2' from='title,content'
  chunk_strategy='recursive' max_tokens='256' overlap_tokens='32'
```

token 预算与 `fixed` 相同，但每次切分都会回退到最近的自然边界：先空行，再换行，再句末，最后空格。块在文本自然停止处结束，而不是在计数器耗尽处结束。边界绝不会被回退到块中点之前，因此不会产生一串细碎片段。

如果你用过 LangChain 的 `RecursiveCharacterTextSplitter`，这是同一个思路，只不过它在数据库内部基于模型真实 token 运行，而不是按字符运行，并且无需安装任何东西。

**适用场景：** Markdown 和 HTML 文档、Wiki 页面、知识库、博客文章、README 文件、结构化报告，也就是任何由人按段落写成的内容。在我们的基准测试中，它在深层内容上得分最高。

### `sentence` — 当一个块必须是完整意思时

```sql
v float_vector_array knn_type='hnsw' hnsw_similarity='cosine'
  model_name='Xenova/all-MiniLM-L6-v2' from='content'
  chunk_strategy='sentence' max_tokens='256' overlap_tokens='32'
```

使用 [Unicode UAX #29](https://www.unicode.org/reports/tr29/) 检测句子边界，然后贪心地打包完整句子，直到达到 token 预算。块绝不会在句子中间开始或结束。只有当单个句子长于预算时，才会作为最后手段按 token 窗口拆分。

**适用场景：** 支持工单和邮件线程、聊天和会议转录、法律和政策文本、新闻、客户评论、医学和科学摘要，也就是任何句子片段会改变或破坏含义的内容。当块随后会被送入 LLM 时，也应选择这种策略，因为在从句中间结束的块放进提示词里读起来很糟。

`sentence` 比 `recursive` 稍微保守一些：在我们的测试中，它生成的块更少、更干净，recall@5 得分大致相同。

## 三个调节参数

```
chunk_strategy  = truncate | mean | fixed | recursive | sentence
max_tokens      = chunk size in tokens; 0 (default) = the model's own limit
overlap_tokens  = tokens shared between consecutive chunks; needs a non-zero max_tokens
max_chunks      = ceiling on vectors per document; 0 (default) = unlimited
```

**`max_tokens`** 的上限是模型实际能接受的长度：在 512-token 模型上请求 4,096，仍然只会得到 512，而不是报错。块越小，匹配越精准，向量越多；块越大，每个向量包含的上下文越多，向量数量越少。对于英文散文，128–512 几乎覆盖所有用例；我们的基准测试全程使用 256。

**`overlap_tokens`** 会把每个块的尾部重复到下一个块的开头，因此跨越边界的句子仍会在某处完整出现。常见设置是 `max_tokens` 的 10–20%。Manticore 保证会向前推进：`fixed` 和 `recursive` 会把重叠限制在块大小的一半以内，`sentence` 会用最多 `overlap_tokens` 个 token 的尾部完整句子重新开始下一个块，同时始终至少前进一个句子。它要求显式设置非零 `max_tokens`，因为针对“模型限制碰巧是多少”的重叠并不是有意义的设置，所以 Manticore 会拒绝它。

**`max_chunks`** 用来限制异常大文档的影响。如果没有它，把一份 400 页 PDF 粘进一行，就会变成数千个 HNSW 节点。有了它，Manticore 会把溢出的内容合并进最后保留的块，然后在生成嵌入时将其截断到模型窗口：

```sql
-- a ~600-token document, chunked at 64 tokens
chunk_strategy='fixed' max_tokens='64'                  -- 22 vectors
chunk_strategy='fixed' max_tokens='64' max_chunks='3'   --  3 vectors
```

把它当作防止离群值的护栏，而不是全局节省内存的方法。

## 搜索看起来是什么样

你的查询方式与之前没有任何变化。没有块表，没有嵌套字段，没有 join，也没有 `GROUP BY`。下面是完整示例：

```sql
DROP TABLE IF EXISTS notes;
CREATE TABLE notes (
  title text,
  body text,
  chunks float_vector_array knn_type='hnsw' hnsw_similarity='cosine'
    model_name='Xenova/all-MiniLM-L6-v2' from='title,body'
    chunk_strategy='sentence' max_tokens='32'
);

INSERT INTO notes (id, title, body) VALUES
  (1, 'Certificate rotation',
   'The replication certificate is not the one the HTTP API uses. Generate the new key on the node being rotated and sign it with the cluster authority. Update the paths and reload, one node at a time, confirming every peer reports as synced before you move on.'),
  (2, 'Disk pressure',
   'When a data directory crosses eighty percent the merge scheduler stops compacting and the node starts refusing writes. Free space first, then trigger a manual OPTIMIZE. Adding a disk without draining the queue only postpones the problem.'),
  (3, 'Slow queries',
   'Turn the query log on before guessing. Most investigations end at a single query nobody knew was being issued, usually one that sorts on an unindexed attribute over the whole table.');

SELECT id, title, knn_dist() FROM notes
WHERE knn(chunks, 3, 'how do I replace an expiring certificate on every node');
```

```text
+------+----------------------+------------+
| id   | title                | knn_dist() |
+------+----------------------+------------+
|    1 | Certificate rotation | 0.51039070 |
|    2 | Disk pressure        | 0.91703475 |
|    3 | Slow queries         | 1.02606630 |
+------+----------------------+------------+
```

这里故意把 `max_tokens='32'` 设得很小，这样这些短笔记也会实际拆分，你就能在玩具数据集上看到多向量行为。向量列上的 `LENGTH()` 显示每篇文档是如何被切分的：

```sql
SELECT id, title, LENGTH(chunks) AS n FROM notes ORDER BY n DESC LIMIT 5;
```

```text
+------+----------------------+------+
| id   | title                | n    |
+------+----------------------+------+
|    1 | Certificate rotation |    2 |
|    2 | Disk pressure        |    2 |
|    3 | Slow queries         |    2 |
+------+----------------------+------+
```

六个向量，返回三行。搜索遵循[每篇文档多个向量](https://manual.manticoresearch.com/Searching/KNN#Multiple-vectors-per-document)中描述的规则：

- 如果文档的**任意**向量接近查询向量，该文档就匹配。
- Manticore 对每个匹配项**只返回一次**。`knn_dist()` 是到其**最近**块的距离。
- **`k` 统计文档数**，不是向量数。`knn(chunks, 3, ...)` 表示三篇文档。
- 没有向量的文档永远不会被返回。

同一个查询通过 HTTP 执行：

```json
POST /search
{
  "table": "notes",
  "knn": {
    "field": "chunks",
    "query": "how do I replace an expiring certificate on every node",
    "k": 3
  },
  "_source": ["title"]
}
```

```json
...
{
  "_id": 1,
  "_score": 1,
  "_knn_dist": 0.51039070,
  "_source": { "title": "Certificate rotation" }
}
...
```

KNN 页面上的其他功能照常工作：过滤、预过滤和后过滤策略、[量化](/blog/quantization/)、[提前终止](/blog/knn-early-termination/)以及重评分。

## 它真的有帮助吗？我们自己的手册上的数据

我们在 **Manticore 英文手册**上测试了这个功能：189 页，约 298,000 词，从两段式说明到 39,000 词的更新日志都有。

查询集是机械生成的，不是人工挑选的。对于每个页面，我们取其章节标题，只保留在整本手册中唯一的标题，然后分成两组：

- **深层内容查询（419）** — 出现在页面前约 1,200 个字符*之后*的标题。这是一条刻意保守的线：模型窗口是 512 个 token，约 2,000 个字符，因此其中一些查询仍指向 `truncate` 能部分看到的文本。所以下面的差距是低估，而不是夸大。
- **头部内容查询（88）** — 位于前约 1,200 个字符以内的标题。控制组：`truncate` 已经能看到的内容。

如果 KNN 在前 *k* 个结果中返回标题所属页面，则该查询命中。模型：`Xenova/all-MiniLM-L6-v2`（384 维，512-token 窗口），运行在 Manticore 的 [ONNX 后端](/blog/onnx-embeddings-speedup/)上。硬件：32 个线程。多向量策略使用 `max_tokens='256'`、`overlap_tokens='32'`。对于给定索引，质量数据是确定性的；时间数据是在一台基本空闲的机器上每种策略单次运行的结果。

### 深层内容 — 分块真正要解决的问题

| 策略 | 向量数 | 摄取 | 索引 RAM | hit@1 | hit@5 | hit@10 | MRR |
|---|---|---|---|---|---|---|---|
| `truncate` | 189 | 21 s | 4.2 MB | 33.7% | 55.1% | 63.2% | 0.44 |
| `mean` | 189 | 72 s | 4.2 MB | 43.9% | 65.2% | 74.7% | 0.54 |
| `fixed` | 3,430 | 73 s | 9.5 MB | 56.3% | 81.1% | 86.2% | 0.68 |
| `recursive` | 4,664 | 86 s | 11.7 MB | **58.7%** | 83.3% | **89.5%** | **0.70** |
| `sentence` | 4,041 | 79 s | 10.6 MB | 55.4% | **83.5%** | 89.0% | 0.68 |

分块把一次抛硬币式的搜索变成了可用的搜索。**recall@5 从 55.1% 提升到 83.3%**，正确答案的排名也同样明显改善：MRR 0.44 → 0.70。在 `truncate` 完全无法把答案排进前 5 的查询中，`recursive` 大约挽回了三分之二。

`mean` 的表现符合预期：它免费弥补了约三分之一差距，因为存储和搜索完全没有额外成本。

### 头部内容 — 控制组

| 策略 | hit@1 | hit@5 | MRR |
|---|---|---|---|
| `truncate` | **65.9%** | **86.4%** | **0.74** |
| `mean` | 59.1% | 83.0% | 0.69 |
| `fixed` | 60.2% | 83.0% | 0.71 |
| `recursive` | 58.0% | **86.4%** | 0.70 |
| `sentence` | 56.8% | 85.2% | 0.69 |

为了完整起见，控制组值得仔细看。对于模型本来就能看到的内容，**`truncate` 在排名第 1 的精度上仍然最高**：65.9%，而 `recursive` 是 58.0%。整篇文档向量携带页面的整体主题，当查询问的是页面开头的主题时，这种上下文会有帮助。

到排名前 5 时，差异消失了：`recursive` 与 `truncate` 同为 86.4%。因此，这笔交换是牺牲开头附近内容几个百分点的 top-1 精度，换取其他所有内容 +28 个百分点的召回率。对于文档搜索、帮助中心，或任何会把 5–10 个段落送给 LLM 的 RAG 检索器来说，这不是一个难选的问题。

### 成本

- **索引 RAM**：4.2 MB → 11.7 MB，约 2.5 倍，而向量数量约为 25 倍。向量只是 RT 表存储内容的一部分。这些向量上的 HNSW 图在保存块和执行 `OPTIMIZE` 时也需要更久来构建，不过 Manticore 会[跨所有核心构建它](/blog/knn-parallel-build/)。
- **数据加载**：189 篇文档从 21 s → 86 s。分块意味着嵌入整套语料，而不是每篇文档开头的 380 个词，时间也随之增长。这是嵌入成本，不是分块成本；与推理相比，拆分本身几乎不可测。
- **查询响应时间**：p50 下从 6.3 ms → 8.5 ms。HNSW 处理 4,664 个向量几乎和处理 189 个一样轻松，背后的原因见 [2-pass HNSW、批量距离计算和 AVX-512](/blog/knn-hnsw-performance/)。

如果你使用付费嵌入 API，请把这个摄取数字视为账单：分块会把你的整个语料送给模型，而不是每篇文档的开头部分，你需要为每个 token 付费。本地 ONNX 模型没有按 token 计费，这也是我们让它们变得[快速](/blog/onnx-embeddings-speedup/)的重要原因。

## 选择分块策略的建议

| 你的数据 | 建议从这里开始 |
|---|---|
| 标题、名称、短描述、标签、日志行 | `truncate` |
| 很长但单一主题；或 RAM 是硬限制；或列是现有 `float_vector` | `mean` |
| 文档、Wiki、知识库、文章、README | `recursive`，`max_tokens` 128–256 |
| 支持工单、邮件、转录稿、法律文本、评论 | `sentence`，`max_tokens` 128–256 |
| OCR、抓取的 HTML、机器转录、非结构化转储 | `fixed`，`max_tokens` 256，并加重叠 |
| 块会作为上下文传给 LLM | `sentence`，`max_tokens` 384–512 |

大多数推荐中刻意没有加入重叠：我们下面的扫描测试无法在结构化散文上测出它的收益，而它会增加向量成本。只有当一个意思经常跨越边界时才添加它，例如非结构化转录、OCR、没有段落断点的长篇叙事。

### 块应该多大？

块大小是真正影响结果的设置。取舍很直接：**块越小，越能精准匹配单个想法；块越大，携带的上下文越多，但其中每个想法都会被稀释。** 长文档中埋藏的段落，只有在块足够小、能拥有自己的向量时，才会变得可检索。

我们又跑了一项测试：在同一份 189 页手册上使用 `recursive`，三种块大小 × 三种重叠设置，以及同样的 419 个深层查询。两个趋势很明显：块越小质量越高，而重叠增加了成本，却几乎没有改善质量。

![三种重叠设置下，检索质量和索引 RAM 随块大小变化](./auto-chunking/chunk_size_sweep.svg)

注意 Y 轴从 78% 开始，而不是从零开始：整个跨度大约只有六个百分点，如果使用从零开始的坐标轴，它会被压平成一条直线。图表背后的数据：

| `max_tokens` | `overlap_tokens` | 向量数 | 索引 RAM | deep hit@5 | deep MRR |
|---|---|---|---|---|---|
| 128 | 0 | 8,256 | 17.3 MB | 85.2% | **0.718** |
| 128 | 13 | 9,328 | 18.7 MB | **85.7%** | 0.705 |
| 128 | 32 | 11,623 | 22.6 MB | 85.2% | 0.694 |
| 256 | 0 | 3,984 | 10.0 MB | 83.1% | 0.657 |
| 256 | 26 | 4,525 | 10.9 MB | 84.5% | 0.681 |
| 256 | 64 | 5,569 | 12.5 MB | 83.5% | 0.689 |
| 512 | 0 | 1,973 | 6.9 MB | 80.2% | 0.655 |
| 512 | 51 | 2,191 | 7.2 MB | 79.2% | 0.651 |
| 512 | 128 | 2,666 | 8.0 MB | 79.7% | 0.660 |

我们看到的是：

**更小的块始终胜出。** 从 512 降到 128 个 token，recall@5 提高约五个百分点（80.2% → 85.2%），排名质量也大幅提升（MRR 0.655 → 0.718）。代价是向量数增加 4 倍，索引 RAM 增加 2.5 倍。低于 128 后，块开始容不下完整想法，所以这不是一条可以无限走下去的斜坡；但在长篇技术散文上，128–256 每次都优于 512。

**重叠基本没有提升质量，而且并非免费。** 在 128 个 token 时，从无重叠到 25% 重叠，recall@5 仍是 85.2%，同时向量数增加 41%，RAM 增加 5 MB。各种大小下模式都相同：不同重叠设置之间的波动（±1.5 个百分点）处在 419 个查询集的噪声范围内，但成本不是。这与 Chroma 的[分块评估](https://www.trychroma.com/research/evaluating-chunking)一致：普通递归拆分在 200 个 token 且**无重叠**时取得了 88.1% 召回率，与 LLM 驱动分块器的 91.9% 只差几个百分点；这也与大多数 RAG 指南里“总是使用 10–20% 重叠”的建议相反。

坦诚的 caveat 是：这只是一套语料、一个模型，以及看起来像章节标题的查询。当单个事实经常跨越边界时，重叠会发挥价值，例如长篇无断点叙事、缺乏结构的转录稿；而 `recursive` 已经会把切分对齐到段落和句子边界，完成了很多类似工作。因此，可以把“从 128–256 且无重叠开始，只有在能测出帮助时才添加重叠”作为默认做法，并用下面的流程在你自己的数据上验证。

### 比较设置

你不必猜，也不需要两张表。一张表可以包含**多个由模型支持的向量列**，每个列都有自己的策略，并且都在同一次 `INSERT` 中由相同字段填充：

```sql
CREATE TABLE ab (
  title text,
  body text,
  sent_256 float_vector_array knn_type='hnsw' hnsw_similarity='cosine'
    model_name='Xenova/all-MiniLM-L6-v2' from='title,body'
    chunk_strategy='sentence' max_tokens='256' overlap_tokens='32',
  rec_128 float_vector_array knn_type='hnsw' hnsw_similarity='cosine'
    model_name='Xenova/all-MiniLM-L6-v2' from='title,body'
    chunk_strategy='recursive' max_tokens='128' overlap_tokens='16'
);
```

加载一次语料，然后对每个列运行相同查询并比较。例如：

```sql
SELECT id, LENGTH(sent_256) AS sent_chunks, LENGTH(rec_128) AS rec_chunks FROM ab;
SELECT id, knn_dist() FROM ab WHERE knn(sent_256, 5, 'how do I rotate the replication certificate');
SELECT id, knn_dist() FROM ab WHERE knn(rec_128,  5, 'how do I rotate the replication certificate');
```

在一篇证书章节位于末尾的短运维手册上，`sentence`/256 会把整篇文档放进单个块，并以距离 **0.515** 给出答案；`recursive`/128 会把它拆成两块，隔离出证书段落，并以 **0.310** 给出答案。同一行、同一模型、同一查询，只有块大小不同。

构建一组真实查询和可信答案的数据集，即使 50 个也足够，然后像我们在上面的手册测试中那样，在两三个列之间比较 recall@5。最后用 `ALTER TABLE ... DROP COLUMN` 删除表现较差的列，保留胜出的列。

## 配方

**文档和帮助中心搜索。** 长 Markdown 页面，用户用自己的话提问。按结构分块并跨块搜索：

```sql
CREATE TABLE docs (
  url string,
  title text,
  body text,
  chunks float_vector_array knn_type='hnsw' hnsw_similarity='cosine'
    model_name='Xenova/all-MiniLM-L6-v2' from='title,body'
    chunk_strategy='recursive' max_tokens='192'
);
```

注意 `from='title,body'`：字段会在分块前拼接，因此页面标题会落在第一个块中，为它提供上下文。关于这种形态的端到端示例，请参见 [GitHub 上的向量搜索](/blog/github-semantic-search/)。

**支持工单和邮件线程。** 一个线程是一串完整消息；在句子中间切开会丢失你需要的事实。要限制块数量，因为线程没有天然长度上限：

```sql
CREATE TABLE tickets (
  ticket_id bigint,
  customer string,
  status string,
  thread text,
  chunks float_vector_array knn_type='hnsw' hnsw_similarity='cosine'
    model_name='Xenova/all-MiniLM-L6-v2' from='thread'
    chunk_strategy='sentence' max_tokens='256' overlap_tokens='32' max_chunks='64'
);

SELECT ticket_id, knn_dist() FROM tickets
WHERE knn(chunks, 10, 'customer was charged twice after upgrading')
  AND status = 'closed';
```

过滤的工作方式与单向量列完全相同。

**合同和政策文档。** 条款级检索才是核心目标：没人想要返回“整份合同”，他们想要赔偿条款。使用更小的块和更充分的重叠：

```sql
chunks float_vector_array knn_type='hnsw' hnsw_similarity='cosine'
  model_name='Xenova/all-MiniLM-L6-v2' from='body'
  chunk_strategy='sentence' max_tokens='128' overlap_tokens='32'
```

**带长描述的商品目录。** 一个商品就是一个主题，而目录通常很大，所以不要额外付出成本：

```sql
embedding float_vector knn_type='hnsw' hnsw_similarity='cosine'
  model_name='Xenova/all-MiniLM-L6-v2' from='name,description'
  chunk_strategy='mean'
```

**RAG：面向 LLM 的检索。** 检索到的任何内容都会被粘贴进提示词，因此块应该读起来像正常散文。这是[对话式搜索](/blog/conversational-search/)中的检索部分。使用更大的块、句子边界，并请求更多结果：

```sql
chunks float_vector_array knn_type='hnsw' hnsw_similarity='cosine'
  model_name='Xenova/all-MiniLM-L6-v2' from='title,body'
  chunk_strategy='sentence' max_tokens='512' overlap_tokens='64'
```

**给已有表添加分块。** 多向量列不能通过 `ALTER` 添加：现有行没有向量，而且目前还没有回填方式。单向量策略可以：

```sql
ALTER TABLE docs ADD COLUMN v2 float_vector knn_type='hnsw' hnsw_similarity='cosine'
  model_name='Xenova/all-MiniLM-L6-v2' from='title,body' chunk_strategy='mean';

ALTER TABLE docs REBUILD EMBEDDINGS v2;
```

对于多向量列，请创建带有该列的新表，并重新索引到新表中。

## 其他引擎如何处理这件事

现在每个向量引擎都会为你生成嵌入。少得多的引擎会在生成嵌入前*拆分*你的文档；而在这些引擎中，大多数还要求你用流水线阶段把它组装起来。

| 引擎 | 引擎内嵌入 | 引擎内分块 | 策略 | 搜索时每篇文档一行 |
|---|---|---|---|---|
| **Manticore Search** | 是 — 本地 + OpenAI / Voyage / Jina | **[是 — 向量列上的 `chunk_strategy`](https://manual.manticoresearch.com/Searching/KNN#Chunking-strategies)** | truncate, mean, fixed, recursive, sentence | 是，原生支持 |
| **Elasticsearch** | 是 — 推理端点 | [是](https://www.elastic.co/docs/explore-analyze/elastic-inference/inference-api#chunking-settings) | sentence（默认）、word、recursive（9.1+）、none | 是 — `semantic_text` 会隐藏块 |
| **OpenSearch** | 是 — ML Commons | [是 — 单独的摄取处理器](https://docs.opensearch.org/latest/ingest-pipelines/processors/text-chunking/) | fixed_token_length, fixed_char_length, delimiter | 需要嵌套字段 + 嵌套查询 |
| **Vespa** | 是 — 内置嵌入器 | [是 — 索引表达式](https://docs.vespa.ai/en/rag/working-with-chunks.html) | fixed-length, sentence, custom | 是 |
| **Azure AI Search** | 是 — 集成向量化 | [是 — skillset 中的 Split skill](https://learn.microsoft.com/en-us/azure/search/cognitive-search-skill-textsplit) | pages（字符）、sentences | 否 — 每个块一行 |
| **PostgreSQL + pgai** | 是 — 后台 worker | [是](https://github.com/timescale/pgai/blob/main/docs/vectorizer/api-reference.md) | character, recursive character | 否 — 单独表、join 和去重 |
| **Milvus / Zilliz** | 是 — Function（2.6+） | 否 — [应用侧](https://zilliz.com/learn/guide-to-chunking-strategies-for-rag) | — | — |
| **Qdrant** | 是 — Cloud Inference | 否 — [应用侧](https://qdrant.tech/course/essentials/day-1/chunking-strategies/) | — | — |
| **Weaviate** | 是 — 向量化模块 | 否 — [应用侧](https://docs.weaviate.io/academy/py/standalone/chunking) | — | — |
| **Meilisearch** | 是 — 嵌入器 | 否 — [应用侧](https://www.meilisearch.com/blog/rag-chunking-strategies) | — | — |
| **Typesense** | 是 | 否 — [开放请求](https://github.com/typesense/typesense/issues/1526) | — | — |
| **Apache Solr** | 是 — [LLM 模块](https://solr.apache.org/guide/solr/latest/query-guide/text-to-vector.html)（9.8+） | 否 | — | — |
| **Pinecone** | 是 — 集成推理 | 否 — [应用侧](https://www.pinecone.io/learn/chunking-strategies/) | — | — |
| **MongoDB Atlas** | 是 — Automated Embedding | 否 — [应用侧](https://www.mongodb.com/resources/basics/chunking-explained) | — | — |

分块列中的每个单元格都链接到来源。“是”链接到该功能自己的文档。“应用侧”链接到该厂商关于在你的应用中进行分块的*自家*指南，也就是他们发布来替代引擎内选项的内容。如果我们漏掉了某个功能，或者之后已经发布了相关功能，请[告诉我们](https://github.com/manticoresoftware/manticoresearch/issues)，我们会修正。

<details style="margin: 1.5rem 0;">
<summary style="cursor: pointer;">已检查版本 - 2026 年 9 月 4 日</summary>

当天可用的各产品最新稳定版本：

| 产品 | 版本 |
|---|---|
| Elasticsearch | 9.5.3 |
| OpenSearch | 3.8.0 |
| Vespa | 8.750.13 |
| Azure AI Search | REST API 2026-04-01 |
| PostgreSQL + pgai | extension 0.11.2 |
| Milvus / Zilliz | 2.6.23 |
| Qdrant | 1.19.0 |
| Weaviate | 1.38.13 |
| Meilisearch | 1.53.1 |
| Typesense | 30.2 |
| Apache Solr | 10.0.0 |
| Pinecone, MongoDB Atlas | 托管服务，无需固定版本 |

</details>

有两点很突出。

**引擎内分块仍然很少见。** Milvus、Qdrant、Weaviate、Pinecone、MongoDB Atlas、Typesense、Meilisearch，以及自 9.8 LLM 模块起的 Apache Solr，都会为你运行嵌入模型，而且它们也都会毫无提示地截断你的 4,000 词文档。拆分是你的问题，要在你的应用里完成，用一种并不知道模型使用什么 tokenizer 的语言和库来做。

**即使存在分块，管线通常也会漏水。** OpenSearch 需要用 `text_chunking` 处理器接上 `text_embedding` 处理器，写入嵌套字段，再用嵌套查询和评分模式来查询。Azure AI Search 需要包含 Split skill、嵌入 skill 和索引投影的 skillset，并且每个块返回一行结果，因此你要自己把它们分组回文档。pgai Vectorizer 会把块写入第二张表，所以每次查询都是 join 加 `DISTINCT ON`。Elasticsearch 的 `semantic_text` 确实很接近 Manticore 的模型：推理端点上有分块设置，块隐藏在字段内部，每篇文档一个命中。

Manticore 用更小的表面积做同一件事：策略是列上的一个选项，块就是列的值，搜索返回文档。如果你评估的是整个技术栈，而不只是这个功能，我们也写过与 [Elasticsearch](/blog/manticore-alternative-to-elasticsearch/) 以及 [Turbopuffer](/blog/turbopuffer-vs-manticore/) 的对比。

## 分块不能解决什么

分块很好地解决了一个问题：长于模型窗口的文档不再有一半内容不可见。但它不会让检索变得完美，有两个已知缺口值得说明。

**块不知道自己来自哪里。** 拆分一篇文档后，你可能得到一个段落，说“逐个节点操作，并确认每个对等节点都报告已同步”，但没有说明正在轮换的*是什么*，或者它属于哪个产品。Anthropic 的[上下文检索](https://www.anthropic.com/engineering/contextual-retrieval)工作给出了数据：在嵌入前为每个块前置一段简短、针对该块的周边文档描述，可将 top-20 检索失败减少 35%；如果再结合上下文 BM25 索引，可减少 49%。

Manticore 不会为你做这件事。`FROM` 会在分块*之前*用空格连接字段，因此把 `title` 放在前面会让标题位于待拆分文本的开头，也就意味着它只会落在**第一个**块里。之后的每个块都只能靠自己：

```sql
-- 'title' leads, so its words are in chunk 1; chunks 2..N never see them
from='title,body'
```

如果你需要每个块都携带上下文，就必须在插入前自己把它构建进存储文本里，例如在 `body` 每个小节开头重复一个短标题。目前没有按块添加前缀的选项。

**块边界在模型看到文本之前就已决定。** Manticore 会先拆分，再独立嵌入每个片段。这是标准做法，也是上表中所有支持引擎内分块的引擎所采用的方式。另一种称为[后期分块](https://jina.ai/news/late-chunking-in-long-context-embedding-models/)的方法则反过来：先让长上下文模型处理整篇文档，再把 token 嵌入池化成块，这样每个块向量都携带来自文档其余部分的上下文。它需要长上下文模型，并且每篇文档需要更多计算；Manticore 目前不支持。如果你的文档高度依赖跨段落上下文，知道这个选项存在是有价值的。

这两个缺口都不改变基本结论：对于长文档，分块检索远胜截断检索，而实现它只需要在一个列上添加 `chunk_strategy`。

## 限制和注意事项

**`max_chunks` 会丢弃文本。** Manticore 会把溢出内容合并进最后保留的块，然后将其截断到模型窗口。不会有任何警告。它是针对离群值的护栏，而不是全局节省内存的方法。

**远程模型按字节分块，而不是按 token。** OpenAI、Voyage 和 Jina 没有本地 tokenizer，因此 Manticore 会回退到一个刻意保守的估算：**每个 token 3 字节**，确保块落在提供商限制以内，而不是超出限制。实践中，`max_tokens='N'` 会变成 `N × 3` 字节窗口。我们用一个 3,599 字节文档和 `fixed` 策略，在桩端点上测得：

| `max_tokens` | 字节窗口 | 生成的块数 |
|---|---|---|
| 100 | 300 | 12 |
| 200 | 600 | 6 |
| 400 | 1,200 | 3 |

英文散文更接近每个 token 4 字节，因此在远程模型上，你得到的块大约会比请求的数字小四分之一。要落到与本地模型相同的位置，可以把 `max_tokens` 设得比本地模型高约 30%。如果精确边界很重要，请使用本地模型，因为分块会基于模型真实 token 完成。

**多向量列不能用 `ALTER` 添加。** 在由模型支持的 `float_vector_array` 上，`ALTER TABLE ... ADD COLUMN` 和 `ALTER TABLE ... REBUILD EMBEDDINGS` 都会被拒绝。请改为重建表。二者在 `float_vector` 上正常工作，包括使用 `mean` 时。

**分块只适用于自动嵌入。** 你自己插入的向量会按原样存储，Manticore 永远不会重新切分你提供的数据。没有 `model_name` 的 `chunk_strategy` 会触发 DDL 错误，这是有意设计。

**`embeddings` 是保留字。** `EMBEDDINGS` 是 DDL 关键字（`ALTER TABLE ... REBUILD EMBEDDINGS`），因此列名如果字面上叫 `embeddings`，除非转义，否则会产生语法错误。如果你需要这个名称，请使用转义。

**查询不会被分块。** 查询会作为单个向量整体嵌入。这正是你想要的：分块是为了让长*文档*可被找到，而不是为了拆分一个十五个词的问题。

**DDL 会在组合错误时告诉你**，时间点是在 `CREATE TABLE` 阶段，而不是第一次插入时：

```sql
mysql> CREATE TABLE t (title text, v float_vector ... chunk_strategy='sentence');
ERROR 1064: chunk_strategy='sentence' produces several vectors per document
            and requires a float_vector_array attribute

mysql> ... chunk_strategy='fixed' overlap_tokens='32');
ERROR 1064: overlap_tokens requires an explicit non-zero max_tokens

mysql> ... chunk_strategy='paragraph');
ERROR 1064: unknown chunk_strategy 'paragraph'; expected truncate, mean, fixed,
            recursive or sentence

mysql> ... chunk_strategy='truncate' max_tokens='128');
ERROR 1064: chunk_strategy='truncate' ignores max_tokens, overlap_tokens and max_chunks
```

## 试试看

实现可用的分块语义搜索的最短路径：

```sql
DROP TABLE IF EXISTS docs;

CREATE TABLE docs (
  title text,
  content text,
  chunks float_vector_array knn_type='hnsw' hnsw_similarity='cosine'
    model_name='Xenova/all-MiniLM-L6-v2' from='title,content'
    chunk_strategy='recursive' max_tokens='256' overlap_tokens='32'
);

INSERT INTO docs (id, title, content) VALUES
  (1, 'Backup and restore runbook', 'Nightly backups run at 02:00 UTC ... ');

SELECT id, title, knn_dist() FROM docs
WHERE knn(chunks, 5, 'how do I rotate the replication certificate');
```

不需要手动下载模型，不需要选择分块器，也不需要维护流水线。一个列选项，就能让过去不可见的文档部分开始出现在结果中。

完整参考：手册中的[分块策略](https://manual.manticoresearch.com/Searching/KNN#Chunking-strategies)和[每篇文档多个向量](https://manual.manticoresearch.com/Searching/KNN#Multiple-vectors-per-document)。问题和 bug 报告请提交到 [GitHub](https://github.com/manticoresoftware/manticoresearch/issues)。
