从零构建现代搜索引擎(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、向量、混合、重排序四组结果 修...
从零编写现代编译器 30 - 张量方言与 Lowering 到 LLVM
上一篇为 Sprout 定义了自己的 MLIR 方言——带形状检查的张量操作停留在高层表示里,没有进入真正的机器码。这一篇把它降下去:从张量方言出发,经过 linalg、scf、memref,一路到 LLVM IR,最后链接成本机可执行文件。这条路径正是 MLIR 最初被设计出来要走的路——它诞生于 TensorFlow 和 IREE 项目,目的就是让张量运算能在多层抽象之间有序地降低。 张量类型:值语义的多维数组 MLIR 里的 tensor<4x4xi64> 表示一个 4 行 4 列、元素类型为 i64 的多维数组。它和 memref<4x4xi64> 的区别在语义层面:tensor 是值语义(value semantics),一次操作产生一个新 tensor,原来的不变;memref 是引用语义(reference semantics),指向一块具体内存,修改直接生效。 这个区别不是风格偏好,而是编译优化的前提。值语义让编译器可以自由重排、合并、拆分张量操作而不用担心别名问题。等到优化做完,再通过 bufferization 统一转成 memref...
从零编写现代编译器 29 - MLIR 入门:定义 Sprout 方言
到第 14 篇为止,Sprout 的编译管线把 Typed HIR 降低到 LLVM IR,再交给 LLVM 的 pass pipeline 做优化和代码生成。这条路径能用,但有一个结构性的缺陷:LLVM IR 太低了。循环变成了跳转和 phi,数组访问变成了 getelementptr,函数调用的高层语义全部摊平成了调用约定。一旦进入 LLVM IR,编译器就很难再回答"这段代码原来是一个模式匹配"或者"这个数组创建应该做边界检查"这类高层问题。 MLIR(Multi-Level Intermediate Representation)正是为了解决这个问题而设计的。它允许在同一个框架内定义多个抽象层级的 IR——称为方言(dialect),每个方言保留特定层级的语义信息,再逐层降低到最终的机器表示。这一篇我们为 Sprout 定义一个自己的 MLIR 方言,并搭建从 Sprout 方言到 LLVM IR 的降低管线。 MLIR 的核心结构 MLIR 的设计围绕几个递归嵌套的概念展开。 操作(Operation) 是最基本的单元。每个操作...
从零编写现代编译器 28 - 用 WIT 封装组件接口
第 27 篇把 Sprout 的标量子集编译成了一个独立的 .wasm 核心模块。模块能在 Wasmtime 里跑起来,但它的边界只有导出函数的数字签名——调用者必须自己知道哪个参数是长度、哪个是指针偏移、返回值代表什么。换句话说,核心模块没有类型化的接口描述,就像 C 的 .o 文件没有头文件。 WIT(WebAssembly Interface Types)正是给 Wasm 组件补上的这层头文件。它用一种独立的接口定义语言描述组件的导入和导出:函数签名、参数类型、返回类型、记录、枚举、列表。两个组件通过 WIT 约定的接口互相调用,运行时在边界处自动完成类型验证和数据格式转换。组件之间不共享线性内存,也不需要知道对方的内部布局。 这篇要做的事情:给 Sprout 的统计函数写一份 WIT 接口定义,用工具把核心模块封装成组件,再把它和一个提供文件读取能力的宿主组件接到一起。 WIT 的基本结构 第 27 篇使用 wasm32-wasi(WASI preview 1)编译核心模块。本篇切换到 wasm32-wasip2(WASI preview 2),因为组件模型是 WASI...
从零编写现代编译器 27 - 编译到 WebAssembly
前 26 篇编译出来的都是本机二进制。它在 Linux x86-64 上跑得好好的,拿到 ARM macOS 就得重新编译,拿到浏览器里更是无从执行。WebAssembly 提供了另一条路:编译一次,生成一个 .wasm 模块,任何带 Wasm 运行时的平台都能直接运行。这篇给 sproutc 加一个 wasm32-wasi 目标,让同一份 Sprout 源码产出可移植的 Wasm 模块。 WebAssembly 是什么 WebAssembly(缩写 Wasm)是一种二进制指令格式,设计目标是安全、快速、可移植。它不绑定任何特定操作系统或处理器架构。一个 .wasm 文件是一个模块,包含函数定义、线性内存、类型签名和导入导出声明。 几个关键特征: 栈机器。Wasm 的执行模型是栈机器,不是寄存器机器。每条指令从栈上消费操作数,把结果压回栈。i64.add 弹出两个 i64,压入一个 i64。这和 x86 的寄存器模型不同,但 LLVM 后端会处理这层转换。 类型化函数。每个函数都有精确的参数和返回值类型签名。调用时参数数目和类型必须匹配。这在模块加载阶段就能验证,不需要等到运行...
从零编写现代编译器 26 - 用 Cranelift 实现 JIT 编译
前 25 篇的代码生成路径是固定的:Sprout SSA → LLVM 文本 IR → opt 优化 → llc 编译成机器码 → 链接成可执行文件。整个流程产出的是一个本地二进制文件,运行前必须经过完整的编译和链接。LLVM 的优化器能生成很快的机器码,但编译本身不快——一个千行级的 Sprout 程序走完 LLVM 流水线可能需要数百毫秒。对于发布构建这完全可以接受,但如果想做交互式开发——输入一段代码、立即看到结果——几百毫秒的延迟就太明显了。 这篇引入第二个代码生成后端:Cranelift。它是 Bytecode Alliance 开发的代码生成库,设计目标是编译速度优先、生成质量够用。用它替代 LLVM 之后,编译到可执行的函数指针只需要几毫秒,代价是生成的机器码比 LLVM O2 慢大约两倍。 Cranelift 的架构 Cranelift 不是一个完整的编译器框架,而是一个函数级别的代码生成库。它的输入是自己的中间表示 CLIF(Cranelift IR),输出是特定目标架构的机器码。没有链接器,没有目标文件——调用 API 传入 CLIF,拿回一段可执行的机器码...
从零编写现代编译器 25 - 从空目录构建并运行完整应用
二十四篇下来,编译器从只接受 return 42 长到了能处理泛型容器、多文件模块和编辑器协议。每篇都在前一篇基础上增加一种能力,但从来没有人从一个空目录开始,把所有东西拉下来跑一遍。本篇做的就是这件事:克隆源码,构建编译器,编译一个用到系列全部特性的 Sprout 应用,运行它,然后用四种输入检验结果。 最终应用:整数文本统计 目标程序 programs/stats/ 由三个 .spr 文件组成。io.spr 负责从文件读取文本并按行解析为整数数组;sort.spr 实现泛型插入排序;main.spr 调用前两者,输出统计值。 123456789101112131415161718192021222324// main.sprimport io;import sort;struct Stats { min: i64, max: i64, mean: i64, count: i64,}fn compute(nums: Array<i64>) -> Stats { ... }fn main() -&...





