# 模糊匹配与拼写容错

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

---

全文检索把不同词形归一到 lexeme，却不会自动把错拼词改成正确词。
`pg_trgm` 从另一个角度补召回：不先理解语言，而是比较字符三元组。

这使它很适合人名、标题、标识符和拼写容错，也意味着它可能找到“长得像”
但业务上完全不相关的字符串。

## 15.3.1 `pg_trgm` 相似度与距离 {#item-15-3-1}

### 三元组如何形成

trigram 是三个连续字符。`pg_trgm` 忽略非单词字符；对每个词，左侧补两个
空格，右侧补一个空格。官方例子中 `cat` 产生：

```text
"  c", " ca", "cat", "at "
```

两个字符串共享的 trigram 越多，`similarity(a, b)` 越高。它的取值在
0–1，距离运算符则是：

```text
a <-> b = 1 - similarity(a, b)
```

这不是 Levenshtein edit distance。字符插入、删除或换位会同时影响周围
多个 trigram；短字符串可用 trigram 更少，一个字符的影响会更大。

查看：

```sql
SELECT shop_ch14.show_trgm('wireless');

SELECT
  shop_ch14.similarity(
    'wireless headphones',
    'wireles hedphones'
  );
```

本章扩展装在 `shop_ch14`，所以函数和 operator class 都全限定。普通安装
可能在 `public`；不要假设 `search_path`。

### `similarity`、`word_similarity` 与 strict 版本

三个函数回答不同问题：

| 函数 | 比较范围 |
|---|---|
| `similarity(a,b)` | 两个完整字符串的 trigram 集合 |
| `word_similarity(a,b)` | `a` 与 `b` 中任一连续 trigram extent |
| `strict_word_similarity(a,b)` | extent 还必须落在词边界 |

例如 query 是一个短词，title 是多个词时，完整字符串 similarity 会被 title
额外字符稀释；`word_similarity(query, title)` 更像是在 title 中寻找最相似
局部。strict 版本适合不希望跨词边界拼接的场景。

参数顺序很重要。官方把 `word_similarity(first, second)` 描述为 first 的
trigram 集与 second 某一连续 extent 的最大相似。对称函数 `similarity`
可以交换，`word_similarity` 的语义不要想当然地交换。

本章用：

```sql
greatest(
  shop_ch14.similarity(lower(p.title), lower(q.raw_query)),
  shop_ch14.word_similarity(
    lower(q.raw_query),
    lower(p.title)
  )
) AS score
```

这样同时保留整串与局部词候选。`lower` 使表达式与索引表达式一致；官方文档
还说明默认构建中的 trigram similarity 本身不区分大小写，但显式规范化能
让应用合同与表达式索引更清楚。

参见
[`pg_trgm` Functions and Operators](https://www.postgresql.org/docs/18/pgtrgm.html)。

### 函数分数、布尔门槛与距离排序

`pg_trgm` 提供三组布尔操作符：

```text
%      uses pg_trgm.similarity_threshold
<%     uses pg_trgm.word_similarity_threshold
<<%    uses pg_trgm.strict_word_similarity_threshold
```

默认门槛在本章 PostgreSQL 18 文档中分别为 0.3、0.6、0.5。它们是 session
GUC，可以 `SET LOCAL`，不应靠某个连接之前遗留的值：

```sql
BEGIN;
SET LOCAL pg_trgm.similarity_threshold = 0.35;

SELECT product_id, title
FROM shop_ch15.product_search
WHERE lower(title)
      OPERATOR(shop_ch14.%)
      lower('wireles hedphones')
ORDER BY
  shop_ch14.similarity(
    lower(title),
    lower('wireles hedphones')
  ) DESC,
  product_id;
COMMIT;
```

门槛控制 candidate set，函数分数控制 set 内次序。若只写：

```sql
ORDER BY similarity(...) DESC
LIMIT 10
```

就会永远返回十行，即使后几行分数是 0；也不一定能利用 GIN 做候选过滤。
这是“Top-K 必有结果”最常见的误判。

本章 `fuzzy_ranking` 故意对过滤后的全部小集合排名，不设阈值。它让评估
框架看到低分尾部，也展示为何生产必须选择门槛、最小结果可信度或 no-result
行为。

### 索引操作类决定可用路径

本章：

```sql
CREATE INDEX product_search_title_trgm_idx
ON shop_ch15.product_search
USING gin (
  lower(title) shop_ch14.gin_trgm_ops
);
```

强制 probe：

```sql
SET enable_seqscan = off;

EXPLAIN (COSTS OFF)
SELECT product_id
FROM shop_ch15.product_search
WHERE lower(title)
      OPERATOR(shop_ch14.%)
      lower('wireles hedphones');
```

得到：

```text
Bitmap Index Scan on product_search_title_trgm_idx
```

目录同时验证：

```text
access_method = gin
operator_class = shop_ch14.gin_trgm_ops
valid/ready/live = true
```

PostgreSQL 官方 `pg_trgm` 提供 GiST 与 GIN opclass。两者都支持相似操作符，
也支持 trigram 可提取的 `LIKE`、`ILIKE`、正则与等号查询；普通等值查询
通常还是 B-tree 更合适。

若需求是：

```sql
ORDER BY title <-> :query
LIMIT K
```

GiST 能提供 KNN distance 路径；官方文档明确指出某些 distance Top-N 形式
能高效使用 GiST，而不能用 GIN 直接完成同样的有序扫描。选择前先写清楚是
“按门槛过滤大量候选”还是“按距离取最近 K 个”。

## 15.3.2 前缀、包含、拼写错误与候选召回 {#item-15-3-2}

### 先按意图选算子

| 用户意图 | 首选起点 |
|---|---|
| SKU 完全相等 | B-tree `=` |
| 规范化前缀补全 | B-tree/pattern opclass 或专门前缀设计 |
| 任意位置包含 | trigram `LIKE '%term%'` |
| 错拼容忍 | `%` / word similarity + threshold |
| 词形、布尔词、短语 | FTS |
| 模型表达的语义 | vector |

能被 trigram 索引支持，不等于它是所有文本谓词的首选。用 trigram 做每一次
等值查询，通常比普通唯一/B-tree 索引更大、更贵、语义也更弱。

### 短输入是天然边界

trigram 需要可提取的三字符片段。官方文档提醒：`LIKE` 或正则模式若没有
可提取 trigram，会退化为 full-index scan。对一两个字符的自动补全：

- trigram 区分力低；
- 候选可能巨大；
- 门槛对短词异常敏感；
- 用户每敲一个字符就查询会放大负载。

常见策略：

```text
length < 3       -> no fuzzy query / curated prefix path
length 3..N      -> prefix or stricter threshold
longer typo text -> trigram candidate generation
```

长度规则要按语言与规范化后的 token 定义，不要只按 UTF-8 byte 数。

### 规范化必须与索引表达式相同

本章索引的是：

```sql
lower(title)
```

所以 predicate 也写 `lower(title)`。生产可能还要处理：

- Unicode normalization；
- accent folding；
- 标点与空白；
- 全角/半角；
- SKU 中应保留的连字符；
- locale/collation；
- 同义缩写。

若规范化函数不 immutable，或查询表达式和索引表达式不同，表达式索引可能
无法使用。更重要的是：过度规范化会把本来不同的业务标识合并。先把规则做成
测试向量，再决定存储列或表达式索引。

### 门槛不是一次拍脑袋

对每个 query，按 score 取候选并与标注比较：

```sql
SELECT
  q.query_id,
  p.product_id,
  greatest(
    shop_ch14.similarity(lower(p.title), lower(q.raw_query)),
    shop_ch14.word_similarity(lower(q.raw_query), lower(p.title))
  ) AS score,
  j.grade
FROM shop_ch15.eval_query AS q
JOIN shop_ch15.product_search AS p
  ON p.active
 AND p.category = q.category_filter
LEFT JOIN shop_ch15.relevance_judgment AS j
  USING (query_id, product_id)
ORDER BY q.query_id, score DESC, p.product_id;
```

再画出或列出不同 threshold 下：

```text
candidate count
precision/recall
latency
index/heap buffers
no-result rate
```

门槛可能按字段、查询长度或语言分层，但每多一个规则就多一个需要版本化和
回归的参数。不要在应用各处散落 `0.2`、`0.3`、`0.45`。

### 召回池与展示结果要分开

模糊匹配很适合 **召回候选**，不一定适合最终排序：

```text
typo query
  -> trigram top-N candidate ids
  -> exact filters
  -> FTS/vector/business features
  -> fusion or re-rank
  -> final K
```

如果 title 很短、候选同质，trigram 分数可以直接做主要排序；如果 document
很长、字段复杂或业务相关性与字面差别大，它更适合作为一项 feature。

本章 `q04 coffee bean grinder` 的模糊前三名包含商品 14（coffee scale），
却漏掉标注相关的 espresso machine，说明“字符串看起来像”会压过工作流上
相关但字面不同的对象。

## 15.3.3 与全文检索的互补和重复 {#item-15-3-3}

### 用失败矩阵判断互补

本章观察：

| query | FTS matches | fuzzy top-3 relevant | 解释 |
|---|---:|---:|---|
| q01 exact words | 1 | 3 | FTS 精确，fuzzy 扩展同类 |
| q02 two typos | 0 | 3 | trigram 补错拼 |
| q03 descriptive phrase | 1 | 3 | 描述命中与标题相似都可工作 |
| q04 grinder | 1 | 2 | fuzzy 被字面相似干扰 |
| q05 espresso task | 1 | 3 | 两路互补 |
| q06 trail hydration | 2 | 2 | 两路都漏一个字面较远对象 |
| q07 technical typos | 0 | 3 | trigram 补技术词错拼 |
| q08 exact long-tail | 1 | 3 | FTS 精确，fuzzy 给邻近书籍 |

这比“FTS + trigram 效果更好”更有用。它指出：

- 哪些查询需要 fallback；
- 哪些查询适合 union；
- 哪些弱候选可能污染最终结果；
- 下一批标注应该补什么失败类型。

### 不要做脆弱的二选一 fallback

一种常见实现：

```text
if FTS has results:
    use FTS only
else:
    use trigram
```

它简单，但“FTS 有一条结果”不代表召回充分。q06 的 FTS 有两条，却漏掉
第三条相关对象；fallback 不会启动。

更稳健的做法是并行产生有限候选：

```sql
lexical top N
UNION ALL
fuzzy top N
```

然后保留 source/rank，去重并融合。这样能看到一条对象由几路支持，也能在
延迟预算不足时按证据关闭某一路。

### 也不要无条件扩大候选

多一路候选会增加：

- index/heap 读取；
- SQL 排序与去重；
- 重排器输入；
- 解释复杂度；
- 弱信号把强结果挤下去的机会。

本章 RRF 的 q02 就是反例：纯 fuzzy 把最理想商品排第一；vector 与 lexical
信息加入后，混合把商品 3 排到第一，NDCG 下降。

候选源的采用条件应写成：

```text
incremental recall gain
vs latency/resource cost
vs ranking degradation
on a frozen and a fresh evaluation set
```

### 可解释证据

给每个最终结果至少保留：

```json
{
  "product_id": 3,
  "sources": {
    "fuzzy": {"rank": 1, "score": 0.7},
    "vector": {"rank": 2, "distance": 0.06}
  },
  "fusion": {"method": "rrf", "k": 60, "rank": 1}
}
```

不必把内部数值全部暴露给最终用户，但调试与审核必须能重建。

### 一条可操作的路由原则

先不要设计“智能路由器”。以简单证据开始：

```text
exact identifier detected -> exact path first
normal natural-language query -> FTS + bounded vector
likely typo / sparse FTS -> add trigram candidates
very short query -> avoid broad fuzzy scan
authorization filters -> always mandatory
```

每条规则都需要 query segment 指标，最终也可能被统一并行候选替代。路由器
本身是一个模型；如果没有独立评估，它只是在隐藏失败。

### 本节验收

本章同时固定两类证据：

```text
behavior:
  fuzzy quality and per-query ranks

mechanics:
  GIN operator class and forced bitmap index plan
```

行为证据回答“结果如何”；机制证据回答“数据库能否走这条路径”。二者不能
相互替代。

---

[上一节：PostgreSQL 全文检索](../02/) · [返回本章目录](../) · [下一节：可复现的向量检索](../04/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
