在技术博客里搜"Java NullPointerException at line 42",搜索框返回了一堆标题带"Java"的文章,没有一篇提到这个异常。换成 Stack Overflow,第一条就是对应的解答。差距在哪里?不在界面,在从文档到查询结果之间的每一层处理。

这个系列要做的事情是:从一个空目录开始,逐步构建一套能抓取站点、索引中英文文档、接受关键词和自然语言查询、返回带摘要和排序的搜索结果的检索系统。不调用 Elasticsearch API,不安装现成搜索服务——先亲手写倒排索引和 BM25,再接入 Lucene,加上向量召回和模型重排序。

最终演示:搜索引擎能做什么

先把终点亮出来。系列完成时,系统应该支撑以下操作:

操作 可观察结果 验收证据
导入本博客已发布文章 + 抓取获准文档站 标题、正文、URL、语言、抓取时间进入统一文档模型 数据清单、抓取日志、去重统计
搜索错误码、中文术语和自然语言问题 精确标识符保留词法优势,语义检索补充同义表达 同一查询下 BM25、向量、混合、重排序四组结果
修改或删除一篇文档 新版本可见,旧段落及旧向量不再返回 更新前后结果、版本号、可见时间
关闭模型服务或重启检索进程 模型超时后返回词法结果;已提交索引可恢复 故障注入记录、降级标记、恢复后查询
打开搜索页面完成查询与翻页 过滤、高亮、稳定翻页、空结果与错误提示 浏览器操作记录及 API 响应

这张表是整个系列的契约。每完成一组章节,回来勾掉对应行。

架构全景

把上面的能力拆成数据流,从左到右画出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
文件 / 获准网页
┌──────────────────────────────────────────────────────────┐
│ 采集与解析 → 编码检测 → 规范化 → 去重 → 文档存储 │
└───────────────────┬──────────────────┬───────────────────┘
│ │
┌───────▼───────┐ ┌───────▼────────┐
│ 分词 → 倒排索引 │ │ 切块 → embedding │
│ (BM25 召回) │ │ → 向量索引 │
└───────┬───────┘ │ (ANN 召回) │
│ └───────┬──────────┘
│ │
┌───────▼──────────────────▼───────┐
│ 查询解析 → BM25 候选 + ANN 候选 │
│ → 文档级去重 → RRF 融合 │
│ → Reranker 精排 │
│ → 摘要、高亮 → 搜索页面 │
└──────────────────────────────────┘

左侧是词法路径:文档经过分词器变成词项,建立倒排索引,查询时用 BM25 算分。右侧是语义路径:文档切块后经 embedding 模型编码为向量,存入 HNSW 索引,查询时做近似最近邻检索。两条路径的候选在融合层合并,最后由 cross-encoder 重排序模型做精排。

这张图里的语义路径在系列前半段不会出现。前 11 篇只走词法路径,从顺序扫描到倒排索引再到 BM25。向量检索从第 18 篇开始引入。模型停机时,系统退回词法结果——这条降级路径在第 21 篇验证。

技术选型

层次 选择 理由
教学内核 Java 手写分词、倒排表、BM25 能观察每个中间对象;完成后冻结,不继续造生产级索引库
工程索引 Apache Lucene 10.5.1 同一进程完成词法与向量实验,不引入分布式服务
运行环境 Java 25 LTS (Temurin) 2025 年 9 月发布的长期支持版本,record、模式匹配等语法可用
文档解析 commonmark-java 0.24.0 + jsoup Markdown 和 HTML 两条导入管道
模型推理 Qwen3-Embedding-0.6B + Qwen3-Reranker-0.6B 0.6B 参数量,CPU 可跑小样本;Apache 2.0 许可
界面 Java HTTP API + 简单 HTML 页面 复用检索核心,不额外引入前端框架

Lucene 10.5.1 是 2026 年 8 月 12 日发布的 bugfix 版本。它继承了 10.5.0 的贝叶斯分数融合(Bayesian score fusion)、10.4.0 的标量量化(scalar quantization)和 10.3.0 的 late-interaction 支持。这些能力会在对应章节逐个实验。

Qwen3-Embedding-0.6B 输出 1024 维向量,最大输入 32768 token,支持中英文。配套的 Qwen3-Reranker-0.6B 是 cross-encoder 架构,输入一对 query-passage 输出相关性分数。两个模型都由通义千问团队发布,权重在 Hugging Face 和 ModelScope 公开。

边界:做什么,不做什么

做的事情:

  • 抓取指定站点白名单内的网页,加上本地 Markdown/HTML 文件
  • 中英文文档的分词、索引、BM25 检索
  • 向量召回、RRF 融合、cross-encoder 重排序
  • 文档更新、删除、崩溃恢复
  • 固定分片、只读副本的分布式查询
  • 搜索 API 和简单 Web 界面
  • 从 30 个查询起步的人工评测,扩展到 100 个查询的开发集/测试集

不做的事情:

  • 通用互联网爬虫(只抓白名单站点)
  • 生产级高可用、自动扩容、跨地域容灾
  • 广告竞价、反作弊、点击日志分析
  • 替代 Elasticsearch/OpenSearch 的现成方案
  • 深度学习模型训练(只做推理)

数据规模从几十篇 fixture 起步,逐步扩到万级。百万级容量作为扩展实验,需要独立机器预算。

一个具体的预期结果

在系列还没写完之前,先用手工推演建立预期。假设 fixture 里有这样三篇文档:

1
2
3
doc1: "Java NullPointerException 常见原因与修复方法"
doc2: "搜索引擎的倒排索引原理"
doc3: "Java 并发编程中的锁机制"

查询 “NullPointerException 修复”,预期排序:

  1. doc1——标题和正文都包含查询词,BM25 得分最高
  2. doc3——包含 “Java” 但不含 “NullPointerException”,部分匹配
  3. doc2——与查询无词项重叠,词法检索不应召回

如果后续加上向量检索,doc2 可能因为"搜索引擎"与"NullPointerException"在语义空间距离远而仍然不被召回,但一篇讲"Java 异常处理最佳实践"的文档即使不包含 “NullPointerException” 这个字符串,也可能被语义路径召回。这就是混合检索的价值。

这种"给定输入写出预期输出"的推演,是本系列每篇验收的基本动作。

先修自测

开始之前,确认以下内容不需要现查:

  • 能写 Java 程序,理解泛型、集合框架、文件 I/O
  • 知道 HTTP 请求/响应的基本结构
  • 用过 Maven 或 Gradle 构建 Java 项目
  • 理解时间复杂度(O(n)、O(log n)、O(n log n) 的含义)

不需要提前掌握的:

  • 信息检索理论(BM25、TF-IDF 在用到时推导)
  • 深度学习和 embedding(第 18 篇再引入)
  • Lucene API(第 15 篇切换时讲解)

Python 只用于模型推理脚本,不要求深度学习训练经验。

系列路线图

全系列 31 篇必修 + 5 篇选修,分五个阶段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
第一部(00-05)  从第一条结果到真实网页
愿景 → 项目搭建 → 文档导入 → 评测基线 → 爬虫 → 正文提取

第二部(06-11) 亲手实现词法检索内核
分词 → 倒排索引 → 布尔查询 → BM25 → 外部排序 → 跳表剪枝

第三部(12-17) 让搜索成为可用的软件
更新恢复 → 摘要高亮 → API 界面 → 接入 Lucene → 相关性优化 → 查询理解

第四部(18-23) 现代混合检索
向量编码 → HNSW → 混合融合 → 重排序 → 向量压缩 → 模型升级

第五部(24-30) 规模、质量与完整交付
性能画像 → 持续采集 → 分片 → 故障恢复 → 访问控制 → 迭代流程 → 最终演示

第一批只推进 00–03 四篇。冻结工具链和基础数据后,完成顺序扫描搜索、统一导入和人工评测基线,然后继续推进。

下一篇

第 01 篇「建立可重复运行的项目」会从一个空目录开始,锁定 Java 25 和 Maven 版本,创建 fixture 数据,实现一个最简单的顺序扫描搜索 CLI。目标是:java -jar search.jar --query "NullPointerException" --data fixtures/ 能返回匹配结果。


练习:

  1. 在上面的三篇 fixture 文档基础上,追加一篇 “Python 异常处理与 traceback 解读”。对查询 “异常处理” 写出你预期的 BM25 排序,说明理由。
  2. 如果用户输入的查询是空字符串,系统应该返回什么?如果查询包含 100 个词呢?写出你认为合理的行为。