从零构建现代搜索引擎(30):从空目录完成最终演示
30 篇文章构建了一个完整的搜索引擎:从爬虫到倒排索引,从 BM25 到向量搜索,从单机到分片,从权限过滤到相关性迭代。 本篇把所有组件打包成一个可以从空目录启动的完整系统,写清楚 README,固定测试数据,提供完整运行脚本,并诚实列出限制。最终演示覆盖查询、修改、删除、模型停机、进程重启、重新查询的全过程。 项目结构 目录布局 1234567891011121314151617181920search-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/ ...
从零构建现代搜索引擎(29):形成可重复的相关性迭代流程
前 28 篇构建了一个功能完整的搜索系统。但"能跑"和"搜得好"之间还有距离。搜索相关性需要持续迭代——改了分词器、加了同义词、调了 BM25 参数,怎么判断是变好了还是变差了? 本篇建立可重复的相关性迭代流程:归因失败查询、做盲评、写离线实验报告、设计 A/B 实验。每次变更都有证据支撑接受或拒绝的决定。 失败查询归因 什么是失败查询 评测集中 nDCG@10 = 0 的查询是明确的失败查询——Top-10 中没有相关文档。但更多失败是隐性的: nDCG@10 < 0.3:有相关文档但排位很低 Recall@100 = 0:相关文档根本不在召回集中 用户从搜索结果页直接退出(线上才能观测到) 四类失败 1234567891011121314151617181920212223242526272829303132333435363738enum FailureType { COVERAGE, // 相关文档不在索引中 RECALL, // 在索引中但未被召回 RANKING, ...
从零构建现代搜索引擎(28):搜索质量与访问边界
搜索引擎返回的结果必须尊重访问权限——用户不该看到无权访问的文档,哪怕那个文档与查询高度相关。同时需要过滤垃圾页和限制恶意查询,防止低质量内容和滥用行为影响系统。 本篇在检索内部实现权限过滤,而不是在结果页面上隐藏。同时处理租户隔离、垃圾页过滤、查询限额和缓存泄漏。 文档级权限过滤 为什么必须在检索阶段过滤 错误做法:先检索 Top-10,再过滤掉无权限的文档。 问题:如果 Top-10 中有 8 条无权限,过滤后只剩 2 条结果。用户看到的结果数量不可预测,体验极差。更严重的是,排序位置本身就泄露了信息——"第一条结果被过滤了"告诉用户那个位置有内容存在。 正确做法:在 Lucene 检索遍历倒排列表和 HNSW 图时过滤。过滤后的文档不参与评分和排序,就像它们不存在一样。 权限模型 每个文档存储一个访问控制列表(ACL),记录哪些用户或角色可以访问: 12345678910111213141516171819void indexDocumentWithAcl(IndexWriter writer, String docId, String ...
从零构建现代搜索引擎(27):节点故障、备份与恢复
上一篇把索引拆成了多个分片。分片解决了容量问题,但带来了新问题:一个分片进程挂了,那个分片上的文档就搜不到了。 本篇用只读副本实现读取冗余,用健康检查发现故障节点,用快照备份保证数据可恢复。目标:停一个进程后查询仍然可用(降级),从快照恢复后数据损失可量化(RPO/RTO)。 只读副本 为什么要副本 单个分片只有一个进程时: 进程挂了 → 该分片所有文档不可搜索 升级或重启 → 搜索服务中断 副本让同一个分片的数据存在于多个进程上。主节点负责写入,副本负责查询。 基于已提交快照的同步 主节点 writer.commit() 后,副本从共享目录打开新的 reader: 123456789101112131415161718192021222324252627282930class PrimaryNode { private final IndexWriter writer; private final Path indexDir; void indexAndCommit(Document doc) throws IOException {...
从零构建现代搜索引擎(26):把查询分发到多个分片
单机索引有容量天花板。内存装不下全部 HNSW 向量时,要么做量化压缩(第 22 篇),要么把索引拆到多个进程上。量化只能压缩 4 倍,而索引拆分没有理论上限。 本篇把索引拆成多个分片,用 scatter/gather 模式分发查询,在 coordinator 上合并全局 Top-K。重点处理分片内 BM25 统计偏差和跨分片评分可比性。 为什么要分片 单机瓶颈 文档数 倒排索引 HNSW(int8) Stored fields 合计 10K 50 MB 23 MB 20 MB ~93 MB 100K 500 MB 230 MB 200 MB ~930 MB 1M 5 GB 2.3 GB 2 GB ~9.3 GB 10M 50 GB 23 GB 20 GB ~93 GB 10M 文档需要 93 GB 内存——超出大多数单机配置。即使装得下,查询延迟也随文档数增长。 分片的收益 将 10M 文档拆成 10 个分片,每个分片 1M 文档: 每个分片只需 ~9.3 GB 内存 查询延迟接近 1M 文档的水平(各分片并行执行) 单个分片故障不影响其他分...
从零构建现代搜索引擎(25):新鲜度与持续采集
到目前为止,索引中的文档是一次性灌入的。真实搜索引擎的内容不断变化——新页面发布、旧页面修改、过期页面删除。搜索引擎需要持续追踪这些变化,保持索引的新鲜度。 本篇实现持续采集系统:条件请求避免无谓下载,自适应调度控制抓取节奏,有界队列和背压防止资源耗尽,幂等写入保证重试安全,状态持久化保证重启不丢进度。 条件请求 HTTP 条件请求机制 HTTP 协议内置了条件请求机制,避免重复下载未变化的内容。 Last-Modified / If-Modified-Since: 123456789101112131415161718192021222324record CrawlMetadata( String url, String etag, String lastModified, long lastCrawledAt, int consecutiveNotModified, int consecutive404) {}HttpResponse<String> conditionalFetch(CrawlMetada...
从零构建现代搜索引擎(24):建立搜索的性能画像
搜索系统的各阶段——查询解析、BM25 召回、向量召回、融合、重排——性能特征各不相同。不做测量就不知道瓶颈在哪里,不知道瓶颈就无法有效优化。 本篇为搜索请求建立端到端的性能剖面,用冷热缓存压测暴露真实延迟,用容量账本规划资源预算。 阶段计时 SearchTrace 一个贯穿请求生命周期的计时器,记录每个阶段的耗时: 12345678910111213141516171819202122232425class SearchTrace { private final long requestStartNanos = System.nanoTime(); private final Map<String, Long> stageStartNanos = new LinkedHashMap<>(); private final Map<String, Long> stageDurationMicros = new LinkedHashMap<>(); void stageStart(String sta...
从零构建现代搜索引擎(23):模型升级时怎样重建索引
embedding 模型会升级。Qwen3-Embedding-0.6B 今天够用,明天可能被更大或更准的版本替代。新模型产生的向量与旧模型不在同一空间——余弦相似度失去意义。 本篇解决一个工程问题:在不中断服务的前提下,用新模型重建整个向量索引,验证质量,原子切换,并在出问题时回滚。 为什么不能直接替换模型 向量空间不兼容 不同 embedding 模型(即使同一系列的不同版本)学到的向量空间不同。用模型 A 编码的文档向量和用模型 B 编码的查询向量做余弦相似度,结果无意义。 1234模型 A 空间: "搜索引擎" → [0.12, -0.34, 0.56, ...]模型 B 空间: "搜索引擎" → [0.78, 0.11, -0.22, ...]cosine(A_doc, B_query) = 无意义的数字 混用两个版本的向量等于在两套不同的坐标系之间算距离。 必须全量重建 局部替换不可行。如果只用新模型编码新文档,索引里就同时存在两个空间的向量。查询时无论用哪个模型编码查询,都只能正确匹配一半文档。 唯一正确的做法:用新模型对全...
从零构建现代搜索引擎(22):压缩向量与控制推理成本
第 18-19 篇用 float32 向量做 HNSW 搜索。512 维 float32 = 2KB/向量。1 万个 chunk 占 20 MB,尚可接受。但向量数增长到百万级时,内存开销达到 GB 级别——单机装不下,或者装得下但挤占了其他组件的内存预算。 本篇用量化减小向量体积,用缓存和批处理降低 embedding 推理的调用成本。 向量存储的成本账 内存占用 512 维 float32 向量的存储开销: 向量数 原始向量 含 HNSW 图 备注 10K 20 MB ~30 MB 教学语料 100K 200 MB ~300 MB 中等站点 1M 2 GB ~3 GB 大型站点 10M 20 GB ~30 GB 超出单机内存 HNSW 图的边(每节点 M 个邻居 × int32 node ID)增加约 50% 的额外开销。 推理成本 每次查询需要调用 embedding 服务编码查询文本。每次文档写入需要编码 chunk 文本。 操作 延迟(GPU) 延迟(CPU) 编码 1 条文本 ~5 ms ~50 ms 编码 100 ...
从零构建现代搜索引擎(21):让重排序模型检查候选
上一篇用 RRF 将 BM25 和向量检索的结果合并为一个排名。但 RRF 的融合依据只有排名位置——它不知道候选文档和查询的实际匹配程度。一篇排在 BM25 第 2 名的文档可能因为标题恰好包含查询词而得分高,但正文和查询主题完全无关。 本篇引入重排序模型(reranker),对 RRF 返回的 Top-N 候选逐一做精细评分,找出真正最相关的文档。 二阶段检索 为什么不用 reranker 做全量搜索 Cross-encoder reranker 对每个 (query, document) pair 做联合编码——query 和 document 的每个 token 之间做 full attention。这比 bi-encoder(第 18 篇)精确得多,但也慢得多。 方法 1 万文档耗时 10 万文档耗时 BM25 ~5 ms ~20 ms 向量 HNSW ~5 ms ~10 ms Cross-encoder ~5 s ~50 s Cross-encoder 对每个候选都要过一遍完整的 Transformer 前向传播。对 1 万文档逐个打分需...










