从零构建现代搜索引擎(10):内存装不下时怎样建立索引
前面九篇的倒排索引和 BM25 有一个隐含假设:所有文档在内存里放得下。50 篇文档确实没问题。但把数据集扩展到 10,000 篇、每篇平均 2,000 词时,仅 (词项, docID) 对就可能有几千万条,单次排序的内存需求轻松超过 JVM 默认堆。 这不是"用更大的机器"就能解决的问题。即使加到 32 GB 内存,真实语料还是可能更大。需要一种策略:把内存装不下的数据分批处理,每一批写成磁盘上的段文件(segment),最后把所有段合并成完整索引。 分批建立索引 朴素方案的瓶颈 第 07 篇的索引构建把所有文档一次性塞进 HashMap: 123456Map<String, List<Integer>> index = new HashMap<>();for (Document doc : allDocs) { for (String term : analyze(doc.content())) { index.computeIfAbsent(term, k -> new ...
从零构建现代搜索引擎(09):从词频到 BM25
前面的章节用布尔查询找到匹配的文档,但所有结果按 docId 返回——排在前面的不是最相关的文档,只是最先被索引的文档。搜索 “Java 垃圾回收” 时,一篇通篇讲 GC 调优的文章应该排在只在页脚提了一句 “Java” 的文章前面。 评分排序需要量化"相关性"。BM25(Best Matching 25)是目前最广泛使用的词法检索评分函数,Lucene、Elasticsearch、Solr 的默认评分都基于 BM25。它的输入只有三样:词频、文档长度和文档频率——全部可以从倒排索引中直接获取。 从 TF 到 TF-IDF 词频(Term Frequency) 直觉上,一个词在文档中出现次数越多,这篇文档与该词越相关。这就是词频(TF)。 但原始词频有问题:一个词出现 10 次不意味着相关性是出现 1 次的 10 倍。从 1 次到 2 次的信息增益远大于从 9 次到 10 次。对数平滑是常见处理: 12tf_log(t, d) = 1 + ln(tf(t, d)) 如果 tf > 0 = 0 ...
从零构建现代搜索引擎(08):短语、布尔查询与过滤
上一篇的倒排索引只支持单词查询和最基本的 AND/OR。搜索 “Java 内存模型” 时,AND 查询能找到同时包含 “java”、“内存”、“模型” 三个词的文档,但也会命中一篇标题是"Java 并发编程"、正文某处提到"内存不足"、另一处提到"设计模型"的文档——三个词散布在不同段落,根本不是"Java 内存模型"这个概念。 短语查询(phrase query)要求词项不仅共现,还必须按指定顺序出现在相邻位置。这需要用到第 06 篇记录的 position 信息。 本篇在倒排索引上增加三项能力:短语查询、带优先级的布尔查询语法、以及字段过滤。 短语查询:Positional Intersection 核心思路 短语 “Java 内存模型” 经过分析器产出三个词项,position 分别为 0、1、2。在目标文档中,这三个词项也必须出现在连续的 position 上(允许 position 差恰好等于词项在查询中的间距)。 算法分两步: 对短语中所有词项的倒排列表做 AND(交集),找到同时包含...
从零构建现代搜索引擎(07):第一张倒排索引
上一篇把文本切成了词项流。但词项流只是一个线性序列——要回答"哪些文档包含’Java’"这个问题,仍然需要扫描所有文档的词项流。1000 篇文档还能忍受,10 万篇就不行了。 倒排索引(inverted index)把"文档包含哪些词"的关系翻转成"每个词出现在哪些文档中"。查询时只需要查一次词典,就能拿到所有包含该词的文档列表,不需要扫描任何一篇文档的全文。 从正排到倒排 正排索引(forward index)是文档到词项的映射: 123doc1 → [java, 内存, 模型, gc, 调优]doc2 → [java, 线程, 池, 并发]doc3 → [python, 内存, 管理] 查询 “java” 需要扫描全部 3 篇文档。 倒排索引把方向翻转: 12345678910java → [doc1, doc2]内存 → [doc1, doc3]模型 → [doc1]gc → [doc1]调优 → [doc1]线程 → [doc2]池 → [doc2]并发 → [doc2]...
从零构建现代搜索引擎(06):中文、英文和代码怎样变成词项
前五篇完成了文档的采集、导入和评测基线。文档以纯文本的形式进入系统,但搜索是以词为单位进行的——查询 “Java 内存模型” 需要把这五个字拆成能和索引匹配的词项。英文有空格做天然分隔,中文没有;版本号 3.14.1 不应该在小数点处断开;NullPointerException 既需要保留完整形式也需要拆成子词。 本篇设计一个分析器(Analyzer),将原始文本转换为可索引的词项流,记录每个词项的位置和偏移量。 分析器的三阶段模型 文本分析拆成三步: 1原始文本 → Tokenizer → Token Filter → 词项流(Term Stream) Tokenizer 把连续文本切成一个个 token。Token Filter 对 token 做后处理:小写化、停用词移除、同义词展开。每个 token 携带四项信息: 123456public record Token( String term, // 词项文本 int position, // 在词项流中的序号(0-based) int startOffset, // 在原始...
从零构建现代搜索引擎(05):正文提取、URL 规范化与去重
上一篇实现的爬虫能够抓回 HTML 页面,但抓回来的原始 HTML 里充满了导航栏、侧边栏、页脚、广告和脚本标签。把这些噪声和正文一起送入索引,搜索"Java 内存模型"可能命中一堆导航菜单里的"Java"字样。 本篇解决三个问题:从 HTML 中提取干净的正文,将 URL 统一为规范形式以避免同一页面被当作不同文档,以及识别内容相同或高度相似的页面以避免重复索引。 从 HTML 到正文 噪声标签移除 HTML 页面中不是所有标签都承载正文内容。<nav>、<header>、<footer>、<aside>、<script>、<style> 以及带有常见 CSS 类名(.sidebar、.menu、.ad)的元素通常是噪声。jsoup 的选择器可以一次性移除: 123456Document doc = Jsoup.parse(html);doc.select( "nav, header, footer, aside, script, style, &q...
从零构建现代搜索引擎(04):实现有边界的网页爬虫
前三篇建立了文档导入和评测基线,数据来源是本地 fixture 文件。真实搜索引擎的数据来自网页。抓取网页看起来只是发 HTTP 请求再解析 HTML,但如果不加约束,爬虫会在几分钟内失控——压垮目标站点、陷入无限循环、抓回内网敏感数据,或在中断后丢失全部进度。 本篇的目标:写一个有明确边界的爬虫,能从种子 URL 出发,按广度优先发现页面,遵守 robots.txt,控制并发和重试,拒绝危险地址,并在中断后恢复。 边界从哪里来 "有边界"在爬虫语境下指四类约束: 抓取范围:只抓白名单内的域名。种子 URL 页面上提取的链接如果指向其他站点,直接丢弃。URL 深度设上限(例如 5 层),防止动态生成的无限路径耗尽队列。 礼貌约束:同一站点的请求间隔不低于 1 秒,同站并发不超过 2 个连接。robots.txt 声明的 Crawl-delay 优先于默认值。全局并发受线程池限制。 资源约束:单个响应体不超过 5 MB,连接超时 10 秒,读取超时 30 秒,单个 URL 最多重试 3 次。整个爬取任务可以设总页面数上限。 安全约束:在发起 HTTP 请求之前...
从零构建现代搜索引擎(03):在改进排序前建立评测基线
改了分词器,搜"Java 异常"的结果看起来比之前好。真的好了吗?好了多少?有没有别的查询变差了? 没有数字就没有答案。靠肉眼看几条结果来判断质量,和靠"感觉服务器变快了"来判断性能一样不可靠。更危险的是:改了十次之后,已经记不清第一次的结果长什么样,无法判断整体方向是变好还是变差。 本篇的任务是在第一次改进排序之前,先把衡量标准建好。30 个标注查询、一个最小评测器、一份基线报告。之后每次改动,跑一遍评测,看数字说话。 评测的核心材料:查询、文档和相关性判定 搜索评测需要三样东西: 123查询集(queries) ─── 用户会搜什么文档集(corpus) ─── 搜索引擎里有什么相关性判定(qrels) ─── 哪些文档和哪个查询相关,相关到什么程度 相关性判定是人工标注的。格式沿用 TREC 的 qrels 标准: 1query_id 0 doc_id relevance 中间的 0 是历史遗留的迭代字段,固定填 0。relevance 是相关性等级,本系列用 0–3 四级: 等级 含义 标注标准 ...
从零构建现代搜索引擎(02):文档对象与统一导入
上一篇的顺序扫描搜索器直接读取文件内容做子串匹配。这个做法有两个明显问题:同一个文件导入两次会出现两条重复结果;一份 GBK 编码的中文文档读进来变成乱码,搜什么都搜不到。 搜索引擎的第一层抽象不是索引,而是文档对象。把文件系统里散落的 Markdown、HTML、纯文本统一成结构化的文档记录,处理好编码、去重和元数据,后面的分词、索引、检索才有干净的输入。 从文件到文档:为什么需要一层抽象 第 01 篇的 SequentialSearcher 直接操作 Path 和 String。这在 fixture 只有 5 个 UTF-8 纯文本文件时没有问题,但很快会遇到以下情况: 同一篇文档以 .md 和 .html 两种格式存在,内容相同,搜索结果出现两条 文件是 GBK 编码,Files.readString() 默认按 UTF-8 读取,正文变成 鎼滅储 HTML 文件里的 <nav>、<footer>、<script> 内容混入正文,搜"搜索引擎"命中了导航栏里的链接文字 没有稳定的文档标识符,文件改名后被当作新文档 ...
从零构建现代搜索引擎(01):建立可重复运行的项目
Git clone 拉下来的项目跑不起来,原因通常不是代码有 bug,而是环境不对:JDK 版本不匹配、Maven 插件解析失败、依赖拉不到。调试这类问题消耗的时间往往比写业务代码还多。这个系列从搜索引擎的第一行代码开始,但在写第一行代码之前,要先回答一个更基本的问题:怎样保证任何人在一个干净目录里执行一条命令就能构建运行。 本篇的交付物是一个可构建的 Maven 项目,包含几份 fixture 文档和一个顺序扫描式搜索 CLI。输入一个关键词,程序遍历所有 fixture 文件,打印包含该关键词的文档标题和匹配行。功能极简,但四个验证场景——正常查询、空查询、不存在的路径、--help——必须全部通过。 锁定工具链版本 "用最新版"不是版本策略。版本策略是把每个组件钉到一个具体的数字上,并说明为什么选它。 组件 版本 选择理由 JDK Eclipse Temurin 25 (LTS) 2025 年 9 月发布的长期支持版;GPLv2 + Classpath Exception 许可证,无商业歧义 Maven 3.9.16 3.9.x 系列...










