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。

关联