上一篇的顺序扫描搜索器直接读取文件内容做子串匹配。这个做法有两个明显问题:同一个文件导入两次会出现两条重复结果;一份 GBK 编码的中文文档读进来变成乱码,搜什么都搜不到。
搜索引擎的第一层抽象不是索引,而是文档对象。把文件系统里散落的 Markdown、HTML、纯文本统一成结构化的文档记录,处理好编码、去重和元数据,后面的分词、索引、检索才有干净的输入。
从文件到文档:为什么需要一层抽象
第 01 篇的 SequentialSearcher 直接操作 Path 和 String。这在 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:a3f8b2 或 html:7c91d0
source
String
来源类型:markdown、html、text
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 { CharsetDecoder utf8 = StandardCharsets.UTF_8.newDecoder() .onMalformedInput(CodingErrorAction.REPORT); try { String text = utf8.decode(ByteBuffer.wrap(raw)).toString(); } catch (CharacterCodingException e) { 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,建立一套能用数字说话的评测机制。
练习:
把 SearchDocument 的 body 校验改成长度上限 100,000 字符。超长文档应该拒绝还是截断?各有什么后果?
写一个 HTML 文件,其中 <nav> 里包含文字"搜索引擎",<article> 里不包含。验证噪声移除后搜索"搜索引擎"是否还能命中。
构造一个 UTF-8 和 GBK 编码检测都返回低 confidence 的字节序列(提示:纯 ASCII 内容)。在这种情况下,上面的检测流程会走哪条分支?