45.1 倒排索引与分词
一句话
倒排索引把“文档 → 词”翻成“词 → 文档列表”,让“哪些文档包含这个词”从 O(全库) 变成 O(词表);而分词决定词表长什么样——分错了,索引再快也搜不对。
正排 vs 倒排
| 正排(文档 → 词) | 倒排(词 → 文档) | |
|---|---|---|
| 查询“含某词的文档” | 逐文档扫描,O(N) | 查词典,一次定位,O(1)+列表长度 |
| 查询“某文档的词” | O(1) | 需要额外结构(正排字段) |
| 存储 | 文档本身 | 词典 + 倒排列表(docID、词频、位置) |
| 适合 | 按 ID 取详情 | 全文检索、多条件过滤 |
一条倒排列表里通常存三样东西:
| 内容 | 用途 |
|---|---|
| docID(压缩后的整数) | 找文档;差值编码 + varint 压缩(44.8 的邻近思想) |
| 词频 tf | 相关性打分(45.2) |
| 位置 positions | 短语查询("手机 维修"必须相邻) |
分词:中文的第一道坎
| 方案 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 单字切分(unigram) | 每个汉字一个 term | 简单、召回高、无需词典 | 词典巨大、精度低("中国/国人"混) |
| 词典分词(如 IK/jieba) | 按词表切 | 精度高、term 少 | 新词/专有名词要维护词典 |
| 二元切分(bigram) | 相邻两字一个 term | 召回高、无需词典 | 索引膨胀(约为单字的 2 倍) |
| 混合 | 词典 + 单字兜底 | 平衡 | 实现复杂 |
package main
import (
"fmt"
"sort"
"strings"
)
var docs = []string{
"手机维修换屏幕",
"手机屏幕保护膜",
"笔记本电脑维修",
"屏幕清洁剂手机适用",
"手机电池更换",
}
// 单字切分:中文按 rune,英文按空格
func unigram(s string) []string {
var out []string
for _, r := range s {
if r == ' ' {
continue
}
out = append(out, string(r))
}
return out
}
// 二元切分:相邻两字
func bigram(s string) []string {
rs := []rune(strings.ReplaceAll(s, " ", ""))
var out []string
for i := 0; i+1 < len(rs); i++ {
out = append(out, string(rs[i:i+2]))
}
return out
}
// 极简“词典分词”:用固定词表做最长匹配(演示用,不是工业级)
var dict = []string{"手机", "维修", "屏幕", "保护膜", "笔记本", "电脑", "清洁剂", "电池", "更换", "适用"}
func dictTokenize(s string) []string {
rs := []rune(strings.ReplaceAll(s, " ", ""))
var out []string
for i := 0; i < len(rs); {
matched := ""
for _, w := range dict {
wr := []rune(w)
if i+len(wr) <= len(rs) && string(rs[i:i+len(wr)]) == w && len(wr) > len([]rune(matched)) {
matched = w
}
}
if matched == "" {
out = append(out, string(rs[i]))
i++
continue
}
out = append(out, matched)
i += len([]rune(matched))
}
return out
}
func buildIndex(docs []string, tok func(string) []string) map[string][]int {
idx := map[string][]int{}
for i, d := range docs {
seen := map[string]bool{}
for _, t := range tok(d) {
if seen[t] {
continue
}
seen[t] = true
idx[t] = append(idx[t], i)
}
}
return idx
}
func search(idx map[string][]int, query string, tok func(string) []string) []int {
terms := tok(query)
if len(terms) == 0 {
return nil
}
count := map[int]int{}
for _, t := range terms {
for _, d := range idx[t] {
count[d]++
}
}
var out []int
for d, c := range count {
if c == len(terms) { // 所有词都要命中(AND)
out = append(out, d)
}
}
sort.Ints(out)
return out
}
func main() {
for _, tc := range []struct {
name string
tok func(string) []string
}{{"单字", unigram}, {"二元", bigram}, {"词典", dictTokenize}} {
idx := buildIndex(docs, tc.tok)
postings := 0
for _, v := range idx {
postings += len(v)
}
fmt.Printf("%-6s 词表 %-4d 个 term,倒排项 %-4d 条\n", tc.name, len(idx), postings)
}
fmt.Println()
q := "手机屏幕"
for _, tc := range []struct {
name string
tok func(string) []string
}{{"单字", unigram}, {"二元", bigram}, {"词典", dictTokenize}} {
idx := buildIndex(docs, tc.tok)
hits := search(idx, q, tc.tok)
fmt.Printf("查询 %q(%s分词)命中:", q, tc.name)
for _, h := range hits {
fmt.Printf("[%d]%s ", h, docs[h])
}
fmt.Println()
}
}
本机实测(macOS,Apple M1,Go 1.27):
单字 词表 22 个 term,倒排项 36 条
二元 词表 25 个 term,倒排项 31 条
词典 词表 11 个 term,倒排项 17 条
查询 "手机屏幕"(单字分词)命中:[0]手机维修换屏幕 [1]手机屏幕保护膜 [3]屏幕清洁剂手机适用
查询 "手机屏幕"(二元分词)命中:[1]手机屏幕保护膜
查询 "手机屏幕"(词典分词)命中:[0]手机维修换屏幕 [1]手机屏幕保护膜 [3]屏幕清洁剂手机适用
读数:
| 切分 | 词表(term 数) | 倒排项 | 查询“手机屏幕”命中 |
|---|---|---|---|
| 单字 | 22 | 36 | 3 条(手/机/屏/幕 都要出现) |
| 二元 | 25 | 31 | 1 条(要求“手机”“屏幕”都作为连续二元出现) |
| 词典 | 11 | 17 | 3 条(手机 + 屏幕,与单字在本例中结果相同) |
三个读数值得注意:① 词典分词的 term 最少(11 vs 22/25),索引最省;② 二元的 term 反而最多,但精度最高(只命中真正连续的“手机屏幕”);③ 本例语料太小,单字与词典的命中数碰巧一样——语料一大,单字会把“手表机会”“屏幕幕布”这类噪声全捞进来。
🔑 分词的取舍是召回 vs 精度 vs 索引成本的三角:单字召回最高、词典精度最高、二元是折中。工业上常见组合是“词典分词建索引 + 单字兜底召回”,查询时用
best_fields或cross_fields组合多字段。
短语查询与位置信息
| 查询 | 需要什么 | 没有位置信息会怎样 |
|---|---|---|
"手机 维修"(短语) | 倒排列表里的 positions | 只能判断“两个词都出现”,不能判断相邻 |
手机 AND 维修 | 只要 docID | 无所谓 |
| 高亮 | positions + 原文 | 无法定位要高亮的片段 |
反模式
| 反模式 | 后果 |
|---|---|
| 中文用默认的 standard 分词器 | 按单字切,精度低、词表膨胀 |
| 索引时用 A 分词器、查询时用 B | 词对不上,查不到(这是最隐蔽的“搜不到”) |
| 停用词表过大 | “的/了”删了没事,但把“不/无”删了会语义反转 |
| 不做同义词/别名 | 用户搜“手机”找不到标为“移动电话”的商品 |
| 索引所有字段(含大文本) | 索引膨胀、写入放大(45.3) |
| 用倒排索引做聚合统计 | 倒排适合“找文档”,数值聚合要走 doc_values/列存 |
自检
Q1 为什么“索引和查询用不同的分词器”会导致搜不到,而且很难排查?
▸ 答案:因为倒排索引的 key 是“索引时分词产生的 term”。如果索引时把“手机屏幕”切成 手机/屏幕,查询时却切成 手/机/屏/幕,查询的 term 在词典里根本不存在,结果就是 0 命中——但索引看起来是好的、数据也在。排查时要同时看“索引的 mapping/analyzer”与“查询的 analyzer”,用 _analyze 接口分别验证两侧的切分结果。
Q2 单字切分召回高,为什么很多中文搜索仍用词典分词?
▸ 答案:因为单字的精度太低:搜“手机”会命中“手”“机”相邻但语义无关的文档(“手表机会”),且词表膨胀导致索引与查询都变慢。词典分词把“手机”当一个 term,精度高、索引小。代价是新词(品牌名、网络词)不在词典里就搜不到,所以要配合“单字兜底 + 同义词 + 新词发现”。
Q3 短语查询为什么必须存位置(positions)?
▸ 答案:因为倒排列表只记录“这个文档包含这个词”,不记录“出现在第几个位置”。短语 “手机 维修” 要求两个词相邻且顺序一致,只有存了每个词在文档中的位置,才能在查询时做“位置相邻”的交集运算。代价是索引变大(位置列表通常比 docID 列表大几倍),所以不需要短语查询的字段可以关掉 positions。