从零构建现代搜索引擎(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 系列...
从零构建现代搜索引擎(00):最终要做出怎样的搜索引擎
在技术博客里搜"Java NullPointerException at line 42",搜索框返回了一堆标题带"Java"的文章,没有一篇提到这个异常。换成 Stack Overflow,第一条就是对应的解答。差距在哪里?不在界面,在从文档到查询结果之间的每一层处理。 这个系列要做的事情是:从一个空目录开始,逐步构建一套能抓取站点、索引中英文文档、接受关键词和自然语言查询、返回带摘要和排序的搜索结果的检索系统。不调用 Elasticsearch API,不安装现成搜索服务——先亲手写倒排索引和 BM25,再接入 Lucene,加上向量召回和模型重排序。 最终演示:搜索引擎能做什么 先把终点亮出来。系列完成时,系统应该支撑以下操作: 操作 可观察结果 验收证据 导入本博客已发布文章 + 抓取获准文档站 标题、正文、URL、语言、抓取时间进入统一文档模型 数据清单、抓取日志、去重统计 搜索错误码、中文术语和自然语言问题 精确标识符保留词法优势,语义检索补充同义表达 同一查询下 BM25、向量、混合、重排序四组结果 修...
从零编写操作系统 12 - 内核堆:在物理页内分配、分裂与合并
物理页分配器每次交出 4096 字节,队列节点、字符串和小结构体却通常用不满一页。 本篇在已经建立恒等映射的物理页内实现 kmalloc 和 kfree,按请求长度分配空间,并在释放时合并相邻空闲块。 堆最多持有四个独立页面,返回地址按 16 字节对齐,单次请求上限为 4076 字节。 实验继续运行在单 CPU、64 MiB RAM 的 qemu32 环境中,所有代码位于 ring 0。 正常镜像验证不同大小的读写、真实耗尽和回收;独立故障镜像破坏对象尾部标记,记录拒绝结果后主动进入诊断停机。 尾部标记由软件检查,越界写入发生时不会因此自动产生硬件异常。 页分配和字节分配各自管理什么 page_alloc() 依据 E820 和保留区规则选择可用物理页,返回物理地址。 它只记录页面归属,不知道页内有几个对象,也不知道其中某个对象申请了多少字节。 如果一个 17 字节对象独占一页,剩余空间仍然被这次页分配占用。 堆从页分配器取得整页,再用块头描述页内区间。 同一页可以同时容纳多个对象,释放一个对象后,其他对象继续有效。 分页还提供了另一项必要条件:这些物理字节必须能被当前虚拟地址...
从零编写操作系统 11 - 分页与页故障:修改映射后重试同一条指令
物理页分配器已经能交出一页空闲 RAM,但它不决定一条访存指令最终访问哪一页。 本篇建立 32 位两级页表,开启分页,再让同一个虚拟地址先后访问两个物理页。 随后触发两次真正的页故障:一次读取未映射页,一次向只读页写入;处理函数修复映射,返回后重试原指令。 实验沿用单 CPU、64 MiB RAM 的 qemu32 模拟机,所有代码运行在 ring 0。 低 64 MiB 保持恒等映射,分页开启前后继续使用原 C 栈。 页目录和页表共占用 18 个物理页,演示结束后仍然保留;用于读写的两个数据页则归还分配器。 一次访存经过哪几种地址 在此前的平坦段模型中,代码段和数据段基址都是 0。 指令使用的偏移经过分段后得到同值的线性地址,分页关闭时,再按此前建立的地址环境访问物理内存。 开启分页后,线性地址需要经过页表翻译;本文把这一层输入称为虚拟地址。 物理页分配器返回的 uint32_t 仍然表示物理地址。 把它转换为 C 指针,得到的却是一次线性地址访问。 只有相关地址已经建立适当映射,这种直接转换才有效;分页不会自动给所有分配结果补上映射。 本篇刻意保留低地址恒等映射,使物理页地...











