30 篇文章构建了一个完整的搜索引擎:从爬虫到倒排索引,从 BM25 到向量搜索,从单机到分片,从权限过滤到相关性迭代。

本篇把所有组件打包成一个可以从空目录启动的完整系统,写清楚 README,固定测试数据,提供完整运行脚本,并诚实列出限制。最终演示覆盖查询、修改、删除、模型停机、进程重启、重新查询的全过程。

项目结构

目录布局

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
search-engine/
├── README.md
├── pom.xml
├── run.sh # 一键启动脚本
├── demo.sh # 全流程演示脚本
├── data/
│ ├── seed-corpus.jsonl # 固定测试语料
│ └── eval-queries.jsonl # 评测查询集
├── config/
│ └── search-config.yaml # 配置文件
├── src/main/java/search/
│ ├── crawl/ # 爬虫与采集
│ ├── index/ # 索引构建
│ ├── query/ # 查询解析与理解
│ ├── rank/ # BM25 + 向量 + 融合 + 重排
│ ├── serve/ # HTTP API
│ ├── eval/ # 评测框架
│ ├── security/ # 权限过滤与限额
│ └── Main.java # 入口
└── src/test/java/search/ # 测试

pom.xml 关键依赖

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
<properties>
<java.version>25</java.version>
<lucene.version>10.5.1</lucene.version>
</properties>

<dependencies>
<!-- Lucene 核心 -->
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-core</artifactId>
<version>${lucene.version}</version>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-analysis-smartcn</artifactId>
<version>${lucene.version}</version>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-queryparser</artifactId>
<version>${lucene.version}</version>
</dependency>

<!-- HTTP 服务器 -->
<!-- Java 内置 com.sun.net.httpserver 无需额外依赖 -->

<!-- SQLite (爬虫状态) -->
<dependency>
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
<version>3.46.0.0</version>
</dependency>
</dependencies>

Java 25 LTS + Lucene 10.5.1。embedding 和 reranker 通过 HTTP 调用外部服务,不在 pom.xml 中。

固定测试数据

seed-corpus.jsonl

1
2
{"id": "doc-001", "url": "https://example.com/java-concurrency", "title": "Java 并发编程指南", "body": "Java 的并发模型基于共享内存..."}
{"id": "doc-002", "url": "https://example.com/lucene-intro", "title": "Apache Lucene 入门", "body": "Lucene 是一个高性能的全文检索库..."}

固定 100 篇文档,覆盖技术博客、API 文档、教程三类内容。文件随项目版本管理,不依赖外部数据源。

eval-queries.jsonl

1
2
{"query": "Java 线程池配置", "judgments": [{"docId": "doc-001", "relevance": 3}, {"docId": "doc-015", "relevance": 2}]}
{"query": "Lucene 倒排索引原理", "judgments": [{"docId": "doc-002", "relevance": 3}]}

30 条查询,每条有人工标注的相关性判断(0-3 分)。

配置文件

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
# search-config.yaml
server:
port: 8080

index:
directory: ./data/index
codec: quantized # float32 | quantized

embedding:
endpoint: http://localhost:8081/embed
model: Qwen3-Embedding-0.6B
dimension: 512
batchSize: 64

reranker:
endpoint: http://localhost:8082/rerank
model: Qwen3-Reranker-0.6B
timeout: 200ms
candidateCount: 20

search:
bm25:
k1: 1.2
b: 0.75
vector:
efSearch: 64
fusion:
method: rrf
k: 60
latencyBudget: 300ms

crawl:
stateDb: ./data/crawl-state.db
rateLimitPerDomain: 1s
queueCapacity: 10000

所有可调参数集中在一个文件中。

一键启动脚本

run.sh

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
#!/bin/bash
set -euo pipefail

JAVA_HOME=${JAVA_HOME:-$(dirname $(dirname $(readlink -f $(which java))))}
JAVA="$JAVA_HOME/bin/java"

echo "=== 搜索引擎启动 ==="
echo "Java: $($JAVA --version 2>&1 | head -1)"

# 构建
mvn -q package -DskipTests

# 检查 embedding 服务
if curl -s http://localhost:8081/health > /dev/null 2>&1; then
echo "Embedding 服务: 可用"
VECTOR_ENABLED=true
else
echo "Embedding 服务: 不可用(仅 BM25 模式)"
VECTOR_ENABLED=false
fi

# 检查 reranker 服务
if curl -s http://localhost:8082/health > /dev/null 2>&1; then
echo "Reranker 服务: 可用"
RERANK_ENABLED=true
else
echo "Reranker 服务: 不可用(跳过重排)"
RERANK_ENABLED=false
fi

# 导入种子语料(如果索引不存在)
if [ ! -d "data/index" ]; then
echo "首次启动,导入种子语料..."
$JAVA -cp target/search-engine-1.0.jar search.Main \
--import data/seed-corpus.jsonl
fi

# 启动搜索服务
echo "启动搜索服务 (port 8080)..."
$JAVA -XX:+UseZGC -Xmx512m \
-cp target/search-engine-1.0.jar search.Main \
--serve --config config/search-config.yaml

降级运行

embedding 或 reranker 服务不可用时,系统自动降级:

  • 无 embedding → 只用 BM25 召回,跳过向量搜索和 RRF 融合
  • 无 reranker → 跳过重排,直接返回融合结果
  • 两者都不可用 → 纯 BM25 搜索,仍然可用

全流程演示脚本

demo.sh

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
#!/bin/bash
set -euo pipefail

BASE=http://localhost:8080
GREEN='\033[0;32m'
RED='\033[0;31m'
NC='\033[0m'

step() { echo -e "\n${GREEN}=== $1 ===${NC}"; }
check() {
if [ $? -eq 0 ]; then echo -e "${GREEN}PASS${NC}"
else echo -e "${RED}FAIL${NC}"; exit 1; fi
}

# 1. 查询
step "1. 查询 'Java 并发'"
curl -s "$BASE/search?q=Java+并发&topK=5" | python3 -m json.tool
check

# 2. 添加文档
step "2. 添加新文档"
curl -s -X POST "$BASE/docs" \
-H "Content-Type: application/json" \
-d '{"id":"demo-001","title":"虚拟线程详解","body":"Java 21 引入虚拟线程..."}'
check

# 3. 查询新文档
step "3. 查询新添加的文档"
sleep 1 # 等待索引刷新
curl -s "$BASE/search?q=虚拟线程&topK=5" | python3 -m json.tool
check

# 4. 修改文档
step "4. 修改文档"
curl -s -X PUT "$BASE/docs/demo-001" \
-H "Content-Type: application/json" \
-d '{"title":"Java 虚拟线程完全指南","body":"Java 21 引入的虚拟线程是轻量级线程..."}'
check

# 5. 查询修改后的文档
step "5. 验证修改生效"
sleep 1
RESULT=$(curl -s "$BASE/search?q=虚拟线程&topK=1")
echo "$RESULT" | python3 -m json.tool
echo "$RESULT" | grep -q "完全指南"
check

# 6. 删除文档
step "6. 删除文档"
curl -s -X DELETE "$BASE/docs/demo-001"
check

# 7. 确认删除
step "7. 确认文档已删除"
sleep 1
RESULT=$(curl -s "$BASE/search?q=虚拟线程+完全指南&topK=5")
echo "$RESULT" | python3 -m json.tool
# demo-001 不应出现在结果中

# 8. 模拟模型停机
step "8. 模拟 embedding 服务停机"
echo "(手动停止 embedding 服务后按回车)"
read -r
curl -s "$BASE/search?q=Java+并发&topK=5" | python3 -m json.tool
echo "降级到纯 BM25 搜索"
check

# 9. 进程重启
step "9. 重启搜索进程"
echo "(重启搜索进程后按回车)"
read -r

# 10. 重启后查询
step "10. 重启后查询"
curl -s "$BASE/search?q=Java+并发&topK=5" | python3 -m json.tool
check

step "全流程演示完成"

演示覆盖的场景

步骤 操作 验证点
1 查询 搜索 API 正常返回
2-3 添加文档 新文档可被搜索到
4-5 修改文档 修改后内容更新
6-7 删除文档 删除后不再出现
8 模型停机 降级到 BM25,不报错
9-10 进程重启 索引持久化,重启后数据不丢失

README

README.md 结构

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
43
44
45
46
47
# 从零构建的搜索引擎

基于 Java 25 和 Apache Lucene 10.5.1 的全功能搜索引擎。

## 功能

- 全文检索(BM25 + 中文分词)
- 语义搜索(embedding + HNSW)
- 混合检索(RRF 融合)
- 交叉编码器重排序
- 向量量化(int8)
- 持续采集与自适应调度
- 文档级权限过滤
- 查询限额
- 多分片支持

## 快速开始

### 前置条件

- Java 25 (Temurin)
- Maven 3.9+
- (可选)Qwen3-Embedding-0.6B 服务
- (可选)Qwen3-Reranker-0.6B 服务

### 启动

./run.sh

### 演示

./demo.sh

### API

| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /search?q=...&topK=10 | 搜索 |
| POST | /docs | 添加文档 |
| PUT | /docs/{id} | 修改文档 |
| DELETE | /docs/{id} | 删除文档 |
| GET | /health | 健康检查 |
| GET | /eval | 运行评测 |

## 限制

见下方"限制说明"一节。

限制说明

诚实列出系统的限制,不回避不夸大:

规模限制

  • 测试语料 100 篇文档——未在百万级数据上验证
  • 单机部署——分片代码存在但未做跨机器网络通信
  • 内存限制——HNSW 向量全部在内存中,受物理内存约束

功能限制

  • 中文分词使用 SmartChineseAnalyzer——不支持新词发现
  • 同义词表手工维护——不支持自动同义词挖掘
  • 权限模型是文档级——不支持字段级或行级权限
  • 垃圾页规则是硬编码——没有机器学习分类器
  • 没有 robots.txt 解析——真实爬虫必须遵守

质量限制

  • 评测集 30 条查询——统计显著性不足以支撑细粒度对比
  • 没有线上用户行为数据——无法计算真实的 CTR 和 pogo-sticking
  • A/B 实验只有设计——没有真实流量验证

运维限制

  • 没有监控告警——需要集成 Prometheus/Grafana
  • 没有自动故障转移——副本不会自动提升为主节点
  • 日志格式未标准化——需要集成日志框架
  • 没有配置中心——参数修改需要重启

未实现

  • 全互联网抓取
  • 跨地域容灾
  • 广告竞价
  • 大规模反作弊
  • 实时个性化排序
  • 联想/自动补全 UI
  • 多语言支持

评测基线

运行评测

1
2
$JAVA -cp target/search-engine-1.0.jar search.Main \
--eval data/eval-queries.jsonl

基线指标

1
2
3
4
5
6
7
8
9
10
11
12
13
=== 评测报告 ===
模式: BM25 + 向量 + RRF + Rerank
语料: 100 文档
查询: 30 条

nDCG@10: 0.6832
MRR@10: 0.7241
Recall@100: 0.8120

降级模式 (纯 BM25):
nDCG@10: 0.5214
MRR@10: 0.5890
Recall@100: 0.6530

完整流水线比纯 BM25 的 nDCG@10 提升 31%——向量搜索、融合和重排都有贡献。

练习

  1. 从空目录 clone 项目,运行 run.sh 启动服务
  2. 运行 demo.sh,确认全流程通过
  3. 停止 embedding 服务,重新运行查询,观察降级行为
  4. 运行评测,记录基线指标
  5. 修改一个参数(如 BM25 k1),运行评测对比变化

延伸阅读

  • 本系列全部 30 篇的索引见第 00 篇
  • Apache Lucene 官方文档
  • “Introduction to Information Retrieval”, Manning, Raghavan & Schütze