# 不用搜索引擎，也能搜索

> 在 Postgres 中构建全文搜索和向量搜索，测量解析、排名、摘要、TOAST 与召回率的代价，并判断什么时候确实需要搜索引擎。

---

LLMS 索引： [llms.txt](/llms.txt)

---

<span id="basics-chapter-23"></span>

给帮助中心加搜索框有两种做法。相同的 50,000 篇文章、相同的[GIN](/43-glossary/#gin)索引、搜索“failed payment”得到相同的 4,206 个匹配项、排名最前的 20 个结果也相同。其中一种不到 5 ms 就能返回，另一种却要 170 ms，因为它索引的是表达式而不是存储列，而且每个匹配项都要再次解析才能排名。

Postgres 中的搜索就是这样：每个部分都很好，但只有测量时才会看到它的代价。大多数产品根本不需要搜索引擎；确实需要的产品也能说清楚原因。

[B-树之外](/18-more-indexes/#a-full-text-gin-search-read-51-pages-not-6671)已经介绍了基础知识：一个[`tsvector`](/43-glossary/#tsvector)列、一个 GIN 索引，以及一个 `@@` 匹配操作。这里我们在一个包含 50,000 篇文章的合成帮助中心上构建文本和向量搜索（实验脚本是 `22-search.sh`）。其中的嵌入向量并非来自任何模型，因此这里的召回率数字用于展示取舍，不是对你的数据的预测。

## 文本会变成词素 {#text-becomes-lexemes}

全文搜索从不把查询与原文直接比较。它比较的是规范化词与规范化词。`to_tsvector` 会解析文档，并保留带位置的**词素**，也就是规范化后的词：

```sql
select to_tsvector('english',
  'The deploys were failing, so we rolled back the deployment');
```

```text
 'back':8 'deploy':2,10 'fail':4 'roll':7
```

“Deploys”和“deployment”都会变成 `deploy`；“the”、“were”、“so”和“we”都会被去掉。`'english'` 参数是一种**文本搜索配置**：它由解析器以及每类词元所使用的词典组成。

### 停用词和词干提取有两面性 {#stop-words-and-stemming-cut-both-ways}

词干提取依靠后缀规则，而不是理解含义：在 `english` 中，“organization”会匹配“organ”。搜索乐队 The Who 时，`english` 会把整个查询当作停用词丢掉。词干提取有利于普通文章，却会伤害名称和代码，因此应为不同内容提供不同配置：文本使用 `english`，标题、标签和错误代码使用只做小写转换的 `simple`。

### 配置是索引的一部分 {#the-configuration-is-part-of-the-index}

始终传入配置：单参数形式 `to_tsvector(body)` 依赖会话设置，因此索引会拒绝它（`functions in index expression must be marked IMMUTABLE`）。

重音符号需要自己的配置。`english` 会把“Zürich”保留为 `zürich`，因此搜索“zurich”什么也找不到。应把 contrib 中的 `unaccent` 词典加入配置副本，而不是把它作为函数调用。我们的配置名是 `app_english`，`to_tsvector('app_english', 'Requests from Zürich and Montréal')` 会得到 `'montreal':5 'request':1 'zurich':3`。

<span id="inside-the-parser-and-why-unaccent-cant-be-indexed" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-23-inside-the-parser-and-why-unaccent-cant-be-indexed" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：解析器内部，以及为什么 unaccent() 不能建立索引
>
> ```sql
> create extension unaccent;
> create text search configuration app_english (copy = english);
> alter text search configuration app_english
>   alter mapping for hword, hword_part, word with unaccent, english_stem;
> ```
>
> `english_stem` 是带有 127 个停用词的 Snowball 词干提取器，因此 `plainto_tsquery('english', 'to be or not to be')` 会返回空结果。`unaccent()` 函数只有 `stable` 属性（它的规则文件可能变化），所以索引会拒绝 `to_tsvector('english', unaccent(body))`，却接受 `to_tsvector('app_english', body)`，因为它信任该配置。你要手动兑现这个承诺：修改 `app_english` 后，旧行会继续保留旧向量，直到你重写它们。至于内置解析器无法拆分成词的日语或中文，见[PGroonga](/16-extensions/#pgroonga-searches-text-the-built-in-parser-cant-split)。
>
> > [!EXAMPLE]- 动手验证 · lab `22-search.sh`
> >
> > ```sql
> > select lab.prove('english stems deploys, failing, rolled, and deployment and drops the stop words',
> >   to_tsvector('english', 'The deploys were failing, so we rolled back the deployment')
> >     = $$'back':8 'deploy':2,10 'fail':4 'roll':7$$::tsvector);
> > select lab.prove('the english stop-word list has 127 words, and a query of only stop words has no lexemes left',
> >   numnode(plainto_tsquery('english', 'to be or not to be')) = 0);
> > select lab.prove('stemming collides: organization matches organ, university matches universe',
> >   to_tsvector('english', 'organization') @@ to_tsquery('english', 'organ')
> >   and to_tsvector('english', 'university') @@ to_tsquery('english', 'universe'));
> > select lab.prove('plain english keeps the accent, so a search for zurich misses Zürich',
> >   not (to_tsvector('english', 'Requests from Zürich') @@ websearch_to_tsquery('english', 'zurich'))
> >   and to_tsvector('app_english', 'Requests from Zürich') @@ websearch_to_tsquery('app_english', 'zurich'));
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/22-search.sh)

## 存储 tsvector；不要索引表达式 {#store-the-tsvector-dont-index-the-expression}

把向量存储在生成列中，再为该列建立索引。直接索引表达式看起来更节省空间，却会让每次搜索都付出代价。

```sql
alter table docs add column search tsvector generated always as (
  setweight(to_tsvector('english', title), 'A') ||
  setweight(to_tsvector('english', body),  'B')) stored;
create index docs_search_idx on docs using gin (search);
```

标题词的权重高于正文词。作为比较，第二张表 `docs_expr` 不保存该列，而是在相同表达式上建立 GIN 索引。这里写成 `stored`：在 PostgreSQL 18 中，生成列默认是虚拟列，而虚拟列不能建立索引（[类型是一份契约](/12-types/#generated-columns-are-virtual-by-default-postgresql-18)）。

| | 堆页 | GIN 索引 | 加载时的 WAL |
|---|---|---|---|
| 表达式索引 | 3,347 | 9,064 KB | 70 MB |
| 存储列 | 6,766 | 9,360 KB | 95 MB |

<span id="tbl-23-1"></span>

**Table 23-1.** 存储 tsvector；不要索引表达式


存储列让堆的大小翻倍：带位置信息的 `tsvector` 平均为 511 字节，而它所来源的正文只有 380 字节。

这就是代价。对匹配项排名时，收益就出现了。存储版本在我们的机器上耗时 4.6 ms；表达式版本耗时 170 ms。两个 GIN 索引都能在不到一毫秒内找到 4,206 个匹配项，但排名需要每个匹配项的 `tsvector`，而表达式版本必须再次解析全部 4,206 个文档，才能为每个匹配项取得这个向量。

把堆和 WAL 的代价在写入时一次付清，而不是每次搜索都让解析器重新工作。

<span id="proving-the-heap-the-wal-and-the-re-parse" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-23-proving-the-heap-the-wal-and-the-re-parse" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：证明堆、WAL 和再次解析的代价
>
> 表达式版本为什么会输：`tsvector` 上的 GIN 索引只存储词素和行指针，除此之外什么也没有，因此它能说哪些行匹配，却不能把向量交给 `ts_rank` 打分。没有列可以读取向量时，排名过程会对每个匹配项的标题和正文再次调用 `to_tsvector`，每次搜索共 4,206 次。两个 GIN 索引的大小大致相同；存储列额外增加的是向量本身，它存在于堆中，也存在于记录每行写入的 WAL 中。
>
> > [!EXAMPLE]- 动手验证 · lab `22-search.sh`
> >
> > ```sql
> > select lab.prove('the stored tsvector roughly doubles the heap (3,347 pages without it, 6,766 with)',
> >   pg_relation_size('docs') / pg_relation_size('docs_expr')::numeric between 1.8 and 2.6);
> > select lab.prove('the average tsvector is bigger than the text it came from',
> >   (select avg(pg_column_size(search)) > avg(pg_column_size(body)) from docs));
> > select lab.prove('loading the table with the stored column writes more WAL',
> >   (select s.wal > e.wal * 1.15 from cost s, cost e where s.what = 'stored' and e.what = 'expression'));
> > select lab.ms(:'rank_stored') as stored_ms, lab.ms(:'rank_expr') as expr_ms \gset
> > select lab.prove('ranking through the expression index is more than 5 times slower: it re-parses every match',
> >   :expr_ms > 5 * :stored_ms);
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/22-search.sh)
>
> 表达式索引只有在你永远不需要排名或高亮时才会胜出。

## websearch_to_tsquery 从不抛出语法错误 {#websearch_to_tsquery-never-raises-a-syntax-error}

永远不要把用户输入的内容直接交给 `to_tsquery`：它要求 `tsquery` 语法，因此 `'failed payment'` 会产生语法错误。`websearch_to_tsquery`（PostgreSQL 11+）接受搜索框语法，从不抛出语法错误：`'"roll back" -flags or rollback'` 会变成 `'roll' <-> 'back' & !'flag' | 'rollback'`，而像 `'payment & ) ( !'` 这样的垃圾输入会变成 `'payment'`。

### 短语需要位置信息 {#phrases-need-positions}

引号会变成 `<->`，也就是“紧跟其后”，然后根据存储的位置进行检查：“roll”和“back”都出现在 7,849 篇文章中，但两词紧挨着作为短语出现的文章只有 4,194 篇。

<span id="stop-words-still-count-as-positions" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-23-stop-words-still-count-as-positions" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：停用词仍然会占用位置
>
> `phraseto_tsquery('english', 'roll back the deploy')` 会产生 `'roll' <-> 'back' <2> 'deploy'`，因此“roll back a deploy”也能匹配。如果你只是想做搜索，这样已经足够；如果你期待精确字符串，就会觉得意外。
>
> > [!EXAMPLE]- 动手验证 · lab `22-search.sh`
> >
> > ```sql
> > do $$ begin
> >   perform to_tsquery('english', 'failed payment');
> >   raise exception 'no error';
> > exception when syntax_error then
> >   if sqlerrm not like 'syntax error in tsquery%' then raise; end if;
> > end $$;
> > select lab.prove('to_tsquery rejects two plain words with a syntax error', true);
> > select lab.prove('garbage input never raises an error',
> >   websearch_to_tsquery('english', 'payment & ) ( !')::text = $$'payment'$$);
> > select lab.prove('phraseto_tsquery counts the dropped stop word as a gap of two',
> >   phraseto_tsquery('english', 'roll back the deploy')::text = $$'roll' <-> 'back' <2> 'deploy'$$
> >   and to_tsvector('english', 'roll back a deploy') @@ phraseto_tsquery('english', 'roll back the deploy'));
> > select lab.prove('the phrase matches far fewer articles than the two words anywhere (about 4,200 against 7,800)',
> >   :phrase between 3800 and 4600 and :anded between 7400 and 8300);
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/22-search.sh)

## 按邻近程度排名，而不是按频率 {#rank-by-proximity-not-frequency}

匹配只有是或否；排名则负责给这些匹配排序。使用 `ts_rank_cd`：它计算**覆盖密度**，也就是查询词彼此靠得有多近，因此短语“failed payment”会胜过相隔数句才出现的两个词。（`ts_rank` 只计算它们出现的频率。）

<span id="three-documents-three-rankings" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-23-three-documents-three-rankings" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：三个文档，三种排名
>
> 对于查询“failed payment”：
>
> ```text
>  id | doc                                  | ts_rank | norm 2 | ts_rank_cd
> ----+--------------------------------------+---------+--------+-----------
>   1 | title "When a payment fails", short  |  0.9990 | 0.1427 |     1.4000
>   2 | "failed payment" 4 times, long body  |  0.9854 | 0.0340 |     1.8111
>   3 | "failed" and "payment" far apart     |  0.0869 | 0.0072 |     0.0333
> ```
>
> `norm 2` 列是 `ts_rank` 使用规范化值 `2` 的结果。
>
> `ts_rank` 略微偏好那篇短文的标题匹配；`ts_rank_cd` 偏好四个相邻命中；两者都会把第三个结果压到后面。默认情况下，标题命中（权重 A）计为正文命中（B）的 2.5 倍。规范化位会把排名得分除以文档某个属性对应的值：`2` 表示除以文档长度，让长文章的得分降到短文章的四分之一。不存在正确的唯一设置，只有一个得到你的用户认可的设置。
>
> > [!EXAMPLE]- 动手验证 · lab `22-search.sh`
> >
> > ```sql
> > select lab.prove('ts_rank_cd puts the article with four adjacent "failed payment"s first',
> >   (select r2.cd > r1.cd from rank_scores r1, rank_scores r2 where r1.id = 1 and r2.id = 2));
> > select lab.prove('dividing by length (normalization 2) cuts the long article to less than a quarter of the short one',
> >   (select r2.rank_len < r1.rank_len / 4 from rank_scores r1, rank_scores r2 where r1.id = 1 and r2.id = 2));
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/22-search.sh)

### 排名会读取每个匹配项 {#ranking-reads-every-match}

`order by rank limit 20` 看起来应该只读取 20 行，但它做不到。只有给每个匹配项都排名之后，才能知道排名最前的 20 个：

```text
 Limit (actual time=8.167..8.171 rows=20.00 loops=1)
   Buffers: shared hit=3283
         ->  Bitmap Heap Scan on docs ... rows=4206.00 ...
               ->  Bitmap Index Scan on docs_search_idx ...
                     Buffers: shared hit=13
```

索引查找只需 13 个页；其余页用于取出全部 4,206 个匹配项，总计仍是 3,283 个页，和没有 `limit` 时相同。（这里的 8 ms 包含 `EXPLAIN ANALYZE` 自身的开销；直接运行时，同样的搜索耗时就是前面引用的 4.6 ms。）“deploy”出现在 19,507 篇文章中，对它排名会读取 97% 的表。GIN 知道哪些行包含某个词素，却不知道它出现了多少次、位于哪里，因此无法先返回最佳结果。这就是内置全文搜索的扩展性上限。

所以要在排名前先缩小范围。在这份语料中，一个租户总共约拥有 50 篇文章，因此租户过滤只留下少量候选，而整个语料库会有 4,206 个候选。给过滤条件建立索引，让规划器可以把它和 GIN 扫描组合起来：在 `account_id` 上建立 B-树以得到 `BitmapAnd`，或者通过 `btree_gin` 扩展建立一个组合 GIN 索引。在多租户应用中，这通常就是完整的修复。

> [!EXAMPLE]- 动手验证 · lab `22-search.sh`
>
> ```sql
> select lab.buffers(:'top20') as top20_pages, lab.buffers(:'all') as all_pages \gset
> select lab.prove('limit 20 reads the same pages as returning all 4,206 matches: ranking needs every one',
>   :top20_pages >= 0.95 * :all_pages and :top20_pages > 3000);
> select lab.prove('deploy is in four articles of ten, and ranking them reads over 90% of the heap',
>   (select count(*) from docs where search @@ websearch_to_tsquery('english', 'deploy')) between 18000 and 21000
>   and lab.buffers($$ select id, ts_rank(search, q) as rank
>      from docs, websearch_to_tsquery('english', 'deploy') q
>      where search @@ q order by rank desc limit 20 $$) > 0.9 * pg_relation_size('docs') / 8192);
> ```
>
> [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/22-search.sh)

## ts_headline 会再次解析文档 {#ts_headline-parses-the-document-again}

最后再计算摘要，而且只为你展示的行计算。`ts_headline` 每次调用都会再次解析整篇原文。显示 20 行的页面，无论计算摘要与否都约需 5 ms，因为规划器会在限制行数之后才计算摘要。为全部 4,206 个匹配项生成摘要约需 150 ms；当 API 或视图为每个匹配项都生成摘要时，这就是它要支付的账单。

> [!NOTE] ts_headline 的输出不是安全 HTML
>
> 文档说明，它不能防御跨站脚本攻击。渲染前先转义结果，或者把 `StartSel` 和 `StopSel` 设置为特殊标记，让模板先转义结果，再把这些标记转换成标签。

<span id="where-the-plan-puts-ts_headline" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-23-where-the-plan-puts-ts_headline" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：ts_headline 在计划中的位置
>
> ```text
>  Limit
>    ->  Result
>          Output: ..., ts_headline('english'::regconfig, docs.body, ...)
>          ->  Sort
>                Sort Key: (ts_rank(docs.search, ...)) DESC
> ```
>
> 一个在排名前计算摘要的物化 CTE 会破坏上述做法。`MaxWords`、`MinWords` 和 `MaxFragments` 决定摘要的形状。
>
> > [!EXAMPLE]- 动手验证 · lab `22-search.sh`
> >
> > ```sql
> > select lab.ms(:'search') as search_ms, lab.ms(:'page') as page_ms, lab.ms(:'every') as every_ms \gset
> > select lab.prove('snippets for every match cost more than 10 times a page of 20 with snippets',
> >   :every_ms > 10 * :page_ms);
> > select lab.prove('the planner computes ts_headline after the sort and limit, only for the 20 rows shown',
> >   (select (p -> 'Plan' ->> 'Node Type') = 'Limit'
> >       and (p -> 'Plan' -> 'Plans' -> 0 ->> 'Node Type') = 'Result'
> >       and (p -> 'Plan' -> 'Plans' -> 0 -> 'Plans' -> 0 ->> 'Node Type') = 'Sort'
> >      from lab.plan(:'page') p));
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/22-search.sh)

## 对大多数应用来说，Postgres 的搜索已经够用 {#for-most-applications-postgres-search-is-enough}

Postgres 搜索的是你自己的内容，写入与搜索处于同一个事务中，使用相同的权限和租户过滤条件，不需要同步。只有在能明确说出缺少什么时，才考虑更强的工具：把相关性当作产品能力（知道某个词在整个语料中有多稀有的[BM25](/43-glossary/#bm25)评分、权重提升、同义词），长篇文本的拼写容错（`pg_trgm` 覆盖[短字符串](/18-more-indexes/#pg_trgm-makes-a-leading-wildcard-indexable)），海量结果集上的分面和计数，或一个字段中包含多种语言。

独立的搜索引擎会带来一条有延迟且会漂移的同步管线、每条权限规则的第二份副本，以及两个系统之间无法共享事务。先从 Postgres 开始。只有当你能展示某个搜索引擎可以回答、而 Postgres 无法回答的查询时，再迁移出去。

## 搜索扩展把 BM25 放进索引 {#search-extensions-put-bm25-in-the-index}

应该像评价搜索引擎一样评价一个能提供搜索引擎式排名的扩展：使用你的查询和语料，而不是看功能表。话虽如此，BM25 已经不再要求你离开 Postgres。较新的扩展会把词频和语料统计信息保存在索引中，因此无需取出每个匹配项，就能返回最好的 k 个结果。截至 2026 年 10 月，有四个扩展值得了解（我们的实验没有运行其中任何一个；以下内容来自各项目自己的文档）：

| 扩展 | 短语、模糊 | 许可证 | 运行位置 |
|---|---|---|---|
| pg_search（ParadeDB） | 是，是 | AGPL-3.0；Enterprise 为商业版 | 自托管、ParadeDB Cloud |
| pg_textsearch（Timescale） | 是，否 | PostgreSQL | 自托管、PostgreSQL 17 和 18 |
| VectorChord-bm25（TensorChord） | 否，否 | AGPL-3.0 或 ELv2 | 自托管 |
| TIN（PlanetScale） | 是，是 | 未发布 | 仅 PlanetScale |

<span id="tbl-23-2"></span>

**Table 23-2.** 搜索扩展把 BM25 放进索引


在测试其中一个之前，先确认你的托管平台提供它、它的索引能否经受故障切换，以及查询语法有多大可移植性（只有 pg_textsearch 保留 `tsquery`）。PlanetScale 的基准测试把 TIN 排在很前面；但这是厂商在测量自己的产品。

<span id="the-four-extensions-one-paragraph-each" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-23-the-four-extensions-one-paragraph-each" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：四个扩展，各用一段介绍
>
> **[pg_search](https://github.com/paradedb/paradedb)**（0.26.0，PostgreSQL 15+）在 Tantivy 上建立日志结构合并索引；Tantivy 是一个采用 Rust 编写的 Lucene 风格库。其文档称开源版不会复制搜索索引，因此搜索只在主库上运行；副本功能只有商业版提供。
>
> **[pg_textsearch](https://github.com/timescale/pg_textsearch)**（1.5.1）基于本章的配置实现 BM25，并按 `order by content <@> 'failed payment'` 排名。其 README 指出，任何共享 BM25 索引都有一个限制：语料统计会计算被行级安全隐藏的行，因此一个租户可以推断出其他租户数据的某些信息（[不要信任任何租户](/33-security/#basics-chapter-33)）。
>
> **[VectorChord-bm25](https://github.com/supervc-stack/VectorChord-bm25)**（0.3.0，2025 年 12 月）对词元计数的稀疏向量排名：没有位置信息，因此不支持短语；按照其 README，它只在英语上测试过。
>
> **[TIN](https://planetscale.com/blog/introducing-tin)**（2026 年 9 月 16 日正式发布）使用行的[`ctid`](/04-storage/#a-line-pointer-lets-a-tuple-move-without-changing-its-address)作为文档 ID，因此倒排列表会按页、再按元组组织成位图进行压缩。我们没有找到已发布的源代码或许可证；[Lead](https://github.com/planetscale/lead)（AGPL-3.0）是一个供开发使用的替代品，自称“有意不适合生产环境”。我们没有复现厂商的基准测试。

## 向量搜索会找到临近的点，不在乎文字是什么 {#a-vector-search-finds-near-points-whatever-the-words}

嵌入模型会把文本转换为浮点数组，并把相似输入放在彼此附近。“找出与这个问题相似的文章”就变成“寻找最近的点”，而不论它们使用什么词。**pgvector**（实验运行 0.8.7）增加了 `vector` 类型和距离运算符：`<=>` 表示余弦距离，这是大多数文本模型需要的距离；`<->` 表示欧几里得（L2）距离，`<#>` 表示负内积，`<+>` 表示 L1 距离。

### 一个 1,536 维向量会存进 TOAST {#a-1536-dimension-vector-lives-in-toast}

一个常见的 1,536 维嵌入向量有 6,152 字节（每个维度 4 字节，再加 8），占页的四分之三，远远超过约 2 KB 的阈值；Postgres 超过这个阈值后会把值移到行外的 TOAST 中（[大值会先压缩，再移出行外](/04-storage/#large-values-are-compressed-then-moved-out-of-the-row)）。存储 2,000 个向量时，表有 15 个只保存 ID 和指针的堆页，另有 2,000 个 TOAST 页。任何跳过向量的操作都很便宜：`count(*)` 读取 15 个页。精确搜索会逐块读取每个向量：12,024 个缓冲区，每个向量约 6 个。

<span id="plain-storage-halfvec-and-bits-where-the-vector-lives" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-23-plain-storage-halfvec-and-bits-where-the-vector-lives" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：普通存储、halfvec 和 bits：向量存在哪里
>
> 把列设置为 `storage plain` 后，2,000 个向量会内联存储，每个占一个堆页；同样的搜索读取 2,003 个缓冲区。只有在行能装进一个页时这才有效（2,100 个维度会被拒绝：“row is too big”）。`halfvec(1536)`（pgvector 0.7+）为 3,080 字节，并使用 1,000 个 TOAST 页；经过 `binary_quantize()` 的 `bit(1536)` 在堆中只占 200 字节，而且表根本不会有 TOAST 表。两者都是近似形式（[halfvec 可以缩小索引，召回率没有可测量的损失](#halfvec-shrinks-the-index-with-no-measurable-recall-loss)会测量它们的召回率）。
>
> > [!EXAMPLE]- 动手验证 · lab `22-search.sh`
> >
> > ```sql
> > select lab.prove('a vector(1536) is 4 x 1,536 + 8 = 6,152 bytes',
> >   (select pg_column_size(l2_normalize(embedding)) = 6152 from wide limit 1));
> > select lab.prove('2,000 vectors: 15 heap pages and one TOAST page per vector',
> >   pg_relation_size('wide') / 8192 < 20
> >   and (select pg_relation_size(reltoastrelid) / 8192 between 1990 and 2010 from pg_class where relname = 'wide'));
> > select lab.prove('counting rows never touches the vectors: under 20 pages',
> >   lab.buffers('select count(*) from wide') < 20);
> > select lab.prove('exact search reads about six buffers per toasted vector, five times the plain layout',
> >   :toasted between 11000 and 13000 and :plain between 1990 and 2100);
> > select lab.prove('a binary-quantized bit(1536) is 200 bytes and stays in the heap: the table has no TOAST table at all',
> >   (select pg_column_size(embedding) = 200 from wide_bit limit 1)
> >   and (select reltoastrelid = 0 from pg_class where relname = 'wide_bit'));
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/22-search.sh)

## 精确搜索是顺序扫描 {#exact-search-is-a-sequential-scan}

不要先去使用近似索引。从这里开始，嵌入向量的维度是 384，存放在单独的 `doc_embeddings` 表中，这样文章列表就不会把它们带进缓存。没有索引时，`order by embedding <=> $1 limit 10` 会扫描全部 10,000 个页，在这里约需 20 ms。它是精确的，不需要调参；对于几万个向量，或单个租户对应的那份数据，速度已经足够快。

## HNSW 会贪心地遍历图，也可能漏掉结果 {#hnsw-walks-a-graph-greedily-and-can-miss}

**HNSW**（Hierarchical Navigable Small World，分层可导航小世界）是一张图。每个向量都会连接到一些临近邻居；其中少数向量还会出现在更稀疏、连接更长的上层，就像地铁线路上的快车站：

```text
  layer 2   A ─────────────────────────── K
            │                             │
  layer 1   A ─────── E ───────── H ───── K
            │         │           │       │
  layer 0   A ─ B ─ C ─ E ─ F ─ G ─ H ─ J ─ K ─ ...   (every vector)
```

搜索从顶层进入，向查询点靠近，再下降一层；它只接触几百个节点，而不是全部节点，因此可能漏掉贪心遍历无法到达的结果。要给构建过程足够的内存和工作进程：默认 64 MB 的 `maintenance_work_mem` 且没有并行工作进程时需要 57 秒，256 MB 和两个工作进程时需要 8 秒。索引为 98 MB，比 78 MB 的表更大。

### ef_search 用延迟换召回率 {#ef_search-trades-latency-for-recall}

查询时有一个设置很重要：`hnsw.ef_search`，它控制候选列表的大小（默认 40）。**Recall@10** 是真实的十个最近邻中最终返回的比例：

| `hnsw.ef_search` | Recall@10 | 每次查询毫秒数（我们的测量） | 缓冲区，单次查询 |
|---|---|---|---|
| 40（默认） | 0.969 | 0.7 | 722 |
| 400 | 0.995 | 4.0 | 3,925 |
| 精确搜索（无索引） | 1.000 | 21 | 10,035 |

<span id="tbl-23-3"></span>

**Table 23-3.** ef_search 用延迟换召回率


在默认设置下，只需十四分之一的页数，就能得到真实邻居的 97%。HNSW 构建过程带有随机性，因此每次构建的召回率会相差几个百分点：在我们的多次运行中，默认设置得到 0.92 到 0.99，ef_search 400 得到 0.98 到完整的 1.0。这就是“近似”的含义，也是你要在自己的数据上测量召回率的原因。对于高风险查询，按查询提高它（`set local hnsw.ef_search = 100`），并将真实查询样本与精确搜索比较，在自己的数据上测量召回率。

<span id="build-settings-and-how-much-recall-moves-between-builds" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-23-build-settings-and-how-much-recall-moves-between-builds" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：构建设置，以及不同构建之间召回率变化多少
>
> 在 64 MB 下，构建会警告 `hnsw graph no longer fits into maintenance_work_mem after 28369 tuples`。我们的计时从串行构建开始（`max_parallel_maintenance_workers = 0`；服务器默认允许两个工作进程）。仅增加内存（256 MB）就把耗时从 57 秒降到 23 秒；仅增加两个并行工作进程则降到 17 秒；两者都增加时降到 8 秒。并行构建会把图保存在共享内存中，因此在 Docker 中应给容器更大的 `/dev/shm`。
>
> 这轮参数测试还测得 10（0.956，340 个缓冲区）、20（0.968）、100（0.986，1,684 个缓冲区）和 200（0.986）。构建不是确定性的：pgvector 会随机选择每个向量的图层级（`setseed` 不能影响它），而并行构建每次插入顺序都不同。在我们的多次运行中，默认设置的召回率范围为 0.923 到 0.992，因此实验检查的是 0.9 或更高，而不是某一次运行的数字。
>
> > [!EXAMPLE]- 动手验证 · lab `22-search.sh`
> >
> > ```bash
> > build=$(sql -c "set client_min_messages = notice" -c "show maintenance_work_mem" \
> >   -c "create index doc_embeddings_hnsw on doc_embeddings using hnsw (embedding vector_cosine_ops)" 2>&1)
> > prove "with 64 MB the build warns: hnsw graph no longer fits into maintenance_work_mem" \
> >   has "hnsw graph no longer fits into maintenance_work_mem" "$build"
> > ```
> >
> > ```sql
> > select lab.prove('the HNSW index (98 MB here) is bigger than the table it indexes',
> >   pg_relation_size('doc_embeddings_hnsw') > pg_relation_size('doc_embeddings'));
> > select lab.prove('at the default ef_search of 40, recall@10 is 0.9 or better',
> >   (select recall >= 0.9 from sweep where ef = 40));
> > select lab.prove('raising ef_search raises recall, to 0.98 or better at 400',
> >   (select max(recall) filter (where ef >= 200) > min(recall) filter (where ef = 10)
> >       and max(recall) filter (where ef = 400) >= 0.98 from sweep));
> > select lab.prove('ef_search 400 reads more than 4 times the pages of ef_search 40',
> >   (select b.buffers > 4 * a.buffers from sweep a, sweep b where a.ef = 40 and b.ef = 400));
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/22-search.sh)

## IVFFlat 的默认一次探测是陷阱 {#ivfflats-default-of-one-probe-is-a-trap}

**IVFFlat** 在构建时把向量聚成 `lists` 个组，并且只搜索距离查询点最近的 `probes` 个组。它的构建耗时不到 2 秒，而 HNSW 要 8 秒；它的默认设置却是陷阱：50 个组且 `ivfflat.probes = 1` 时，recall@10 只有 0.636。5 次探测达到 1.000。应从 `sqrt(lists)` 开始，并在加载数据后构建，因为这些组来自当时已经存在的行。我们的默认选择是 HNSW。

## halfvec 可以缩小索引，召回率没有可测量的损失 {#halfvec-shrinks-the-index-with-no-measurable-recall-loss}

表可以继续保存 4 字节浮点数，而索引保存更小的形式：`using hnsw ((embedding::halfvec(384)) halfvec_cosine_ops)` 把索引从 98 MB 缩小到 56 MB，召回率没有可测量的损失。二值量化每个维度只使用一位，生成了一个 17 MB 的索引，找回真实近邻的比例为 0.303；按完整向量对 400 个候选重新排名后，比例为 0.915（先用位向量取 400 个最近邻，再进行精确重排）。先在自己的嵌入向量上测量。

<span id="re-ranking-binary-candidates" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-23-re-ranking-binary-candidates" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：对二值候选重新排名
>
> ```sql
> select doc_id
> from (select doc_id, embedding
>       from doc_embeddings
>       order by binary_quantize(embedding)::bit(384) <~> binary_quantize($1)
>       limit 400) candidates
> order by embedding <=> $1
> limit 10;
> ```
>
> 位索引是 `using hnsw ((binary_quantize(embedding)::bit(384)) bit_hamming_ops)`，而且 `hnsw.ef_search` 必须至少等于内层查询的行数上限。对 100 个候选重新排名得到 0.747。高斯噪声是单比特的困难案例；真实模型（其中一些就是为此训练的）表现会好得多。在这次构建中，半精度的 0.980 胜过全精度的 0.969：这是建图运气。
>
> > [!EXAMPLE]- 动手验证 · lab `22-search.sh`
> >
> > ```sql
> > select lab.prove('with 50 lists and the default single probe, recall@10 is under 0.8',
> >   (select recall < 0.8 from probes where probes = 1));
> > select lab.prove('the halfvec index is under two thirds the size, the bit index under a fifth',
> >   pg_relation_size('doc_embeddings_half') < 0.67 * pg_relation_size('doc_embeddings_hnsw')
> >   and pg_relation_size('doc_embeddings_bit') < 0.2 * pg_relation_size('doc_embeddings_hnsw'));
> > select lab.prove('halfvec keeps recall above 0.95', :half >= 0.95);
> > select lab.prove('re-ranking 400 bit candidates by the full vectors lifts recall above 0.85',
> >   :rerank400 > 0.85 and :rerank400 > :rerank100 and :rerank100 > :bits);
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/22-search.sh)

## 过滤条件与近似索引不能混用 {#filters-and-approximate-indexes-dont-mix}

使用近似索引时，`where` 会在索引扫描*之后*运行：HNSW 找到 `ef_search` 个最佳候选，Postgres 丢弃不满足过滤条件的候选，剩下的才是答案。`kind = 'faq'` 每十篇文章中匹配一篇。请求十篇：

```text
 Limit (actual time=0.823..0.861 rows=4.00 loops=1)
   ->  Index Scan using doc_embeddings_hnsw on doc_embeddings (actual rows=4.00 ...)
         Filter: (kind = 'faq'::text)
         Rows Removed by Filter: 36
```

结果只有四行，100 次查询平均只返回 3.7 行，有些查询一行也没有。你的应用请求十行，却只拿到四行，而且没有报错，这是最糟糕的一种错误。

对于宽泛过滤条件，打开 pgvector 0.8 的**迭代索引扫描**（`set hnsw.iterative_scan = relaxed_order`），它会持续获取候选，直到得到足够多符合过滤条件的结果：每次都返回十行，但页数超过原来的三倍。对于高选择性的过滤条件，在过滤列上建立 B-树：对于某个租户的 46 篇文章，规划器使用该索引并计算 46 个精确距离，读取 83 个页，不使用 HNSW。每个值一个部分 HNSW 索引，或[分区](/39-partitioning/#basics-chapter-39)，可以覆盖剩余情况。

<span id="iterative-scans-order-and-the-tenant-plan" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-23-iterative-scans-order-and-the-tenant-plan" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：迭代扫描、顺序和租户计划
>
> 使用 `relaxed_order` 时，一次查询读取 2,529 个页，而未开启迭代扫描时读取 708 个页；它会扫描最多 `hnsw.max_scan_tuples`（20,000）个候选。结果可能并不严格按距离排序；`strict_order` 会保持顺序，但召回率会降低。连接表上的过滤条件无法在扫描期间应用，这就是 `account_id` 和 `kind` 会被复制到 `doc_embeddings` 上的原因。
>
> > [!EXAMPLE]- 动手验证 · lab `22-search.sh`
> >
> > ```sql
> > select lab.prove('with a 10% filter and ef_search 40, limit 10 returns about 4 rows on average',
> >   (select avg_rows between 2.5 and 5.5 and min_rows = 0 from filtered where mode = 'off'));
> > select lab.prove('hnsw.iterative_scan = relaxed_order returns all 10 rows for every query',
> >   (select avg_rows = 10 and min_rows = 10 from filtered where mode = 'relaxed_order'));
> > select lab.prove('a tenant filter (46 rows) uses the B-tree and sorts exactly, ignoring HNSW',
> >   (select count(*) = 46 from doc_embeddings where account_id = 42)
> >   and lab.plan($$ select doc_id from doc_embeddings where account_id = 42
> >      order by embedding <=> (select embedding from queries where id = 1) limit 10 $$)::text
> >      like '%doc_embeddings_account_idx%'
> >   and lab.plan($$ select doc_id from doc_embeddings where account_id = 42
> >      order by embedding <=> (select embedding from queries where id = 1) limit 10 $$)::text
> >      not like '%doc_embeddings_hnsw%');
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/22-search.sh)

## 关键词和向量的失败方式不同，所以要融合 {#keywords-and-vectors-fail-differently-so-fuse-them}

全文搜索能精准命中确切词语（错误代码、“SAML”），却会漏掉释义相同的表达：“card declined”在我们的语料中什么也匹配不到。向量能找到这种释义，却会模糊确切词语。

它们的分数没有共同尺度，因此应改为组合排名位置：**倒数排名融合**（RRF）会从每个返回该文档的列表中按 `1 / (60 + rank)` 给它一个分数，再按总和排序。两张列表都找到的文章总会胜出：每张列表返回 50 个结果时，两个至少为 1/110 的分数之和，会超过最好的单个分数 1/61。对于“card declined”，向量一侧会填满全部十个位置。

把查询向量作为参数传入：如果查询向量来自连接所得行中的某一列，距离计算就无法使用 HNSW 索引。

<span id="hybrid-search-as-one-sql-function" class="pgbasics-deep-dive-anchor" data-td-print-local></span><span id="basics-23-hybrid-search-as-one-sql-function" class="pgbasics-deep-dive-anchor"></span>

> [!DETAILS]- 深入阅读：把混合搜索写成一个 SQL 函数
>
> ```sql
> create function hybrid_search(query text, query_embedding vector(384),
>                               k int default 10)
> returns table (id bigint, score numeric, fts_rank bigint, vec_rank bigint)
> language sql stable as $$
>   with fts as (
>     select d.id,
>            row_number() over (order by ts_rank_cd(d.search, q) desc, d.id) as r
>     from docs d, websearch_to_tsquery('english', query) q
>     where d.search @@ q
>     order by r
>     limit 50
>   ), vec as (
>     select e.doc_id as id,
>            row_number() over (order by e.embedding <=> query_embedding,
>                                        e.doc_id) as r
>     from (select doc_id, embedding from doc_embeddings
>           order by embedding <=> query_embedding limit 50) e
>   )
>   select id,
>          round(coalesce(1.0 / (60 + fts.r), 0)
>              + coalesce(1.0 / (60 + vec.r), 0), 4) as score,
>          fts.r, vec.r
>   from fts full join vec using (id)
>   order by score desc, id
>   limit k
> $$;
> ```
>
> 以计费主题的中心作为查询嵌入向量时，前十名会交错来自两个列表。在一次索引构建中，排名第一的结果在文本列表中排第 35、在向量列表中排第 19；另一次构建中，没有文章同时进入两个列表的前 50。HNSW 图每次构建都不同，因此向量列表也不同。常数 60 来自 RRF 论文，该论文称它为 k；我们函数中的 `k` 只是结果数量。
>
> > [!EXAMPLE]- 动手验证 · lab `22-search.sh`
> >
> > ```sql
> > select lab.prove('fusion returns 10 rows from both lists, and any article both lists found ranks above every article only one found',
> >   (select count(*) = 10 and count(fts_rank) > 0 and count(vec_rank) > 0
> >      from hybrid_search('failed payment', (select embedding from query_embeddings where name = 'billing')))
> >   and (select coalesce(min(score) filter (where fts_rank is not null and vec_rank is not null), 1)
> >               > coalesce(max(score) filter (where fts_rank is null or vec_rank is null), 0)
> >          from hybrid_search('failed payment', (select embedding from query_embeddings where name = 'billing'), 100)));
> > select lab.prove('card declined matches no text at all; the vector side still answers',
> >   (select count(*) = 0 from docs where search @@ websearch_to_tsquery('english', 'card declined'))
> >   and (select count(*) = 10 and bool_and(vec_rank is not null)
> >          from hybrid_search('card declined', (select embedding from query_embeddings where name = 'billing'))));
> > select lab.prove('ordering by distance to a vector from a join can''t use the HNSW index',
> >   'Seq Scan' = any (lab.nodes($$ select e.doc_id from doc_embeddings e, query_embeddings q
> >                                  where q.name = 'billing'
> >                                  order by e.embedding <=> q.embedding limit 10 $$)));
> > ```
> >
> > [完整实验脚本](https://www.flagon.io/books/eight-kilobytes/lab/22-search.sh)

### 向量数据库需要一个说得出的理由 {#a-vector-database-needs-a-reason-you-can-name}

把属性说清楚：数亿个向量、繁忙主库无法满足的延迟、迭代扫描读取过多页的过滤搜索。若需求还没达到这些程度，让向量和行放在一起更有优势：一份备份、一套权限模型，以及与文本在同一事务中更新的嵌入向量。

## 本章要点 {#the-short-version}

- 大多数产品根本不需要搜索引擎。只有为了一个说得出的属性才迁移：BM25 相关性、拼写容错、规模化分面。
- 到处传入文本搜索配置。
- 存储 `tsvector`：通过表达式索引排名耗时 170 ms，而存储列只需 4.6 ms。
- 对用户输入的一切内容使用 `websearch_to_tsquery`。
- 排名会读取每个匹配项。先按租户或日期缩小范围。
- 只为展示的行计算 `ts_headline`，并对其进行转义。
- 1,536 维的 `vector` 会存进 TOAST：跳过它不增加开销，但扫描每个向量要访问 6 个缓冲区。
- 先做精确搜索。默认 HNSW 的召回率为 0.92 到 0.99，只需十四分之一的页数。
- 过滤条件在近似扫描之后运行：打开 `hnsw.iterative_scan`，或用 B-树对高选择性过滤后的数据进行精确搜索。

## 延伸阅读 {#further-reading}

- [Full Text Search](https://www.postgresql.org/docs/18/textsearch.html)，PostgreSQL 文档。整个子系统。
- [Controlling Text Search](https://www.postgresql.org/docs/18/textsearch-controls.html)，PostgreSQL 文档。排名、规范化以及 `ts_headline` 的代价和 XSS 警告。
- [ParadeDB architecture](https://www.paradedb.com/docs/concepts/architecture)，ParadeDB 文档。Postgres 内部的 Tantivy 索引，以及哪些内容会复制。
- [pg_textsearch](https://github.com/timescale/pg_textsearch)，源代码仓库。基于 Postgres 配置的 BM25，以及它的限制。
- [Introducing TIN](https://planetscale.com/blog/introducing-tin)，PlanetScale 博客，2026 年 9 月。以 `ctid` 作为文档 ID 的设计。
- [pgvector](https://github.com/pgvector/pgvector)，pgvector 项目。README 就是手册。
- [Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs](https://arxiv.org/abs/1603.09320)，Yu. A. Malkov 和 D. A. Yashunin。HNSW 论文。
- [Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods](http://cormack.uwaterloo.ca/cormacksigir09-rrf.pdf)，Gordon V. Cormack、Charles L. A. Clarke 和 Stefan Büttcher，SIGIR 2009。介绍 RRF 的两页论文。
