
搜索引擎为什么能秒搜把倒排索引拆给你看「搜索底层认知」系列第 1 篇。 我在医药电商做搜索每天跟 ES 的 match query、bool query、function score 打交道。两年来写了上千条 DSL直到把 Lucene 的倒排索引从磁盘结构到内存缓存完整串了一遍才觉得自己真的懂了。这一篇把我理解的过程写下来。一、先死磕一个基础问题搜阿莫西林胶囊你的直觉做法是什么// 最朴素的思路正向索引——遍历所有文档for(Documentdoc:allDocuments){if(doc.contains(阿莫西林)||doc.contains(胶囊)){results.add(doc);}}时间复杂度 O(N)。100 万文档就要扫 100 万次。ES 的 match query 响应时间是毫秒级——它不是这么干的。搜索引擎不扫描文档。它扫描词。索引阶段它提前建好了一张巨大的倒排表正向索引人读的思路 倒排索引机器的思路 Doc1 → [阿莫西林, 胶囊, 0.25g] 阿莫西林 → [Doc1, Doc3, Doc7, Doc12] Doc2 → [头孢克肟, 片剂, 0.1g] 胶囊 → [Doc1, Doc4, Doc12, Doc18] Doc3 → [阿莫西林, 颗粒, 0.125g] 头孢克肟 → [Doc2, Doc5] Doc4 → [阿莫西林, 片剂, 0.25g] 0.25g → [Doc1, Doc4]搜索阿莫西林时ES 做的事情是在倒排表里找到阿莫西林这一行直接拿到[Doc1, Doc3, Doc7, Doc12]计算 BM25 分数排序返回从 O(N) 变成 O(1 k)k 是命中文档数。这就是倒排索引的核心价值。二、倒排索引的骨架三大组件Lucene 的倒排索引不是一个 HashMap。它由三个精密配合的结构组成每个都在解决一个具体的工程问题。2.1 Term Dictionary FST存什么索引中所有不重复的 Token每个 Token 指向一个 Posting List 的磁盘位置。关键约束Term Dictionary 必须有序。因为查询时要做前缀匹配、fuzzy 查询、范围查询有序才能二分查找 O(logN)。根本问题Term Dictionary 放在哪里放内存一个中大型索引可能有上千万个 Token每个 Token 平均 6 字节加上指针内存轻松突破 GB 级。Lucene 的解法FSTFinite State TransducerFST 本质上是一个最小化的有向无环图把共享前缀的 Token 压缩成一份。用拼音类比Token 列表有序: amoxicillin ampicillin azithromycin 普通存法: 29 字符 FST 存法: 共享 a 和 m只存差异部分压缩率可达 50%~90%Lucene 的 FST 实现org.apache.lucene.util.fst更猛输出类型是PositiveIntOutputs存的是 Posting List 的磁盘偏移量查找操作Util.get(fst, new BytesRef(阿莫西林))返回一个Long直接跳转到.doc文件的对应位置内存占用千万级 Token 通常只需要几十 MB这意味着什么ES 把字典这个最大的内存负担用 FST 压缩到了一个可接受的水平。Term Dictionary 常驻堆内存查表不需要任何磁盘 IO。2.2 Posting List 与压缩编码这是倒排索引真正的肉——每个 Token 后面跟着的那个 DocID 列表。Lucene 的 Posting List 不只是 DocID 列表每个 DocID 条目还包含字段含义用途DocID文档编号定位文档Term Frequency (TF)Token 在文档中的出现次数BM25 词频计算PositionsToken 出现的所有位置偏移量短语查询、高亮Payloads (可选)自定义权重标签特殊打分场景Offsets (可选)起止字符位置高亮定位一个完整的数据帧示例Token 阿莫西林 的 Posting List (.doc 文件): ┌──────────────┬─────┬──────────────────────────┐ │ DocID 1 │ TF2│ Positions {0, 5} │ │ DocID 3 │ TF1│ Positions {0} │ │ DocID 7 │ TF1│ Positions {2} │ │ DocID 12 │ TF3│ Positions {0, 3, 8} │ │ ... │ ... │ ... │ └──────────────┴─────┴──────────────────────────┘压缩是关键。一个高频词比如医药场景的胶囊可能出现在几十万个文档中。如果 DocID 列表不压缩一个 Token 的 Posting 就要几 MB。Lucene 用了两种压缩策略FORFrame of Reference编码原始 DocID: [108, 110, 112, 114, 116] ↓ 做差值 差值列表: [108, 2, 2, 2, 2] ↓ 分块block每个块用最小值 位压缩 编码结果: 每 128 个 DocID 一个 blockblock 内只需要 2 bit/DocIDRoaring BitmapES 5.0 的默认 DocValues 编码把 DocID 空间切成 65536 一桶 桶 0 (0 ~ 65535): DocID 列表长 → 用数组内存紧凑 桶 128 (65536 ~ 131071): DocID 列表短 → 用 bitset交集运算用位运算两种策略互补FOR 适合紧凑的顺序存储.doc 文件Roaring Bitmap 适合内存中的集合运算filter 缓存。2.3 Skip List Block 结构倒排索引不是一个扁平的列表。Lucene 把 Posting List 组织成分块 跳表的结构.skip 文件结构L1 跳表 L2 跳表: Level 2 (跳表第二层): [Block0] ───────────── [Block3] ───────────── [Block6] │ │ │ Level 1 (跳表第一层): [Block0] ── [Block1] ── [Block2] ── [Block3] ── [Block4] ── [Block5] │ │ │ │ │ │ .doc 文件: DocIDs 0-127 128-255 256-383 384-511 512-639 640-767每个 Skip 条目记录三样东西跳到的 DocID例如跳到 DocID384跳到的.doc文件偏移量跳到的.pos文件偏移量Position 信息在另一个文件里为什么要分块// 搜索 阿莫西林 胶囊两个 Posting List 合并// 阿莫西林 → [1, 3, 7, 12, ..., 100000]// 胶囊 → [1, 4, 12, ..., 200000]两个列表做交集。普通做法双指针从头走到尾O(mn)。如果加上 Skip List发现胶囊的当前 Block 最大 DocID 当前 “阿莫西林” 的 DocID直接跳整个 Block。在高频低频词混合查询中跳表能减少 60%~90% 的无效比较。三、一次 match queryLucene 内部发生了什么设用户搜索阿莫西林 0.25g执行的是{query:{match:{generic_name:{query:阿莫西林 0.25g,operator:and}}}}Step 1: Analysis分词链路输入文本: 阿莫西林 0.25g ↓ Character Filters: 无特殊处理如果有 HTML这层会 strip 标签 ↓ Tokenizer: ik_smart ↓ Token Stream: [阿莫西林_TOKEN, 0.25g_TOKEN] ↓ Token Filters: - LowercaseFilter: 阿莫西林_TOKEN → 阿莫西林_token中文无影响 - StopFilter: 无停用词命中 ↓ 最终 Token: [阿莫西林, 0.25g]最关键的一步就是分词——Token 的粒度直接决定召回。我们真实踩过的坑分词器“阿莫西林胶囊0.25g” 的结果问题ik_max_word[阿莫西林, 西林, 胶囊, 0.25, g]西林是无意义噪音召回青霉素类无关药品ik_smart[阿莫西林, 胶囊, 0.25g]基本正确但 “0.25g” 作为一个 Token 限制了召回自定义 医药词典[阿莫西林, 胶囊, 0.25, g]最佳——添加0.25为单位词典词引申为什么我们后来要走 NER 抽取的路因为像 “0.25g×24胶囊/盒” 这种规格串分词器不可能完美切割。正确做法是 NER 把规格拆成基准量/包装数/剂型三个字段分别索引而不是让分词器猜你的业务语义。Step 2: Term 查找// Lucene 内部的大致逻辑简化for(Termterm:queryTerms){// FST 查找O(term.length())longfptermsDict.lookup(term.bytes());// 如果 Term 不存在直接跳过if(fp-1)continue;// 用返回的偏移量读取 Posting ListPostingsEnumpostingstermsEnum.postings(postingsEnum);// ...}FST 查找阿莫西林的过程从根节点开始按BytesRef逐字节走 FST 边走到终态时FST 返回累积的outputLong 类型的文件偏移量拿着这个偏移量去.doc文件 seek这一步不涉及任何磁盘随机 IO——FST 在堆内存里只有一个.doc文件的 seek 操作。这也是为什么 ES 需要足够大的 heap要装得下 FST。Step 3: Posting List 合并两个 Token 各有自己的 Posting List。operator: and意味着要取交集阿莫西林 → Posting Enum: DocID 指针 1 0.25g → Posting Enum: DocID 指针 3 合并循环伪代码 while (两个 Enum 都读到头) { if (阿莫西林.docID 0.25g.docID) { 阿莫西林.nextDoc(); // 也可能是 skipTo(0.25g.docID) } else if (阿莫西林.docID 0.25g.docID) { 0.25g.nextDoc(); } else { 命中加入结果集 两个同时 nextDoc(); } }Skip List 的加速体现在哪里当0.25g是一个低频词Posting List 短而阿莫西林是高频词Posting List 长时传统双指针阿莫西林的指针逐个往前挪大部分都是无效比较加跳表后阿莫西林的skipTo(0.25g的下一级目标)直接跳过中间的无效区间Lucene 的 BoolQuery 内部用的就是这种 Scorer TwoPhaseIterator 的组合——先快速推进 DocID 找到候选集再回头做精确匹配位置、Payload这就是 Two Phase Iterator 名字的由来。Step 4: BM25 打分每一个命中的文档Lucene 会计算 BM25 分数。公式没人会手算但理解每部分的直觉才是关键BM25(D, Q) Σ IDF(qi) × [ f(qi, D) × (k1 1) ] / [ f(qi, D) k1 × (1 − b b × |D| / avgdl) ] 参数拆解: ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ IDF(qi) log((N − n(qi) 0.5) / (n(qi) 0.5) 1) ↑ 稀有词的抓分权重。越稀有→IDF越高 f(qi, D) Term 在本文档的出现次数 ↑ 出现越多→分数越高但有饱和k1 控制 |D| / avgdl 本文档长度 / 平均文档长度 ↑ 短文档相对浓缩有加分长文档有惩罚 k1 (默认1.2) 词频饱和速度。越大 → 词频增长时分数涨得越快 b (默认0.75) 长度归一化强度。0 不管长度1 完全按比例惩罚 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━手算一组真实感的数建立直觉环境设定: 总文档数 N 500,000医药商品库规模 阿莫西林 在 5,000 个文档中出现 → n 5000 0.25g 在 50,000 个文档中出现 → n 50000 平均文档长度 avgdl 15 tokens 当前文档: Doc ID 12345阿莫西林胶囊0.25g×24粒 文档长度 |D| 12 tokens f(阿莫西林) 2 次 f(0.25g) 1 次 ━━━━ 计算 阿莫西林 对本文档的贡献 ━━━━ IDF log((500000 − 5000 0.5) / (5000 0.5) 1) log(99.0 1) log(100) ≈ 4.605 分子 2 × (1.2 1) 2 × 2.2 4.4 分母 2 1.2 × (1 − 0.75 0.75 × 12/15) 2 1.2 × (0.25 0.6) 2 1.2 × 0.85 2 1.02 3.02 阿莫西林贡献 4.605 × 4.4 / 3.02 ≈ 6.71 ━━━━ 计算 0.25g 对本文档的贡献 ━━━━ IDF log((500000 − 50000 0.5) / (50000 0.5) 1) log(9.0 1) log(10) ≈ 2.303 分子 1 × 2.2 2.2 分母 1 1.2 × 0.85 1 1.02 2.02 0.25g贡献 2.303 × 2.2 / 2.02 ≈ 2.51 ━━━━ 总分数 6.71 2.51 9.22 ━━━━从这几组数能得出三个核心直觉IDF 差距 2 倍 ≠ 分数差距 2 倍。阿莫西林IDF4.6050.25gIDF2.303但因为 TF 也不同2 vs 1最终贡献是 6.71 vs 2.51差约 2.7 倍。稀有词的实际影响力比 IDF 比值更大。短文档天然吃亏少。Doc12345 长度为 12低于平均 15分母从理论最大值 3.22 降到 3.02分数略高。b0.75 是经验值——完全不调b0太激进完全惩罚b1太激进。TF 不会无限增长。词频从 1→2 贡献增加约 60%但从 10→11 贡献增加不到 5%。这就是饱和效应k11.2 时解释了很多人的困惑“为什么复制粘贴关键词不能刷排名”Step 5: Collector 收集 TopNBM25 算完分数后不是全量排序——那太慢了百万文档。Lucene 用的是堆排序的 Collector// PriorityQueue 维护 TopN// 新文档分数 堆顶当前第 N 名→ 入堆// 新文档分数 ≤ 堆顶 → 丢弃// 复杂度全量文档 × O(log N) 而不是 O(NlogN)四、回到医药搜索一个真实 DSL 的逐层解析我们给医药商品做对码搜索时典型查询是这样的GETdrug_index/_search{query:{bool:{should:[{match:{generic_name:{query:阿莫西林,boost:3.0}}},{match:{specification:{query:0.25g 24粒,boost:2.0}}}],minimum_should_match:1}}}这条 DSL 执行时Lucene 内部做的事按顺序拆开━━ 子查询1: match generic_name 阿莫西林 ━━ 分词: [阿莫西林]ik_smart 医药词典不会切出西林 FST查表 → Posting List [D1, D3, D7, D12, ...] 每个 Doc 计算 BM25(阿莫西林) 的原始分 乘 boost 3.0 → finalScore1 ━━ 子查询2: match specification 0.25g 24粒 ━━ 分词: [0.25g, 24, 粒]注意 24粒 被切成两个 Token FST查表 → 三个 Posting List 分别查询 取并集match 默认的 OR 语义 每个 Doc 计算三个 Token 的 BM25 之和 → finalScore2 × 2.0 ━━ Bool 合并 ━━ should minimum_should_match1 两个子查询的结果做并集 最终分数 finalScore1 finalScore2这里暴露了一个关键问题specification 字段是字符串全文索引分词器把 “24粒” 切成了 [“24”, “粒”]。如果另一个商品规格是 “24片”token 是 [“24”, “片”]粒和片不匹配导致召回丢失。这就是为什么我们在规格对码场景里走了 NER 抽取的路——把规格串拆成结构化字段// 索引里的实际 mapping{specification:{type:text,analyzer:ik_smart},dosage_form:{type:keyword},// 胶囊 / 片剂 / 颗粒base_amount:{type:float},// 0.25base_unit:{type:keyword},// gpack_count:{type:integer},// 24pack_unit:{type:keyword}// 粒 / 片 / 支}这样粒和片走 keyword 精确匹配不会丢召回。分词器的边界就是你必须做 NER 的起点。五、三条实践建议拿来就能用1. 调试召回问题第一步永远看分词GET drug_index/_analyze{analyzer:ik_smart,text:阿莫西林胶囊0.25g×24粒/盒}跑完看 token 列表跟你的预期对一下。80% 的召回问题出在分词不是 query 写法的问题。2. 高频词走 filter别走 must// 不要这样算分 读 Posting List{must:[{term:{category:抗生素}}]}// 应该这样走 bitset cache不算分{filter:[{term:{category:抗生素}}]}抗生素这种类别标签在几十万文档里出现Posting List 极长。放到filter里ES 会用 Roaring Bitmap 缓存后续查询直接位运算零 IO。3. 用小索引理解 Lucene 内部结构# 检查索引文件结构——直接看哪些文件最大ls-lhS/path/to/es/data/nodes/0/indices/drug_index/0/index/# 典型输出:# _0.tim 48M ← Term Dictionary (FST 元数据)# _0.doc 120M ← Posting ListDocID TF# _0.pos 280M ← Positions位置信息phrase query 要用# _0.pay 15M ← Payloads有直观感受如果你的查询不需要 phrase 检索和高亮position 信息就是纯内存和磁盘开销。index_options: freqs可以不存 positions能省 30%~50% 的索引体积。下期预告第一篇建立了倒排索引的物理结构认知。下一篇聊Analyzer——中文分词器怎么选、ik 分词器内部机制、同义词在索引和查询阶段的不同行为以及医药领域的分词器定制实战。「搜索底层认知」系列我在医药电商做搜索工程师的输出笔记。日常跟 ES、向量检索、Agent 打交道。写这些是为了把散落的知识点串成体系欢迎讨论。