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/ │ ├── serve/ │ ├── 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> <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>
<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
| server: port: 8080
index: directory: ./data/index codec: 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
|
所有可调参数集中在一个文件中。
一键启动脚本
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
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
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 搜索,仍然可用
全流程演示脚本
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 }
step "1. 查询 'Java 并发'" curl -s "$BASE/search?q=Java+并发&topK=5" | python3 -m json.tool check
step "2. 添加新文档" curl -s -X POST "$BASE/docs" \ -H "Content-Type: application/json" \ -d '{"id":"demo-001","title":"虚拟线程详解","body":"Java 21 引入虚拟线程..."}' check
step "3. 查询新添加的文档" sleep 1 curl -s "$BASE/search?q=虚拟线程&topK=5" | python3 -m json.tool check
step "4. 修改文档" curl -s -X PUT "$BASE/docs/demo-001" \ -H "Content-Type: application/json" \ -d '{"title":"Java 虚拟线程完全指南","body":"Java 21 引入的虚拟线程是轻量级线程..."}' check
step "5. 验证修改生效" sleep 1 RESULT=$(curl -s "$BASE/search?q=虚拟线程&topK=1") echo "$RESULT" | python3 -m json.tool echo "$RESULT" | grep -q "完全指南" check
step "6. 删除文档" curl -s -X DELETE "$BASE/docs/demo-001" check
step "7. 确认文档已删除" sleep 1 RESULT=$(curl -s "$BASE/search?q=虚拟线程+完全指南&topK=5") echo "$RESULT" | python3 -m json.tool
step "8. 模拟 embedding 服务停机" echo "(手动停止 embedding 服务后按回车)" read -r curl -s "$BASE/search?q=Java+并发&topK=5" | python3 -m json.tool echo "降级到纯 BM25 搜索" check
step "9. 重启搜索进程" echo "(重启搜索进程后按回车)" read -r
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
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%——向量搜索、融合和重排都有贡献。
练习
- 从空目录 clone 项目,运行
run.sh 启动服务
- 运行
demo.sh,确认全流程通过
- 停止 embedding 服务,重新运行查询,观察降级行为
- 运行评测,记录基线指标
- 修改一个参数(如 BM25 k1),运行评测对比变化
延伸阅读
- 本系列全部 30 篇的索引见第 00 篇
- Apache Lucene 官方文档
- “Introduction to Information Retrieval”, Manning, Raghavan & Schütze