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
# openjdk version "25" 2025-09-16
# OpenJDK Runtime Environment Temurin-25+...

mvn -version
# Apache Maven 3.9.16
# Java version: 25

两条命令的输出里,大版本号必须对得上。如果 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.filejava.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 的统一导入管道。

练习

  1. 给 fixture 增加一份包含错误码的文档(如 “ERROR-40001: timeout waiting for connection”),验证搜索错误码 “ERROR-40001” 时能正确命中。

  2. 把查询词改为多个空格组成的字符串(如 " "),观察程序行为。当前代码能否正确拒绝这种输入?如果不能,修改 Main.java 使其处理这种边界情况。

  3. SequentialSearcher.search() 中加一行 System.out.println("扫描文件: " + file.getFileName()),搜索一个不存在的词,观察输出。思考:当 fixture 数量从 5 份增长到 10,000 份时,这条日志意味着什么。

参考资料