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 系列最终稳定版;Maven 4.0 截至 2026 年 8 月仍处于 RC 阶段
maven-compiler-plugin
3.14.0
支持 <release>25</release> 配置
maven-surefire-plugin
3.5.2
兼容 JDK 25 模块系统
Temurin 的安装方式因操作系统而异。macOS 上用 brew install --cask temurin@25,Linux 上从 Adoptium 下载页 取对应架构的包。安装后确认版本:
1 2 3 4 5 6 7 java -version mvn -version
两条命令的输出里,大版本号必须对得上。如果 mvn -version 显示的 Java 版本不是 25,检查 JAVA_HOME 是否指向了正确的 JDK。
项目结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 mini-search-engine/ ├── pom.xml ├── fixtures/ │ ├── doc-001-inverted-index.txt │ ├── doc-002-bm25-scoring.txt │ ├── doc-003-elasticsearch-intro.txt │ ├── doc-004-中文分词.txt │ └── doc-005-crawl-politeness.txt └── src/ └── main/ └── java/ └── search/ ├── Main.java └── SequentialSearcher.java
fixtures/ 不放在 src/main/resources 里。fixture 是测试语料,不是程序资源。后续章节会把语料来源换成网页爬取结果和博客文章,路径通过命令行参数传入,不需要打包进 JAR。
pom.xml 关键配置
整个 pom.xml 不长,但有几处容易出错。
1 2 3 4 <properties > <maven.compiler.release > 25</maven.compiler.release > <project.build.sourceEncoding > UTF-8</project.build.sourceEncoding > </properties >
<maven.compiler.release> 同时控制源码版本和目标版本,替代了过去 <source> 和 <target> 的组合。一个属性解决两个问题。编码声明为 UTF-8——fixture 里有中文,编码不对会导致匹配失败,而且报错信息不会告诉你是编码的问题。
构建插件锁定最低版本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 <build > <plugins > <plugin > <groupId > org.apache.maven.plugins</groupId > <artifactId > maven-compiler-plugin</artifactId > <version > 3.14.0</version > </plugin > <plugin > <groupId > org.apache.maven.plugins</groupId > <artifactId > maven-surefire-plugin</artifactId > <version > 3.5.2</version > </plugin > </plugins > </build >
第 01 篇没有外部依赖。JDK 25 自带的 java.nio.file 和 java.io 足以完成文件读取和文本匹配。依赖在后续章节按需引入——第 04 篇加 jsoup 做 HTML 解析,第 18 篇加 HTTP 客户端调用 embedding 服务。不提前引入不需要的东西。
准备 fixture 数据
fixture 是手工编写的短文档,覆盖后续实验需要的几种情况:英文技术文档、中文技术文档、包含特定术语的文档、关键词重复出现的文档。每份文档第一行是标题,空一行后是正文。
doc-001-inverted-index.txt:
1 2 3 4 5 Inverted Index Basics An inverted index maps terms to the documents that contain them. Each term points to a posting list: a sorted list of document IDs. Search engines use inverted indexes to avoid scanning every document.
doc-004-中文分词.txt:
1 2 3 4 5 中文分词的基本问题 中文文本没有空格分隔词语。搜索引擎需要把连续的汉字切分成有意义的词项。 "中华人民共和国" 可以切成 "中华/人民/共和国",也可以切成 "中华人民共和国"。 不同的切分方式直接影响搜索结果。
五份文档共约 600 字。足够验证搜索逻辑,小到可以在终端里肉眼检查每一行输出。
顺序扫描搜索
最原始的搜索方式:打开每一份文档,逐行检查是否包含查询词。没有索引,没有排序,没有评分。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 public class SequentialSearcher { private final Path fixtureDir; public SequentialSearcher (Path fixtureDir) { if (!Files.isDirectory(fixtureDir)) { throw new IllegalArgumentException ( "fixture 目录不存在: " + fixtureDir); } this .fixtureDir = fixtureDir; } public void search (String query) throws IOException { String lowerQuery = query.toLowerCase(); int matchedDocs = 0 ; try (var files = Files.list(fixtureDir)) { for (Path file : files .filter(p -> p.toString().endsWith(".txt" )) .sorted() .toList()) { List<String> lines = Files.readAllLines(file); String title = lines.isEmpty() ? "(无标题)" : lines.getFirst(); List<String> hits = lines.stream() .filter(line -> line.toLowerCase() .contains(lowerQuery)) .toList(); if (!hits.isEmpty()) { matchedDocs++; System.out.println("--- " + title + " [" + file.getFileName() + "] ---" ); hits.forEach(h -> System.out.println(" " + h)); System.out.println(); } } } System.out.println("匹配文档数: " + matchedDocs); } }
几个细节值得说明。查询词统一转小写做大小写不敏感匹配——英文搜索的基本要求,中文不受影响。Files.list() 返回的流需要排序,否则不同操作系统上文件顺序不同,输出不可重复。lines.getFirst() 是 JDK 21 引入的 SequencedCollection 方法,比 lines.get(0) 的意图更明确。
这段代码的时间复杂度是 O(D * L),D 是文档数,L 是平均行数。五份文档时感受不到延迟,一万份文档时会明显变慢。第 07 篇会用倒排索引把查询复杂度降到 O(log T + K),T 是词典大小,K 是匹配文档数。当前的顺序扫描是后续对照的基线。
CLI 入口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 public class Main { public static void main (String[] args) { if (args.length == 0 || "--help" .equals(args[0 ])) { System.out.println( "用法: java search.Main <fixture目录> <查询词>" ); System.out.println( "示例: java search.Main fixtures inverted" ); return ; } if (args.length < 2 ) { System.err.println("错误: 缺少查询词。" ); System.err.println( "用法: java search.Main <fixture目录> <查询词>" ); System.exit(1 ); } Path fixtureDir = Path.of(args[0 ]); String query = args[1 ]; if (query.isBlank()) { System.err.println("错误: 查询词不能为空。" ); System.exit(1 ); } try { new SequentialSearcher (fixtureDir).search(query); } catch (IllegalArgumentException e) { System.err.println(e.getMessage()); System.exit(1 ); } catch (IOException e) { System.err.println("读取文件失败: " + e.getMessage()); System.exit(2 ); } } }
错误处理分三层:参数缺失、路径无效、IO 异常。每层用不同的退出码和明确的错误信息。System.exit(1) 表示用户输入错误,System.exit(2) 表示系统错误。这个约定在后续章节保持一致。
构建和运行:
1 2 mvn compile java -cp target/classes search.Main fixtures inverted
四个验证场景
一个程序能否交付,不看正常路径是否通过,看异常路径是否有合理响应。
正常查询——搜索 “inverted”:
1 2 3 4 5 6 $ java -cp target/classes search.Main fixtures inverted --- Inverted Index Basics [doc-001-inverted-index.txt] --- An inverted index maps terms to the documents that contain them. Search engines use inverted indexes to avoid scanning every document. 匹配文档数: 1
输出包含文档标题、文件名和匹配行。“inverted” 出现在标题行和正文中,大小写不敏感匹配命中了 “Inverted” 和 “inverted”。
空查询——不传查询词:
1 2 3 $ java -cp target/classes search.Main fixtures 错误: 缺少查询词。 用法: java search.Main <fixture目录> <查询词>
程序打印用法提示,退出码为 1。不会抛出 ArrayIndexOutOfBoundsException。
不存在的路径——传入一个不存在的目录:
1 2 $ java -cp target/classes search.Main /nonexistent inverted fixture 目录不存在: /nonexistent
SequentialSearcher 构造时就检查目录是否存在,而不是等到遍历文件时才报错。错误尽早暴露。
帮助命令——--help:
1 2 3 $ java -cp target/classes search.Main --help 用法: java search.Main <fixture目录> <查询词> 示例: java search.Main fixtures inverted
这四个场景的意义不在于测试搜索能力——顺序扫描的搜索能力接近于零。它们验证的是项目骨架的健壮程度:构建能通过、入口能运行、参数能解析、错误能报告。后续每一篇在增加功能时,这四个场景只会增多,不会减少。
中文搜索的第一个坑
搜索 “分词”:
1 2 3 4 5 6 $ java -cp target/classes search.Main fixtures 分词 --- 中文分词的基本问题 [doc-004-中文分词.txt] --- 中文文本没有空格分隔词语。搜索引擎需要把连续的汉字切分成有意义的词项。 不同的切分方式直接影响搜索结果。 匹配文档数: 1
结果看起来正确,但有一个隐含问题。搜索 “中华” 也会命中 “中华人民共和国”——因为当前的匹配逻辑是 String.contains(),做的是子串匹配而不是词项匹配。英文文本中 “inverted” 和 “inverted index” 之间有空格分隔,子串匹配碰巧接近词匹配的效果。中文没有空格,子串匹配会产生大量误命中。
这个问题的正式名称是分词(tokenization)。第 06 篇会实现分词接口,把连续文本切成独立词项。在此之前,顺序扫描的子串匹配就是当前的全部能力。记住这个限制——它是后续改进的起点。
版本清单
当前项目锁定的完整版本,以备后续章节复查:
组件
版本
许可证
JDK
Eclipse Temurin 25 (LTS)
GPLv2 + CE
Maven
3.9.16
Apache 2.0
maven-compiler-plugin
3.14.0
Apache 2.0
maven-surefire-plugin
3.5.2
Apache 2.0
外部依赖
无
—
下一篇会引入文档对象模型,把五份 fixture 的手工格式替换成 Markdown 和 HTML 的统一导入管道。
练习
给 fixture 增加一份包含错误码的文档(如 “ERROR-40001: timeout waiting for connection”),验证搜索错误码 “ERROR-40001” 时能正确命中。
把查询词改为多个空格组成的字符串(如 " "),观察程序行为。当前代码能否正确拒绝这种输入?如果不能,修改 Main.java 使其处理这种边界情况。
在 SequentialSearcher.search() 中加一行 System.out.println("扫描文件: " + file.getFileName()),搜索一个不存在的词,观察输出。思考:当 fixture 数量从 5 份增长到 10,000 份时,这条日志意味着什么。
参考资料