前五篇完成了文档的采集、导入和评测基线。文档以纯文本的形式进入系统,但搜索是以词为单位进行的——查询 “Java 内存模型” 需要把这五个字拆成能和索引匹配的词项。英文有空格做天然分隔,中文没有;版本号 3.14.1 不应该在小数点处断开;NullPointerException 既需要保留完整形式也需要拆成子词。

本篇设计一个分析器(Analyzer),将原始文本转换为可索引的词项流,记录每个词项的位置和偏移量。

分析器的三阶段模型

文本分析拆成三步:

1
原始文本 → Tokenizer → Token Filter → 词项流(Term Stream)

Tokenizer 把连续文本切成一个个 token。Token Filter 对 token 做后处理:小写化、停用词移除、同义词展开。每个 token 携带四项信息:

1
2
3
4
5
6
public record Token(
String term, // 词项文本
int position, // 在词项流中的序号(0-based)
int startOffset, // 在原始文本中的起始字符偏移
int endOffset // 在原始文本中的结束字符偏移
) {}

position 用于短语查询——"搜索引擎"要求"搜索"和"引擎"在相邻位置。offset 用于高亮——知道词项对应原文哪几个字符,才能在搜索结果里准确加粗。

Lucene 的 Analyzer 还有一个 Character Filter 阶段(HTML 实体还原、全角转半角),教学版本先省略,在需要时加入。

英文分词

按空白和标点切分

最直接的方式:

1
String[] tokens = text.split("[\\s\\p{Punct}]+");

对 “The quick brown fox” 能切出四个词。但碰上技术文档就出问题:

  • don'tdon + t(缩写被拆坏)
  • C++C(加号被当标点吃掉)
  • 3.143 + 14(小数点被当分隔符)
  • user-agentuser + agent(连字符是分隔符还是词内字符?)

UAX #29 Word Break

Unicode 标准附件 #29(UAX #29)定义了通用的断词规则,能正确处理缩写、小数和大部分西文词边界。Java 的 BreakIterator 实现了这套规则:

1
2
3
4
5
6
7
8
9
10
11
12
BreakIterator it = BreakIterator.getWordInstance(Locale.ENGLISH);
it.setText(text);
List<Token> tokens = new ArrayList<>();
int start = it.first();
int pos = 0;
for (int end = it.next(); end != BreakIterator.DONE;
start = end, end = it.next()) {
String word = text.substring(start, end);
if (word.isBlank()) continue;
if (!Character.isLetterOrDigit(word.codePointAt(0))) continue;
tokens.add(new Token(word, pos++, start, end));
}

UAX #29 把 don't 保持为一个 token,3.14 也不会在小数点处断开。但它对 CamelCase(NullPointerException)和路径分隔符(java.util.List)没有特殊处理——这些需要额外的 Token Filter。

中文分词

没有空格的语言

中文文本 “乒乓球拍卖完了” 至少有两种合理切分:

  • 乒乓球 / 拍卖 / 完 / 了
  • 乒乓 / 球拍 / 卖完 / 了

没有外部知识(词典或统计模型),无法判断哪种正确。搜索引擎的分词策略需要在精确率和召回率之间取舍。

字 bigram:零依赖的起点

字 bigram 将连续的两个汉字作为一个 token 产出。“搜索引擎” 产生三个 bigram:搜索、索引、引擎。同时产出每个单字的 unigram 作为兜底。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
List<Token> tokenizeCjk(String text, int baseOffset) {
List<Token> tokens = new ArrayList<>();
int pos = 0;
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
if (!isCjk(c)) continue;

// unigram
tokens.add(new Token(
String.valueOf(c), pos, baseOffset + i,
baseOffset + i + 1));

// bigram
if (i + 1 < text.length() && isCjk(text.charAt(i + 1))) {
tokens.add(new Token(
text.substring(i, i + 2), pos,
baseOffset + i, baseOffset + i + 2));
}
pos++;
}
return tokens;
}

static boolean isCjk(char c) {
Character.UnicodeBlock block = Character.UnicodeBlock.of(c);
return block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A;
}

Lucene 的 CJKBigramFilter 就是这个策略。

优点:不依赖任何词典或模型,对任何中文文本都能产出 token。

缺点:"北京大学"产生 bigram “北京”、“京大”、“大学”。搜索"京大"会误命中包含"北京大学"的文档。索引体积大约是单字索引的 2 倍。

词典分词:正向最大匹配

如果有一本词典,可以用正向最大匹配(Forward Maximum Matching,FMM)做切分:从当前位置开始,尝试匹配词典中最长的词,匹配不到就取单字。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
List<String> fmm(String text, Set<String> dict, int maxWordLen) {
List<String> result = new ArrayList<>();
int i = 0;
while (i < text.length()) {
String matched = null;
for (int len = Math.min(maxWordLen, text.length() - i);
len > 0; len--) {
String candidate = text.substring(i, i + len);
if (dict.contains(candidate)) {
matched = candidate;
break;
}
}
if (matched == null) matched = text.substring(i, i + 1);
result.add(matched);
i += matched.length();
}
return result;
}

词典可以用开源资源,如 jieba 分词的词频文件(MIT 许可,约 35 万词条)。

FMM 的局限:它是贪心算法,对歧义句子只能给出一种切分。“乒乓球拍卖完了"会切成"乒乓球/拍卖/完/了”,因为"乒乓球"比"乒乓"更长。但在特定上下文中"球拍/卖完"才是正确切分。更好的歧义消解需要统计语言模型(如 HMM、CRF),不在本篇范围内。

教学建议

本篇默认使用 bigram,同时提供 FMM 对照。第 16 篇优化相关性时引入更好的分词器。两种策略通过统一的 Tokenizer 接口切换,不影响下游的索引和查询代码。

中英混合文本

技术文档中中英混合是常态:“使用 HttpClient 发送 GET 请求”。分词需要识别文本中的语言切换点:

  1. 扫描字符,按 Unicode 分类将文本分段:CJK 字符段、ASCII 字母数字段、其他
  2. CJK 段用 bigram 或 FMM 切分
  3. ASCII 段用 UAX #29 或空白切分
  4. 标点和空白作为段落分隔符,不产出 token

这种分段策略保证 “使用 HttpClient” 切成 “使用”(bigram)和 “HttpClient”(ASCII token),而不会把 “H” 和 “使” 混在一起。

代码与特殊标识符

技术文档需要对几类标识符做特殊处理:

CamelCase 拆分

NullPointerException 应该同时索引完整形式和拆分后的子词:

1
2
3
4
5
6
7
8
9
10
List<String> splitCamelCase(String s) {
List<String> parts = new ArrayList<>();
parts.add(s.toLowerCase()); // 完整形式
Matcher m = Pattern.compile(
"[A-Z]?[a-z]+|[A-Z]+(?=[A-Z][a-z]|$)|[0-9]+").matcher(s);
while (m.find()) {
parts.add(m.group().toLowerCase());
}
return parts;
}

搜索 NullPointerException 精确命中,搜索 null pointer exception 也能命中。

版本号

3.14.1v2.0.0-rc1Java 25 中的数字部分不应在小数点处断开。策略:识别符合版本号模式的字符串(如 \d+(\.\d+)+(-\w+)?),作为整体 token 保留。

错误码

ECONNREFUSEDERR_CONNECTION_RESETHTTP 404 这类全大写或带下划线的标识符应保留原始形式。下划线可以同时作为分隔符产出子词(ERRCONNECTIONRESET)。

大小写折叠

Token filter 中统一转小写:

1
2
3
4
5
token = new Token(
token.term().toLowerCase(),
token.position(),
token.startOffset(),
token.endOffset());

但某些场景需要大小写敏感搜索(精确查找错误码)。解决方案:索引时同时写入两个字段——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”)——对技术文档影响较小,暂不实现

练习

  1. 对 “乒乓球拍卖完了” 分别用 bigram 和 FMM(给定词典包含"乒乓球"、“球拍”、“拍卖”、“卖完”)切分,比较结果
  2. 对 “NullPointerException at line 42” 运行完整分析器,验证 position 序列连续
  3. 构造一个 CamelCase 拆分失败的例子(如 “XMLHTTPRequest”),观察输出,思考改进方案
  4. 统计 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