从零构建现代搜索引擎(00):最终要做出怎样的搜索引擎
在技术博客里搜"Java NullPointerException at line 42",搜索框返回了一堆标题带"Java"的文章,没有一篇提到这个异常。换成 Stack Overflow,第一条就是对应的解答。差距在哪里?不在界面,在从文档到查询结果之间的每一层处理。
这个系列要做的事情是:从一个空目录开始,逐步构建一套能抓取站点、索引中英文文档、接受关键词和自然语言查询、返回带摘要和排序的搜索结果的检索系统。不调用 Elasticsearch API,不安装现成搜索服务——先亲手写倒排索引和 BM25,再接入 Lucene,加上向量召回和模型重排序。
最终演示:搜索引擎能做什么
先把终点亮出来。系列完成时,系统应该支撑以下操作:
| 操作 | 可观察结果 | 验收证据 |
|---|---|---|
| 导入本博客已发布文章 + 抓取获准文档站 | 标题、正文、URL、语言、抓取时间进入统一文档模型 | 数据清单、抓取日志、去重统计 |
| 搜索错误码、中文术语和自然语言问题 | 精确标识符保留词法优势,语义检索补充同义表达 | 同一查询下 BM25、向量、混合、重排序四组结果 |
| 修改或删除一篇文档 | 新版本可见,旧段落及旧向量不再返回 | 更新前后结果、版本号、可见时间 |
| 关闭模型服务或重启检索进程 | 模型超时后返回词法结果;已提交索引可恢复 | 故障注入记录、降级标记、恢复后查询 |
| 打开搜索页面完成查询与翻页 | 过滤、高亮、稳定翻页、空结果与错误提示 | 浏览器操作记录及 API 响应 |
这张表是整个系列的契约。每完成一组章节,回来勾掉对应行。
架构全景
把上面的能力拆成数据流,从左到右画出来:
1 | |
左侧是词法路径:文档经过分词器变成词项,建立倒排索引,查询时用 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 | |
查询 “NullPointerException 修复”,预期排序:
- doc1——标题和正文都包含查询词,BM25 得分最高
- doc3——包含 “Java” 但不含 “NullPointerException”,部分匹配
- 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 | |
第一批只推进 00–03 四篇。冻结工具链和基础数据后,完成顺序扫描搜索、统一导入和人工评测基线,然后继续推进。
下一篇
第 01 篇「建立可重复运行的项目」会从一个空目录开始,锁定 Java 25 和 Maven 版本,创建 fixture 数据,实现一个最简单的顺序扫描搜索 CLI。目标是:java -jar search.jar --query "NullPointerException" --data fixtures/ 能返回匹配结果。
练习:
- 在上面的三篇 fixture 文档基础上,追加一篇 “Python 异常处理与 traceback 解读”。对查询 “异常处理” 写出你预期的 BM25 排序,说明理由。
- 如果用户输入的查询是空字符串,系统应该返回什么?如果查询包含 100 个词呢?写出你认为合理的行为。






