从零构建现代搜索引擎(06):中文、英文和代码怎样变成词项
前五篇完成了文档的采集、导入和评测基线。文档以纯文本的形式进入系统,但搜索是以词为单位进行的——查询 “Java 内存模型” 需要把这五个字拆成能和索引匹配的词项。英文有空格做天然分隔,中文没有;版本号 3.14.1 不应该在小数点处断开;NullPointerException 既需要保留完整形式也需要拆成子词。
本篇设计一个分析器(Analyzer),将原始文本转换为可索引的词项流,记录每个词项的位置和偏移量。
分析器的三阶段模型
文本分析拆成三步:
1 | |
Tokenizer 把连续文本切成一个个 token。Token Filter 对 token 做后处理:小写化、停用词移除、同义词展开。每个 token 携带四项信息:
1 | |
position 用于短语查询——"搜索引擎"要求"搜索"和"引擎"在相邻位置。offset 用于高亮——知道词项对应原文哪几个字符,才能在搜索结果里准确加粗。
Lucene 的 Analyzer 还有一个 Character Filter 阶段(HTML 实体还原、全角转半角),教学版本先省略,在需要时加入。
英文分词
按空白和标点切分
最直接的方式:
1 | |
对 “The quick brown fox” 能切出四个词。但碰上技术文档就出问题:
don't→don+t(缩写被拆坏)C++→C(加号被当标点吃掉)3.14→3+14(小数点被当分隔符)user-agent→user+agent(连字符是分隔符还是词内字符?)
UAX #29 Word Break
Unicode 标准附件 #29(UAX #29)定义了通用的断词规则,能正确处理缩写、小数和大部分西文词边界。Java 的 BreakIterator 实现了这套规则:
1 | |
UAX #29 把 don't 保持为一个 token,3.14 也不会在小数点处断开。但它对 CamelCase(NullPointerException)和路径分隔符(java.util.List)没有特殊处理——这些需要额外的 Token Filter。
中文分词
没有空格的语言
中文文本 “乒乓球拍卖完了” 至少有两种合理切分:
- 乒乓球 / 拍卖 / 完 / 了
- 乒乓 / 球拍 / 卖完 / 了
没有外部知识(词典或统计模型),无法判断哪种正确。搜索引擎的分词策略需要在精确率和召回率之间取舍。
字 bigram:零依赖的起点
字 bigram 将连续的两个汉字作为一个 token 产出。“搜索引擎” 产生三个 bigram:搜索、索引、引擎。同时产出每个单字的 unigram 作为兜底。
1 | |
Lucene 的 CJKBigramFilter 就是这个策略。
优点:不依赖任何词典或模型,对任何中文文本都能产出 token。
缺点:"北京大学"产生 bigram “北京”、“京大”、“大学”。搜索"京大"会误命中包含"北京大学"的文档。索引体积大约是单字索引的 2 倍。
词典分词:正向最大匹配
如果有一本词典,可以用正向最大匹配(Forward Maximum Matching,FMM)做切分:从当前位置开始,尝试匹配词典中最长的词,匹配不到就取单字。
1 | |
词典可以用开源资源,如 jieba 分词的词频文件(MIT 许可,约 35 万词条)。
FMM 的局限:它是贪心算法,对歧义句子只能给出一种切分。“乒乓球拍卖完了"会切成"乒乓球/拍卖/完/了”,因为"乒乓球"比"乒乓"更长。但在特定上下文中"球拍/卖完"才是正确切分。更好的歧义消解需要统计语言模型(如 HMM、CRF),不在本篇范围内。
教学建议
本篇默认使用 bigram,同时提供 FMM 对照。第 16 篇优化相关性时引入更好的分词器。两种策略通过统一的 Tokenizer 接口切换,不影响下游的索引和查询代码。
中英混合文本
技术文档中中英混合是常态:“使用 HttpClient 发送 GET 请求”。分词需要识别文本中的语言切换点:
- 扫描字符,按 Unicode 分类将文本分段:CJK 字符段、ASCII 字母数字段、其他
- CJK 段用 bigram 或 FMM 切分
- ASCII 段用 UAX #29 或空白切分
- 标点和空白作为段落分隔符,不产出 token
这种分段策略保证 “使用 HttpClient” 切成 “使用”(bigram)和 “HttpClient”(ASCII token),而不会把 “H” 和 “使” 混在一起。
代码与特殊标识符
技术文档需要对几类标识符做特殊处理:
CamelCase 拆分
NullPointerException 应该同时索引完整形式和拆分后的子词:
1 | |
搜索 NullPointerException 精确命中,搜索 null pointer exception 也能命中。
版本号
3.14.1、v2.0.0-rc1、Java 25 中的数字部分不应在小数点处断开。策略:识别符合版本号模式的字符串(如 \d+(\.\d+)+(-\w+)?),作为整体 token 保留。
错误码
ECONNREFUSED、ERR_CONNECTION_RESET、HTTP 404 这类全大写或带下划线的标识符应保留原始形式。下划线可以同时作为分隔符产出子词(ERR、CONNECTION、RESET)。
大小写折叠
Token filter 中统一转小写:
1 | |
但某些场景需要大小写敏感搜索(精确查找错误码)。解决方案:索引时同时写入两个字段——body(小写化)和 body_exact(原始形式)。查询时根据需求选择字段。
停用词
“的”、“了”、“在”、“the”、“a”、“is” 这类高频低信息量的词称为停用词。移除停用词可以减小索引体积约 20-30%,但会影响短语查询。
现代搜索引擎倾向于保留停用词,让 BM25 的 IDF 权重自然降低其影响。本篇实现停用词 filter 但默认不启用,留到第 09 篇观察 BM25 中停用词的实际权重。
验证分词器
以下场景需要通过分词验证:
| 输入 | 期望 token | 说明 |
|---|---|---|
| “搜索引擎架构” | 搜索, 索引, 引擎, 擎架, 架构 + 单字 | 中文 bigram |
| “Java NullPointerException” | java, nullpointerexception, null, pointer, exception | CamelCase 拆分 |
| “版本 3.14.1” | 版本, 3.14.1 | 版本号不拆分 |
| “HTTP 404 错误” | http, 404, 错误 | 错误码保留 |
| “使用 git commit --amend” | 使用, git, commit, amend | 混合文本 |
对每种输入运行分析器,打印 token 列表(包含 position 和 offset),确认切分符合预期。记录切分失败的样本——这些是后续改进分词器的线索。
当前局限
- bigram 会产生跨词边界的伪 token(如"京大"),影响精确率
- FMM 无法处理歧义,只给出一种切分
- CamelCase 拆分基于正则启发式,对非标准命名可能失败
- 没有同义词处理(“JS” 和 “JavaScript”)——第 16 篇处理
- 没有词干化(英文 “running” → “run”)——对技术文档影响较小,暂不实现
练习
- 对 “乒乓球拍卖完了” 分别用 bigram 和 FMM(给定词典包含"乒乓球"、“球拍”、“拍卖”、“卖完”)切分,比较结果
- 对 “NullPointerException at line 42” 运行完整分析器,验证 position 序列连续
- 构造一个 CamelCase 拆分失败的例子(如 “XMLHTTPRequest”),观察输出,思考改进方案
- 统计 100 篇中文技术文档中 bigram 的去重 token 数量,与单字 unigram 对比,验证约 2 倍膨胀的预期
延伸阅读
- UAX #29 Unicode Text Segmentation: https://unicode.org/reports/tr29/
- Introduction to Information Retrieval, Chapter 2: The term vocabulary and postings lists
- Lucene 源码:
org.apache.lucene.analysis.cjk.CJKBigramFilter






