想象你是图书馆总管理员。馆里有一百万册书,读者跑来问你:“找所有提到'北京'的书。”如果一本本翻,几个月都翻不完。聪明的你会提前做一套索引卡片柜:每张卡片正面写一个关键词,背面按顺序写下所有出现过这个词的书号。读者报一个词,你直接抽出对应卡片。这套“词→书号”的卡片系统,就是搜索引擎的倒排索引。 但卡片不是凭空出现的:你得先决定把句子拆成哪些词——分词;把每个词对应的书号串起来——倒排表设计;卡片太多柜子放不下——索引压缩;每天有新书进来不能重抄全部卡片——增量索引;偶尔换新馆彻底翻新——全量重建;读者要立刻搜到刚上架的书——实时策略;馆大书多人少——工程实践。整篇文章,就是讲你如何从零建起并不断优化这套卡片系统。
对AI从业者而言,索引构建决定了搜索与RAG系统的查询效率和成本。掌握它,你才能设计出支撑千万级文档、秒级更新的生产级检索系统。
可视化演示
本节配有交互动效(进入视口后自动加载,约 3KB)。
倒排索引构建流程 ——文档→分词→词项→倒排表 词典:所有唯一词项集合 倒排表:词项→文档ID列表 score = TF-IDF = tf * log(N/df),BM25改进版
1. 分词预处理
图书管理员整理“北京”这张卡片前,必须先判断书里哪些词值得做卡片。中文书没有空格,“我爱北京天安门”这句话,管理员得凭经验切成“我”“爱”“北京”“天安门”。切得好,整套卡片系统才准确;切得烂,“天安门”被切成“天安”和“门”,读者按“天安门”查就永远找不到这本书。分词看似是细节,其实是索引构建的第一道地基。
这就是中文检索的第一道工序——分词。英文单词天然以空格分隔,中文却需要额外的工具:一套常用词词典加匹配规则。系统扫描文本时从左到右尝试最长匹配:“北京”比“北”长,优先切出“北京”。分词器还会顺势过滤掉“的”“了”这类没有检索价值的停用词,减轻后续倒排表的负担。分词粒度直接决定检索质量:切得太粗漏召回,切得太细噪声大。
分词完成后,需要统计词在文档中的分布情况,其中最基础的统计量是词频(Term Frequency)。它衡量一个词在单篇文档里的重要性:出现得越多,说明它对这篇文章越有代表性。公式如下:
图书管理员会把词频比例记在卡片角落:某本书里“北京”出现比例越高,这张卡片在检索“北京”时权重越大。分词做得好,每张卡片都准确指向真正相关的内容;分词偷懒,整套卡片系统从源头就失真了。
- 识别分子词 t 在文档 d 中的出现次数,管理员数一数“北京”在这本书里出现了几次。
- 分母求和把文档 d 中所有不同词的出现次数加总,得到全文总词数。
- 整体相除分子除以分母,得到词 t 在文档 d 中的占比词频,范围在 0 到 1 之间。
2. 倒排表设计
分词完成后,管理员要为每个关键词制作一张“位置卡”:卡片正面写关键词,背面按书架顺序写下一串书号。“北京”这张卡片背面写着 [1, 3],说明第1排和第3排书柜都出现过它。所有卡片按词条排序,就构成一套完整的“词→书号”反向定位系统——这就是搜索引擎的倒排索引。
倒排表的核心是 Posting List,即每个词对应的一个文档 ID 列表。构建时,每处理一篇文档,就把文档 ID 追加到该词对应的列表里,最终按 ID 升序排序,方便后续合并与压缩。这个过程和正排索引正好相反:正排记录“每本书包含哪些词”,倒排记录“每个词出现在哪些书”。
描述一个词在全部文档中的重要程度,需要文档频率(Document Frequency):
管理员看着“的”“是”这种卡片串得老长却没什么用,心里明白:高频词不一定是关键词。倒排表设计不仅要把结构建对,还要为后续压缩和权重计算打基础——哪些词该好好存,哪些词可以轻量处理,都从这张“词→书号”卡片开始规划。
- 全集 D全部文档编号的集合,代表图书馆里所有书的编号。
- 筛选条件从 D 中挑出所有包含词 t 的文档编号。
- 取大小对筛选结果计数,得到词 t 的文档频率。
3. 索引压缩
索引卡片越来越多,柜子快塞不下了。管理员发现一个规律:书号是按顺序写的,[1, 3, 8, 20] 这张卡片不需要完整存四个数,只要记第一个书号,再记后面的“间隔”——1、2、5、12。间隔比原编号小得多,写起来自然省地方。这就是倒排索引压缩的朴素起点。
利用 docID 的有序性做差值编码,是压缩的核心思路。原始文档 ID 可能上千万,而相邻文档 ID 的差值往往很小,小数字可以用更少的字节表示。变长字节编码(VByte)就是按需分配字节:小数值 1 个字节就能存下,大数值才用更多字节。差值编码的公式非常直观:
除了差值法,位图压缩用 0/1 位串标记每个文档是否包含该词,适合分布密集的列表;主流检索引擎的底层综合使用这些方法。卡片柜经过这轮“缩写改造”,空间占用大减。索引压缩不只是省磁盘——更小的数据读取更快,内存能放更多索引块,管理员终于不必频繁扩建柜子。
- 首个基准列表第一个文档 ID 原样保存,作为累计起点。
- 相邻求差第 i 个文档 ID 减去第 i−1 个,得到差值。
- 差值序列整体公式,解码时从第一个基准开始逐个累加还原。
4. 增量索引
图书馆每天都有新书进馆,如果每进一本就把全部卡片重抄一遍,管理员早就累倒了。聪明的做法:新书单独做一套小卡片,先夹在前台的小抽屉里,读者检索时同时翻大柜和小抽屉;攒到一定量,再把小卡片整理合并进大柜。这就是增量索引的雏形。
系统为新增文档构建独立的“段”(Segment),段之间彼此独立、可并发查询。新文档的索引延迟从“全量重做”的几小时降到“单独构建”的几秒。但段不是越多越好——一次查询要扫描所有段再合并结果,段多了反而变慢,因此需要定期段合并。合并两个段的倒排列表时,长度计算不是简单相加,因为同一文档可能在两个旧段里都有记录,需要去重:
小卡片夹、大卡片柜、加上定期的“合卡仪式”,图书馆在不停业的前提下维持了检索的实时性和整洁度。增量索引的核心哲学就是:把“全量重来”拆成“小步快跑 + 定期大扫除”。
- 两段长度两个旧段的倒排列表长度。
- 交集去重两个段中同时包含的文档 ID 取交集,计数为重复部分。
- 合并结果两段长度相加减去重复,得到新列表长度。
5. 全量重建
有时候图书馆要搬家到新馆舍,旧卡片布局全乱了,管理员决定推倒重来:把所有书按新规则重新拆词、重新做卡。虽然费时费力,但重建出的卡片系统最干净、顺序最规整,没有任何历史包袱。这就是全量索引构建。
全量构建从全部文档出发:扫描所有内容 → 分词 → 收集词条 → 排序 → 生成倒排表。流程简单粗暴,结果一致性最好,适合首次建库、或索引结构升级后的整体重建。但文档量巨大时,内存根本放不下所有中间结果,这时就需要外部排序。
外部排序的做法是:把数据切分成多个有序的临时段,再通过多路归并合成最终索引。归并趟数决定磁盘 IO 次数,公式为:
全量重建是“慢但彻底”的工程手段。它不追求实时,而是追求最终一致性:所有卡片从同一套规则重新生成,不会有旧卡和新卡规则打架的问题。生产环境中,全量构建通常放在低峰期批量执行,作为增量索引的“校准基准”。
- K 个初始段内存放不下时,切分得到的临时段数量。
- M 路归并每趟最多同时读取 M 个段参与合并。
- 趟数计算每趟把段数降为原来的 1/M,取对数得到所需趟数。
6. 实时策略
读者刚把新书放上架,转身就来前台问“这本书能搜到吗?”如果答案要等今晚的全量重建,读者早就走了。管理员需要让新书“即刻可查”:前台放一张临时登记表,新书信息先写上去,查询时先看临时表,再定时把登记表归档到主卡片柜。这就是实时索引的朴素模型。
技术实现上,核心是内存索引缓冲。新文档先写入内存中的倒排结构,查询路径合并检查内存索引和磁盘段。内存索引积累到一定程度——文档条数达到上限,或占用内存超过阈值——就触发一次刷新,把内存中的倒排表落盘为新的小段。刷新时机的选择是实时性与写入吞吐的平衡:
这套机制让索引达到“近实时”:新文档从写入到可见,延迟通常控制在秒级到分钟级,远快于全量重建。临时登记表、定时归档、小段落盘,让图书馆在高频进书时依然保证“上架即能搜到”。
- 文档数阈值内存索引允许积累的最大文档条数。
- 内存阈值允许占用的最大内存字节数。
- 取最小哪个先到就触发刷新,避免内存溢出或索引过大。
7. 工程实践
图书馆规模大了,光靠一个管理员做卡片显然不够。馆长把书库按区域分给多个管理员,每人负责自己片区的拆词和做卡,最后把各片区的卡片汇合排序,合并成总卡片柜。这就是索引构建的并发化思路。
全量重建时把文档集合切成多个分片,每个分片由一个 worker 独立构建局部倒排表,最后做一次归并。并发数越高构建越快,但内存和 CPU 消耗也越大,需要根据机器规格动态调整。构建耗时的估算公式很直观:
工程上还要引入资源控制:限制构建进程的内存上限,防止与在线查询抢资源;同时用监控指标盯住构建速度、段数量、索引大小,及时发现合并积压等问题。从一个人单干到团队协作,索引构建的工程化就是把“会做卡”升级为“稳定、可控、可观测地做卡”。
- 文档总数待构建的全部文档数量。
- 并行度并发 worker 的数量,相当于管理员人数。
- 总吞吐与耗时每个 worker 每秒处理 S 篇,总耗时等于文档数除以总处理能力。
AI 应用场景
电商站内搜索的新品秒级可见
阶段:数据预处理与索引维护
解决的问题:新上架商品搜不到。传统方案直接扫描数据库按名称模糊查询,数据量大后速度慢且不支持多词组合。采用增量索引+实时策略:新品先写入内存倒排缓冲,秒级刷新为小段,搜索立即可见;定期段合并控制查询开销。
具体案例:某电商平台每天数千款新品上架,运营要求上架 1 分钟内可搜。工程师用内存索引缓冲加 2 分钟刷新周期实现近实时检索,凌晨低峰期执行段合并。
日志检索平台的空间优化
阶段:全量构建与编码优化
解决的问题:海量日志(每天数十亿条)全文检索的磁盘成本与构建耗时。全量重建配合差值编码和位图压缩,把索引体积降到原始日志的 10%-20%;低峰期定时全量重建保证数据一致性。
具体案例:SRE 团队搭建日志平台,每天新增 TB 级日志。采用增量索引实时入库,夜间对昨日日志做压缩合并,月索引体积减少约 60%。
推荐系统的标签召回
阶段:特征索引构建
解决的问题:物品标签到候选集的快速匹配。把标签当作“词”、物品 ID 当作“文档 ID”构建倒排索引,用并发构建加速千万级物品的索引生成,查询用户兴趣标签可毫秒级召回候选池。
具体案例:短视频推荐中每个视频打上“分类 + 话题”标签。工程师将标签映射到视频 ID 的倒排表,用户刷新时按兴趣标签并行查询多个列表再合并排序。
Python 演示
# -*- coding: utf-8 -*-"""迷你倒排索引构建器: 词典分词 + 倒排表 + 增量合并"""import collections DIC = ["人工智能", "搜索引擎", "天安门", "北京", "索引", "我们", "学习", "构建"] def tokenize(text, dic): i = 0 toks = [] while i < len(text): best = text[i] for w in dic: if text.startswith(w, i) and len(w) > len(best): best = w toks.append(best) i += len(best) return toks def build_index(docs): postings = collections.defaultdict(list) for doc_id, text in enumerate(docs): for tok in set(tokenize(text, DIC)): postings[tok].append(doc_id) for tok in postings: postings[tok].sort() return dict(postings) def merge_segment(base, extra): merged = {} for tok in set(list(base) + list(extra)): merged[tok] = sorted(set(base.get(tok, []) + extra.get(tok, []))) return merged def query(postings, word): return postings.get(word, []) def main(): docs = [ "我们学习人工智能索引构建", "搜索引擎使用倒排索引", "北京天安门位于北京", ] idx = build_index(docs) print("=== 构建结果 ===") for w, pl in sorted(idx.items()): print("词: %s -> 文档%s" % (w, pl)) print("查询'北京' ->", query(idx, "北京")) extra = build_index(["天安门在北京"]) merged = merge_segment(idx, extra) print("增量合并后'天安门' ->", query(merged, "天安门")) if __name__ == "__main__": main()
def tokenize(text, dic):定义分词函数,按词典做正向最长匹配text.startswith(w, i)从位置 i 开始尝试匹配词典中的词,命中则更新最长候选postings[tok].append(doc_id)把文档 ID 追加到该词的倒排列表,核心构建动作for tok in set(tokenize(text, DIC)):用 set 去重,避免同一篇文档重复写入merged[tok] = sorted(set(...))增量合并两个段并对文档 ID 去重排序postings.get(word, [])查询不存在的词时返回空列表,保证调用安全
常见误区
误区数据库里的索引(B+树)和搜索引擎的倒排索引都叫“索引”,应该是一样的东西。
正解两者数据结构与目标完全不同。数据库 B+树是“键→记录位置”的有序树,用于快速定位单条记录;倒排索引是“词→包含该词的文档集合”,用于检索匹配一组关键词的文档。想象图书管理员:B+树是“按书号查书在哪个书架”,倒排表是“按关键词找出所有相关书号”。用错会导致系统设计南辕北辙。
为何常见:两者都叫“索引”且都能加速查找,初学者很容易混淆概念边界。
误区分词切得越细召回越高,干脆切到每个单字,一定能覆盖所有查询。
正解单字粒度召回高但精度差。“天安门”切成“天”“安”“门”后,搜“天安门”反而匹配不到连续短语,噪声也变大。合理做法是用词典最大匹配保证长词完整,同时保留少量有区分度的单字。分词粒度要和检索需求匹配,不是越细越好。
为何常见:只看“召回率”忽略“精确率”,觉得切碎了总能命中更多结果。
误区增量索引建好后就不用管了,新段一直加下去,反正构建很快。
正解段越多查询越慢——一次查询要读所有段的倒排表再合并结果。必须定期段合并,把小段合并成大段,顺带物理清除删除标记。就像前台小卡片攒太多,找起来反而慢,定期并入大柜才高效。
为何常见:增量构建快,让人误以为“快”等于“永远最优”,忽视了段膨胀的代价。
误区压缩省空间但费时间,构建时不该用压缩。
正解差值编码和 VByte 这类方法反而常带来构建加速:压缩后数据体积小,磁盘读取和内存搬运更快,解码的 CPU 开销通常远小于 IO 节省。小数字还能提高缓存命中率。压缩省空间和提性能可以兼得。
为何常见:直觉上“压缩 = 解压 = 慢”,忽略了 IO 瓶颈往往比 CPU 解码更突出。
面试题
Q1. 倒排索引最核心的结构是?
- A.词到文档列表的映射← 正确答案
- B.文档到词的完整映射
- C.文档编号到大小的映射
- D.词语到字频统计的映射
答案:A。倒排索引的本质就是“词 → 包含该词的文档列表”,像卡片正面写词、背面写书号。B 是正排索引,C、D 与倒排无关。
Q2. 中文检索前必须做的关键处理是?
- A.全文加密与压缩存储
- B.分词得到有效词条← 正确答案
- C.按行号重建正排表
- D.对文档做镜像备份
答案:B。中文句子没有天然空格,必须先通过词典匹配等方法切分成词条,否则倒排表无法正确构建。压缩、镜像都是后续优化,不是前置关键步骤。
Q3. 增量索引的主要意义是?
- A.减少新文档的索引延迟← 正确答案
- B.消除全部索引压缩
- C.替代全量重建与合并
- D.使倒排表永远不排序
答案:A。增量索引只处理新文档,单独建段,把延迟从全量重做的数小时降到几秒。但增量不能替代段合并,也不能替代压缩,C 和 B 都是误解。
Q4. docID 列表 [1, 3, 8, 20] 做差值编码后是?
- A.[1, 2, 5, 12]← 正确答案
- B.[0, 2, 5, 12]
- C.[1, 3, 8, 20]
- D.[2, 5, 12, 20]
答案:A。第一个数 1 原样保留,后面依次为 3−1=2、8−3=5、20−8=12,所以结果是 [1, 2, 5, 12]。差值后数字变小,便于变长字节压缩。
Q5. 段合并的主要目的是?
- A.减少段数提升查询效率← 正确答案
- B.让新文档立即可见
- C.压缩单个文档体积
- D.增加词典匹配精度
答案:A。段越多,查询要扫描的倒排表就越多。段合并把小段合成大段,减少段数,同时物理清除删除标记,提升整体查询效率。
Q6. 实时索引常用的策略有哪些?
- A.内存倒排缓冲区← 正确答案
- B.周期刷新落盘分段← 正确答案
- C.查询时全量扫文档
- D.阈值触发段刷新← 正确答案
答案:ABD。内存缓冲 + 周期/阈值触发落盘是近实时索引的标准做法。查询时全量扫描文档是顺序检索,不是实时索引策略,C 错误。
Q7. 全量重建时内存放不下全部数据,正确的做法是?
- A.丢弃部分低频词条
- B.外部排序多路归并← 正确答案
- C.只索引文档标题
- D.改用单线程逐条构建
答案:B。内存不足时先切分成有序临时段,再用多路归并合成最终索引,保证所有词条都被处理。丢弃词条或只索引标题都会丢失信息。
Q8. 以下哪些属于索引压缩方法?
- A.差值编码← 正确答案
- B.变长字节编码← 正确答案
- C.位图压缩← 正确答案
- D.全文冗余备份
答案:ABC。差值编码利用 docID 有序性缩小数字,变长字节按需分配存储空间,位图用 0/1 位串表示文档是否含词。全文备份是冗余不是压缩。
Q9. 近实时索引期望达到的效果是?
- A.新文档秒级可被搜到← 正确答案
- B.每秒重建全部索引
- C.索引完全不做压缩
- D.查询强制全表扫描
答案:A。近实时意味着新文档写入后,经过短暂的内存缓冲和刷新,秒级到分钟级内可被检索到,而不是等待全量重建。每秒重建全部索引既不现实也无必要。
Q10. 关于段中删除文档的处理,正确的说法是?
- A.先逻辑标记跳过← 正确答案
- B.合并时物理剔除← 正确答案
- C.删除后立即重建全量
- D.删除只需改正排表
答案:AB。删除文档先用标记逻辑跳过,避免频繁重写磁盘;等到段合并时再物理剔除,兼顾效率与一致性。每次删除都重建全量代价过高,D 忽略了倒排表也需要同步。