从零构建现代搜索引擎(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 指针,得到的却是一次线性地址访问。 只有相关地址已经建立适当映射,这种直接转换才有效;分页不会自动给所有分配结果补上映射。 本篇刻意保留低地址恒等映射,使物理页地...
从零编写操作系统 10 - 物理页分配:哪些内存真正可以交出去
内核已经能处理时钟中断和键盘事件,但新增数据仍依赖静态数组。 页表、任务栈或文件缓存需要动态取得内存时,必须知道哪些物理地址可以使用、哪些已经分配,以及释放是否有效。 本篇把第 04 篇取得的 E820 内存图转换成 4 KiB 物理页分配器。 实验仍使用单 CPU、64 MiB RAM 的 qemu32 模拟机,分页保持关闭。 分配器管理固定上限内的页框,返回物理地址;它不建立虚拟地址映射,也不提供任意字节大小的 malloc。 实测初始空闲页数为 16087,客体耗尽全部页面、验证每页数据并释放后,空闲数恢复为 16087。 E820 可用内存还需要扣除内核占用 E820 描述固件报告的系统地址范围。 内核已经装入内存,却不意味着固件会把这段地址从 type 1 区域里扣掉。 若直接把所有 type 1 页面交给调用者,分配结果可能覆盖正在运行的代码或栈。 ACPI 6.5 第 15 章定义了 BIOS 内存图接口和范围类型。 其中 type 1 表示可用 RAM;其他类型不能在本实验中直接当作普通空闲页。 低地址区域也可能有固件或引导程序用途,规范 §15.2要求调用方考...
从零编写操作系统 09 - 键盘与事件队列:把中断接收和字符处理分开
键盘接入后,内核需要同时处理两种节奏:设备产生扫描码时必须及时接收,字符转换、退格和整行输出则可以留在前台执行。把打印放进 IRQ1 会延长关中断时间,也会让输入设备直接依赖控制台。 本篇在已有异常入口和 PIC 重映射上接入 PS/2 键盘。IRQ1 只接收原始字节并放进队列;主循环负责 US set 1 解析与简单行编辑。输入回车后显示 LINE: ...,尚未执行命令,也没有 shell。 IRQ1 接到已有中断入口 第 08 篇把主 PIC 的向量起点设为 0x20。键盘接主片 IRQ1,因此向量为 0x21,即十进制 33。 123456789PS/2 键盘扫描码 ↓i8042 输出缓冲区 → IRQ1 ↓主 PIC → IDT[33] → 公共汇编入口 ↓trap_dispatch → keyboard_irq ↓ 原始字节入队,主 PIC EOIiret 恢复前台 → 出队 → 解析 → 行编辑 kernel/exceptions.c 现在初始化前 34 个 IDT 表项,0—31 为异常,32 为时钟,33 为键盘。其余表项仍不在场,不能...
从零编写操作系统 08 - 时钟中断:让外部事件打断当前执行
上一篇的异常由正在执行的指令触发。时钟中断来自处理器外部:即使内核没有执行 int,定时器也能发出请求,经中断控制器送到 CPU,再通过 IDT 进入处理函数。 本篇接入传统 PC 的 8259A PIC 与 8254 PIT,只开放 IRQ0。正常内核收到 20 次时钟中断后关闭外部 IRQ,继续原有控制台自检;两个独立诊断镜像验证屏蔽、恢复和漏发 EOI 的后果。入口仍沿用上一篇的保存与恢复路径,尚未加入调度或任务切换。 从 PIT 输出到 IDT 第 32 项 PIT 的 channel 0 产生周期信号,连接主 PIC 的 IRQ0 输入。PIC 根据屏蔽位和优先级判断是否请求 CPU 响应;CPU 接受请求后获得向量,再查 IDT。 12345678910111213PIT channel 0 周期输出 ↓主 PIC 的 IRQ0 输入 ↓ IMR、优先级及 CPU 当前 IF 允许CPU 接受可屏蔽外部中断 ↓IDT[0x20] → isr32 → exception_common ↓trap_dispatch(frame) → timer...
从零编写操作系统 05 - 跳进 C:入口、链接脚本与内存布局
第 04 篇的保护模式入口停在一段 32 位汇编里。本篇从这里继续,把磁盘中的真实内核复制到物理地址 0x100000,准备 BSS 和栈,再调用 kernel_main。 屏幕上的 KERNEL_MAIN OK 有几个前提:已初始化全局变量保持原值,未初始化全局变量为零,普通函数调用能返回,BootInfo 指针和调用栈符合约定。这些条件都在本篇镜像里检查;另一个故障镜像故意跳过 BSS 清零,输出 KERNEL BSS FAIL。 编译器不负责启动内核 GCC 生成指令,链接器安排地址,磁盘读取和运行环境由启动代码提供。-ffreestanding 选择独立环境,不能代替加载器,也不会附带完整标准库。普通应用依赖的启动对象、进程栈和零初始化内存,在这里都需要有明确来源。 第 03、04 篇的 payload 是 20000 字节测试数据。本篇将它替换为 C 和汇编编译得到的 kernel.elf,再经 objcopy -O binary 得到平坦文件。第二阶段仍读取固定 LBA 的镜像头与 payload,不解析 ELF 的 program headers。 1234567...







