上一篇的顺序扫描搜索器直接读取文件内容做子串匹配。这个做法有两个明显问题:同一个文件导入两次会出现两条重复结果;一份 GBK 编码的中文文档读进来变成乱码,搜什么都搜不到。

搜索引擎的第一层抽象不是索引,而是文档对象。把文件系统里散落的 Markdown、HTML、纯文本统一成结构化的文档记录,处理好编码、去重和元数据,后面的分词、索引、检索才有干净的输入。

从文件到文档:为什么需要一层抽象

第 01 篇的 SequentialSearcher 直接操作 PathString。这在 fixture 只有 5 个 UTF-8 纯文本文件时没有问题,但很快会遇到以下情况:

  • 同一篇文档以 .md.html 两种格式存在,内容相同,搜索结果出现两条
  • 文件是 GBK 编码,Files.readString() 默认按 UTF-8 读取,正文变成 鎼滅储
  • HTML 文件里的 <nav><footer><script> 内容混入正文,搜"搜索引擎"命中了导航栏里的链接文字
  • 没有稳定的文档标识符,文件改名后被当作新文档

解决方案是定义一个 SearchDocument 记录,所有格式的文件导入后都变成同一种结构。

SearchDocument:统一的文档记录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public record SearchDocument(
String docId,
String source,
String title,
String body,
String rawContent,
Map<String, String> metadata,
String contentHash,
Instant importedAt
) {
public SearchDocument {
Objects.requireNonNull(docId, "docId must not be null");
Objects.requireNonNull(body, "body must not be null");
if (docId.isBlank())
throw new IllegalArgumentException("docId must not be blank");
if (body.isBlank())
throw new IllegalArgumentException("body must not be blank");
}
}

每个字段的职责:

字段 类型 说明
docId String 业务标识符,如 md:a3f8b2html:7c91d0
source String 来源类型:markdownhtmltext
title String 从 H1 或 <title> 提取
body String 纯文本正文,用于索引
rawContent String 原始内容,用于高亮回溯
metadata Map 文件路径、编码、抓取时间等扩展信息
contentHash String SHA-256(归一化后的 body)
importedAt Instant 导入时间戳

body 不允许为空白——一篇没有正文的文档对搜索引擎没有意义。构造器里的校验在导入阶段就拦住坏数据,比在索引阶段发现 NPE 强。

业务 docId 和 Lucene docID 是两回事

这个区分在后续章节会反复出现,这里先说清楚。

业务 docId Lucene docID
类型 String int
生命周期 永久稳定,由导入逻辑生成 段合并后会变
用途 去重、外部引用、API 返回 Lucene 内部文档访问
生成规则 source:contentHash前缀 Lucene 自动分配

Lucene 的 int docID 是段内编号,段合并后重新分配。如果把它当作业务标识返回给用户,用户收藏的链接会在下一次索引合并后失效。正确做法是把 docId 作为 stored field 写入 Lucene,查询结果通过 stored field 取回业务标识。

Markdown 导入:commonmark-java

选 commonmark-java 0.24.0。核心 jar 约 250 KB,零外部依赖,严格遵循 CommonMark 0.31.2 规范。另一个候选 flexmark-java(0.64.8)拆成 60 多个模块,功能远超搜索引擎的需要。

提取纯文本只需要三行核心代码:

1
2
3
4
5
6
7
Parser parser = Parser.builder().build();
TextContentRenderer renderer = TextContentRenderer.builder().build();

public String extractText(String markdown) {
Node document = parser.parse(markdown);
return renderer.render(document);
}

TextContentRenderer 丢弃所有格式标记,只保留文本内容。标题变成普通文本行,链接只保留显示文字,代码块保留代码内容。

标题需要单独提取用于 title 字段。取第一个 H1:

1
2
3
4
5
6
7
8
9
10
11
12
public String extractTitle(String markdown) {
Node document = parser.parse(markdown);
Node node = document.getFirstChild();
while (node != null) {
if (node instanceof Heading h && h.getLevel() == 1) {
Node child = h.getFirstChild();
if (child instanceof Text t) return t.getLiteral();
}
node = node.getNext();
}
return null;
}

Maven 依赖:

1
2
3
4
5
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark</artifactId>
<version>0.24.0</version>
</dependency>

HTML 导入:jsoup

jsoup 1.21.1,MIT 许可,零依赖。

HTML 正文提取的核心问题是去噪。一个典型的网页里,导航栏、侧边栏、页脚、广告的文本量可能超过正文。直接调用 document.body().text() 会把所有文字混在一起。

两层过滤策略:

1
2
3
4
5
6
7
8
9
10
11
public String extractText(String html) {
Document doc = Jsoup.parse(html);

// 第一层:移除已知噪声标签
doc.select("nav, header, footer, aside, script, style, "
+ ".sidebar, .menu, .advertisement").remove();

// 第二层:优先取语义化正文容器
Element main = doc.selectFirst("main, article, [role=main]");
return (main != null) ? main.text() : doc.body().text();
}

先删掉 <nav><script> 等噪声元素,再看页面是否有 <article><main> 标签。有就取里面的内容;没有就回退到 <body> 全文。这比引入过时的 Boilerpipe 库(最后更新 2014 年)实际得多。

标题提取从 <title> 或第一个 <h1> 取:

1
2
3
4
5
6
7
public String extractTitle(String html) {
Document doc = Jsoup.parse(html);
Element h1 = doc.selectFirst("h1");
if (h1 != null) return h1.text();
String title = doc.title();
return title.isEmpty() ? null : title;
}

编码检测:不能假设所有文件都是 UTF-8

Java 的 Files.readString(path) 默认按 UTF-8 解码。如果文件实际是 GBK 编码,中文"搜索"二字会变成 鎼滅储——三个完全无关的汉字。这种现象叫 mojibake(文字化け),原因是用错误的字符集解码字节流。

常见的 mojibake 模式:

原始编码 误用编码 症状
GBK → UTF-8 解码 浣犲ソ ← “你好”
UTF-8 → GBK 解码 鎼滅储 ← “搜索”
UTF-8 → ISO-8859-1 ä½ å¥½ ← “你好”

防御方案是在读取文件时先检测编码。ICU4J 的 CharsetDetector 用统计模型识别字节流的编码,支持 GB18030、Big5、UTF-8 等:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
byte[] raw = Files.readAllBytes(path);
CharsetDetector detector = new CharsetDetector();
detector.setText(raw);
CharsetMatch match = detector.detect();

if (match.getConfidence() >= 50) {
String text = match.getString();
} else {
// 回退:尝试 UTF-8 strict 解码
CharsetDecoder utf8 = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT);
try {
String text = utf8.decode(ByteBuffer.wrap(raw)).toString();
} catch (CharacterCodingException e) {
// UTF-8 失败,回退 GBK(中文语料场景)
String text = new String(raw, Charset.forName("GBK"));
}
}

完整的检测流程:

1
2
3
4
5
6
7
8
9
10
读取原始 bytes
→ 检查 BOM(UTF-8: EF BB BF / UTF-16: FF FE 或 FE FF)
→ 有 BOM → 按 BOM 解码
→ 无 BOM → ICU4J 检测
→ confidence ≥ 50 → 按检测结果解码
→ confidence < 50 → UTF-8 strict 尝试
→ 成功 → 采用 UTF-8
→ 失败 → 回退 GBK
→ 解码后检查 U+FFFD(replacement character)
→ 存在 → 标记 encoding_warning

ICU4J 完整包约 14 MB。只需要编码检测时引入 icu4j-charset

1
2
3
4
5
<dependency>
<groupId>com.ibm.icu</groupId>
<artifactId>icu4j-charset</artifactId>
<version>77.1</version>
</dependency>

精确去重:SHA-256 内容哈希

同一篇文档以不同文件名导入两次,搜索结果不应出现两条。去重的依据不是文件名——文件名随时可能变——而是正文内容。

对归一化后的正文计算 SHA-256:

1
2
3
4
String normalized = body.strip().replaceAll("\\s+", " ");
byte[] hash = MessageDigest.getInstance("SHA-256")
.digest(normalized.getBytes(StandardCharsets.UTF_8));
String contentHash = HexFormat.of().formatHex(hash);

归一化步骤很关键:去掉首尾空白,把连续空白压缩成单个空格。这样同一篇文档即使多了几个换行或缩进不同,哈希值仍然一致。

导入时检查 contentHash 是否已存在:

1
2
3
4
5
6
7
8
9
10
11
12
Set<String> seenHashes = new HashSet<>();

public boolean importDocument(SearchDocument doc) {
if (seenHashes.contains(doc.contentHash())) {
log.info("跳过重复文档: docId={}, hash={}",
doc.docId(), doc.contentHash().substring(0, 12));
return false;
}
seenHashes.add(doc.contentHash());
store(doc);
return true;
}

这里只做精确去重。两篇内容相似但不完全相同的文档(比如同一篇文章的两个版本)不会被合并。近似去重(SimHash、MinHash)留到第 05 篇处理。

把导入器串起来

1
2
3
4
5
6
7
8
9
10
11
12
13
文件路径
→ 读取 bytes
→ 编码检测 + 解码
→ 根据扩展名选择解析器
.md → commonmark-java → 纯文本 + 标题
.html → jsoup → 纯文本 + 标题
.txt → 直接使用,标题取文件名
→ 构造 SearchDocument
docId = source + ":" + contentHash 前 12 位
contentHash = SHA-256(归一化 body)
→ 去重检查
已存在 → 跳过,记日志
不存在 → 存入文档集合

边界情况的处理策略:

情况 处理 理由
body 为空或纯空白 拒绝导入,记 warning 空文档对搜索无意义
编码检测 confidence < 50 尝试 UTF-8,失败回退 GBK 中文语料最常见的两种编码
解码后出现 U+FFFD 导入但标记 encoding_warning 不丢弃整篇文档,但标记质量问题
contentHash 重复 跳过,记 info 幂等导入,不报错
文件不可读 跳过,记 error 不因单个文件中断整批导入

验证:重复导入和坏编码

准备两个测试场景。

场景一:重复导入。同一个 fixture 文件导入两次:

1
2
3
4
5
6
7
8
9
10
11
$ java -jar search.jar --import fixtures/
Imported: md:a3f8b21c9d4e (inverted-index.md)
Imported: md:7c91d0e5f832 (tokenization.md)
...
5 documents imported, 0 duplicates

$ java -jar search.jar --import fixtures/
Skipped duplicate: md:a3f8b21c9d4e (inverted-index.md)
Skipped duplicate: md:7c91d0e5f832 (tokenization.md)
...
0 documents imported, 5 duplicates

场景二:GBK 文件。把一个 fixture 转成 GBK 编码后导入:

1
2
3
4
5
$ iconv -f UTF-8 -t GBK fixtures/tokenization.md > fixtures/tokenization-gbk.md
$ java -jar search.jar --import fixtures/tokenization-gbk.md
Detected encoding: GBK (confidence: 92)
Imported: md:7c91d0e5f832 (tokenization-gbk.md)
Skipped duplicate: content matches existing md:7c91d0e5f832

注意第二个场景:GBK 文件解码后的正文和原 UTF-8 文件相同,contentHash 一致,被去重拦截。编码不同不影响内容哈希——这正是归一化后再哈希的好处。

当前的版本锁定

依赖 版本 用途
commonmark-java 0.24.0 Markdown 解析
jsoup 1.21.1 HTML 解析
ICU4J (icu4j-charset) 77.1 编码检测

加上第 01 篇的 Java 25 + Maven 3.9.16,到目前为止项目只有三个外部依赖,全部是确定版本。

下一篇

文档能干净地导入了,但搜索质量怎么衡量?"感觉变好了"不算。第 03 篇「在改进排序前建立评测基线」会设计 30 个标注查询,手算 nDCG 和 MRR,建立一套能用数字说话的评测机制。


练习:

  1. SearchDocumentbody 校验改成长度上限 100,000 字符。超长文档应该拒绝还是截断?各有什么后果?
  2. 写一个 HTML 文件,其中 <nav> 里包含文字"搜索引擎",<article> 里不包含。验证噪声移除后搜索"搜索引擎"是否还能命中。
  3. 构造一个 UTF-8 和 GBK 编码检测都返回低 confidence 的字节序列(提示:纯 ASCII 内容)。在这种情况下,上面的检测流程会走哪条分支?