从零构建现代搜索引擎(20):将关键词与向量结果合并
BM25 擅长精确标识符和稀有术语,向量检索擅长语义匹配和跨语言。单独使用任何一种都会在对方擅长的查询类型上表现不佳。本篇把两路召回的结果合并为一个排名——混合检索。 两路召回各有盲区 在第 16 篇的评测集上分别运行 BM25 和向量检索,按查询类型统计 nDCG@10: 查询类型 BM25 向量检索 差距 精确标识符 高 低 BM25 胜出,向量空间对标识符区分度差 概念查询 中 高 向量胜出,语义匹配覆盖同义表达 中英混合 中 中高 向量稍好,跨语言 embedding 有优势 同义改写 低 高 向量大幅胜出 多条件 中 中 各有优劣 无答案 好 差 BM25 正确返回零结果,向量总会返回"最近"的 没有一路在所有类型上都最优。混合检索的目标:在每种类型上至少达到两路中较好的那个。 Reciprocal Rank Fusion 为什么不直接加分数 BM25 分数通常在 0-30 之间,向量相似度在 0-1 之间。直接加权求和 α × BM25 + β × cosine 有三个问题: 量纲不同——需要归一化,但归...
从零构建现代搜索引擎(19):用 HNSW 加速近邻搜索
上一篇用精确扫描完成了向量检索的基线。精确扫描遍历所有向量计算点积,结果是精确的,但复杂度 O(N×D) 不可扩展。1 万个 chunk 只需 5 毫秒,10 万就要 50 毫秒,100 万要半秒——远超搜索服务的延迟预算。 本篇引入 HNSW(Hierarchical Navigable Small World),一种基于图的近似最近邻算法。Lucene 10.x 内置 HNSW 实现,可以直接通过 KnnFloatVectorField 使用。 为什么需要近似搜索 精确最近邻搜索保证找到真正最近的 K 个向量,但时间复杂度是线性的。搜索服务的延迟预算通常在 50 毫秒以内(整个搜索流程,不只是向量检索),线性扫描在大规模数据上无法满足。 近似最近邻(ANN)牺牲少量精度换取数量级的速度提升。具体来说:精确 Top-10 中的某些结果可能在 ANN 的 Top-10 中排到了第 11 或第 12——被遗漏了。但 ANN 返回的 Top-10 中 95% 以上是正确的,搜索质量几乎无损。 HNSW 的分层图结构 灵感来源:跳表 回忆第 11 篇的跳表:底层是完整链表,上层是稀疏...
从零构建现代搜索引擎(18):将文档编码成可检索向量
前面十七篇的检索全靠词汇匹配——查询和文档必须共享相同的 term 才能产生分数。搜索"如何提高程序运行速度",如果文档里写的是"性能优化",BM25 给出的分数是零。同义词表能覆盖一部分,但不可能穷举所有语义等价关系。 本篇引入向量检索:用 embedding 模型将文本编码为高维向量,在向量空间中用距离衡量语义相似度。先做精确扫描建立基线,下一篇再用 HNSW 加速。 从词汇匹配到语义匹配 BM25 的语义鸿沟 查询 相关文档包含 BM25 匹配 “如何提高程序运行速度” “性能优化最佳实践” 零分——没有共同 term “machine learning” “机器学习入门” 零分——跨语言 “数据库挂了怎么办” “MySQL 故障恢复指南” 低分——只有"数据库"模糊相关 同义词表(第 16 篇)缓解了一部分问题,但维护成本高,且无法覆盖所有隐含的语义关系。 向量空间的思路 将文本映射为 D 维向量(D 通常是 512 或 1024)。映射由 embedding 模型完成——模型在大量...
从零构建现代搜索引擎(17):查询理解与链接质量信号
用户在搜索框里输入的查询经常不是最理想的检索条件:拼错了字、只打了半个词、用了口语化的说法。前一篇优化了分词和字段权重,本篇在查询到达索引之前对它做预处理——拼写纠错、前缀补全、查询改写——再加入一个与查询无关的信号:链接结构。 前缀补全 问题 用户在搜索框中输入 “java 内” 时,如果等到用户按下回车才开始搜索,就错过了一个引导用户的机会。前缀补全在用户还在打字时就列出可能的完整查询,减少输入量,同时引导用户使用索引中实际存在的词汇。 Trie 实现 最基础的前缀补全用 Trie(前缀树): 123456789101112131415161718192021222324252627282930313233343536373839404142434445class TrieNode { final Map<Character, TrieNode> children = new HashMap<>(); boolean isTerminal; int frequency; String term;}class...
从零构建现代搜索引擎(16):在自己的查询集上优化相关性
第 15 篇把搜索内核切换到了 Lucene,同一组 fixture 在两个后端上对照通过。但 fixture 只有十来篇文档、几个查询——不足以暴露真正的相关性问题。 本篇把评测集从 30 个查询扩展到 100 个,分离开发集与冻结测试集,然后在开发集上逐步调整中文分词、字段权重和同义词,观察每个变量对搜索质量的实际影响。 为什么 30 个查询不够 第 03 篇建立的 30 个查询覆盖了基本查询类型,但存在统计问题: 单个查询的 nDCG 波动大——一个查询从 0.8 变到 0.6,30 个查询的平均值就移动了 0.007 某些查询类型只有 2-3 个样本,无法判断改善是否稳定 调参时容易"碰巧"在这 30 个查询上找到最优值,换一批查询就失效 100 个查询不算多,但足以让每个类型有 10+ 个样本,统计结论更可靠。 查询分类与扩展 六种查询类型 类型 数量 示例 特征 精确标识符 15 NullPointerException、BM25Similarity 必须精确匹配,不能被分词或同义词替换 概念查询 25 “倒排索引原理”、“...
从零构建现代搜索引擎(15):将自制内核接到 Lucene
前面十四篇从零实现了一个教学级搜索引擎:分词、倒排索引、BM25、段文件、崩溃恢复、摘要高亮、HTTP API。它能跑,能搜,能翻页。但它有一个根本问题——每一个组件都是教学级的,没有经过大规模生产验证。 Apache Lucene 是目前最广泛使用的开源搜索库。Elasticsearch、Solr、OpenSearch 底层都是 Lucene。它的分词器、索引格式、评分算法、段合并策略经过二十多年的工程打磨。与其继续在自制引擎上堆功能,不如把核心替换为 Lucene,把精力放在搜索质量和上层功能上。 本篇保持外部接口(搜索 API、文档导入格式)不变,把内部实现从自制内核切换到 Lucene 10.5.1。切换后用同一组 fixture 文档和查询对照两个后端,逐项记录差异。 迁移策略 保持契约不变 外部接口不变: 12输入:Document(title, url, content, language)输出:SearchResult(items[title, url, snippet, score], totalHits, page) 内部替换: 组件 自制 Lucen...
从零构建现代搜索引擎(14):搜索 API 与第一个网页界面
前面十三篇实现了一个完整的搜索引擎内核:倒排索引、BM25 评分、段文件、崩溃恢复、摘要和高亮。但它只能通过 Java main 方法调用——没有 API,没有界面,用户没法用。 本篇把搜索引擎包装成一个 HTTP 服务,加一个最简单的网页界面,让用户在浏览器中输入查询、看到结果、翻页。同时处理搜索服务必须面对的工程问题:参数校验、超时、错误反馈。 HTTP 搜索端点 JDK 内置 HTTP Server Java 25 自带 com.sun.net.httpserver.HttpServer,不需要引入 Spring Boot 或其他框架。教学场景下它足够: 12345HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0);server.createContext("/api/search", this::handleSearch);server.createContext("/", this::handleStatic);server.setExecu...
从零构建现代搜索引擎(13):给搜索结果补齐用户需要的信息
搜索引擎返回了 Top-10 结果,每条是一个 docID 和 BM25 分数。用户看到的却不应该是 doc#4237, score=2.091。用户需要标题、URL、一段能说明"为什么这条结果和查询相关"的摘要,以及查询词在摘要中的高亮。 本篇在搜索结果之上补齐四项能力:查询相关摘要、关键词高亮、HTML 转义(防止 XSS)、以及翻页。 存储字段 倒排索引不存原文 倒排索引记录的是"词项 → 文档列表",查询时能找到匹配的 docID 和词频,但无法还原文档原文。摘要和高亮需要原文。 两种方案: 存储字段(Stored Fields):索引时把需要展示的字段(标题、URL、正文)存入独立的正向存储。按 docID 随机访问。 外部存储:把原文放在数据库或文件系统中,用 docID 查询时回查。 教学实现用存储字段——把标题、URL、正文摘要在索引时写入一个按 docID 排列的文件,查询时按 offset 定位。 1234567891011121314151617181920212223242526record StoredDoc...
从零构建现代搜索引擎(12):更新、删除与崩溃恢复
前面十一篇建立的索引有一个隐含假设:文档集合是静态的。建完索引就不变了。但实际场景中,文档会增加、修改、删除。一篇文档改了标题,搜索结果应该反映新标题;一篇文档被删除,搜索不应该返回它。 在倒排索引上直接修改代价很高——删除一篇文档意味着遍历它包含的每个词项的 posting list,逐一移除对应的 docID。10,000 篇文档的索引里删除一篇,可能要修改上千个 posting list。 本篇解决三个问题:怎样标记删除、怎样安全地提交新版本索引、怎样在崩溃后恢复到一致状态。 标记删除:Tombstone 不直接修改 Posting List 直接从 posting list 中移除 docID 需要: 找到该文档包含的所有词项(需要正向索引或重新分析文档) 在每个词项的 posting list 中定位并删除对应 docID 如果 posting list 用了 delta 编码,删除一个元素需要重新编码后续所有 gap 这个操作的代价与文档包含的词项数成正比——对一篇包含 1,500 个不同词项的文档,要修改 1,500 个 posting list。如果频繁删除...
从零构建现代搜索引擎(11):查询为什么不必扫描全部候选
上一篇把索引从内存搬到了磁盘。现在对一个两词查询 “Java GC”,BM25 需要遍历两个词项的全部 posting list、对每个候选文档计算精确分数、维护 Top-K 堆。“Java” 的 posting list 有 5,000 条,“GC” 有 800 条——OR 查询的候选集最多 5,800 个文档,每个都要算一次 BM25。 但用户只看前 10 条结果。为了找到 Top-10,穷举 5,800 个文档是否必要? 答案是不必要。如果已经找到了分数很高的 10 篇文档,后面的文档只要能证明"它不可能比当前第 10 名更高",就可以直接跳过。这就是查询剪枝的核心思路。 穷举的成本 穷举 Top-K 流程回顾 第 09 篇的 Top-K 算法对所有候选文档计算 BM25: 1234567for (int docId : candidateDocs) { double score = bm25(queryTerms, docId); if (heap.size() < k || score > heap.peek()....










