2026/9/23 4:46:41

从倒排索引到AI流式输出:搜索引擎技术栈全链路拆解

从倒排索引到AI流式输出:搜索引擎技术栈全链路拆解 很多做技术朋友问我想理解头部搜索引擎到底是怎么工作的最简单的切入点是什么。我通常会反问一句你上一次在百度搜索框里敲下关键词、按下回车到结果页完全呈现中间到底发生了什么大部分人会愣一下然后说就是一个搜索框嘛。但如果你沿着这个框往下拆会发现它背后是一整套由数据管道、倒排索引、Ranking模型、实时检索架构组成的庞大工程系统。这篇文章我想以一个后端工程师的视角把这条链路从离线到在线、从索引到排序、从传统检索到AI流式输出一层一层扒开来讲。内容主要面向三类人一是想进入搜索推荐领域的后端或算法工程师二是正在准备大厂搜索相关岗位面试的同学三是对技术栈选型、系统设计感兴趣、想给自己项目里塞一个搜索能力的独立开发者。我会尽量用工程实现逻辑而不是神奇算法的角度来讲毕竟搜索这个领域真正难的不是某一个模型而是把几十个模块在毫秒级约束下稳定地串起来。1. 别把搜索引擎当成一个框先拆清它的整体分层搜索引擎最容易被低估的地方就是它看起来太简单了。一个输入框、一个按钮、十来个结果链接好像谁都能做。但你要是在本地电脑上对一个几百万行的文本文件做一次grep再想想百度索引的是千亿级别的网页就会明白这完全是两个物种。grep是从头到尾扫一遍搜索引擎没这个奢侈它必须提前把数据加工成某种适合快速查找的结构这就是索引。所以理解搜索引擎技术栈的第一件事是建立一个分层心智模型。整个系统可以被粗暴地切成离线和在线两条大线外加一条贯穿两边的效果评估闭环。模块离线还是在线要解决的核心问题网页抓取与内容解析离线为主互联网内容怎么变成结构化文档索引构建离线为主文档怎么变成可快速查询的倒排结构Query理解在线用户输入的一句话到底想表达什么召回在线从海量索引里快速捞出可能相关的候选粗排 / 精排 / 重排在线候选集里谁应该排在最前面前端渲染与交互在线结果怎么展示、用户怎么进一步交互日志与评估闭环怎么知道今天比昨天做得更好这张表建议背下来因为它基本也是搜索岗位面试时的地图。很多人一上来就聊排序模型多牛、BERT多强但模型只是精排这一小环节上游召回不给力下游排序再强也巧妇难为无米之炊。提示搜索引擎的难点从来不是某个算法不够聪明而是每个环节都必须足够快且足够稳。理解了这句话后面所有工程细节就都串起来了。1.1 一个查询请求从输入框到结果页链路是怎样的我用一个最简单的例子模拟整条链路用户输入北京到上海高铁。第一步Query理解模块会把这句话拆成一个一个词项北京、上海、高铁同时做纠错、归一化、意图识别。这里机器要判断用户是想查时刻表、票价还是想看沿途站点这个判断结果会直接影响后续召回策略。第二步系统会把拆好的词项丢给索引层做召回。倒排索引返回的是包含北京、上海、高铁这些词的所有文档ID集合然后对多个词项的文档集合做交集、并集操作得到候选文档队列。这一步基本不做什么复杂计算拼的是数据结构和存储带宽但它是整个系统的底座。第三步候选集进入排序流水线。先经过一个很轻量的粗排模型把几万候选砍到几百再进入精排模型从几百里挑出几十个之后可能还有重排环节处理多样性、时效性、商业化约束最终生成用户看到的搜索结果。第四步前端拿到结果后渲染页面。用户如果点了某条结果行为会被记入日志第二天系统再用这些日志训练新的排序模型。理解这条完整链路比只盯着某一个模型重要得多。1.2 各模块的耗时预算为什么搜索不能慢慢算搜索场景下用户对延迟极其敏感。业界常说一个经验值搜索结果如果超过一两秒才出来用户会明显感觉卡了。所以整个在线系统会被设计成预算制每个模块能花多少毫秒基本是提前定死的。假设一次搜索的整体目标控制在300毫秒以内大概是这样的分配逻辑环节耗时预算参考值说明网络与接入层30ms前端到后端、网关转发Query理解20ms分词、纠错、意图识别多路召回80ms倒排、向量、缓存并行发出去粗排30ms轻量模型快速过滤精排80ms复杂模型跑头部候选聚合与重排20ms规则干预、结果多样性调整返回与渲染40ms序列化、网络回包、前端绘制实际生产肯定比这个复杂不同业务分配也不同但核心思路一致每个模块必须在自己预算内完成超时的模块要么降级、要么跳过绝不能让用户体验到无限等待。这也是为什么搜索引擎里到处是超时控制、熔断、降级的影子。2. 倒排索引为什么说它是搜索引擎的底裤如果说搜索引擎有什么数据结构是灵魂级的那一定是倒排索引。所有关于排序模型的讨论都建立在能快速找到候选文档这个前提上。没有倒排索引再强的模型也只能面对一个空空的候选集。2.1 正排表 vs 倒排表理解为什么搜索要用倒排为了讲清楚倒排索引我先搬出另一张表正排表。正排表的概念和数据库表很像每一行是一篇文档列是文档的属性包括文档ID、标题、正文、URL、发布时间等。如果你想知道文档3里包含哪些词正排表一次就能查到。但搜索引擎的查询往往是反过来的用户输入一个词我们要知道这个词出现在哪些文档里。这个问题在正排表上会变成一次全表扫描海量数据下完全不可行。倒排表就是把这张表翻过来以词项为主键每个词项对应一个包含该词的文档ID列表。词项倒排列表文档ID列表北京[1, 4, 889, 1024, ...]上海[1, 55, 887, 1024, ...]高铁[4, 887, 1024, 5678, ...]当用户搜北京 高铁时系统只需要取出北京和高铁两个倒排链求个交集就能快速得到候选文档。正是这个以词查文档的反向结构让海量文本检索成为可能。如果用代码实现一个最简版本可以这样理解from collections import defaultdict # 原始文档集 docs { 1: 北京到上海的高铁票, 2: 上海迪士尼游玩攻略, 3: 高铁动卧体验报告, } # 构建倒排索引简化版未做分词细节处理 inverted_index defaultdict(set) for doc_id, text in docs.items(): terms text.replace(的, ).replace(了, ).split() for term in terms: inverted_index[term].add(doc_id) # 查询包含上海的文档 print(inverted_index[上海])真实搜索引擎里的分词、去停用词、词权重计算肯定要复杂得多但这个骨架就是倒排索引的精髓提前把词到文档的映射关系算好查询时只做集合操作。2.2 倒排链的存储压缩与快速求交倒排索引听起来简单但在千亿文档的规模下一个高频词的倒排链可能有上亿个文档ID如果每条ID都用一个int32甚至int64存存储成本会爆炸。所以工程上会有大量压缩手段。最常见的做法是差分编码。文档ID一般不存绝对值而是存和前一个ID的差值。比如ID序列[1000, 1001, 1005, 1030]差分为[1000, 1, 4, 25]这些差值往往很小可以用更少的比特位存储。再配合变长编码小数值占一个字节大数值占多个字节整体压缩率非常可观。另一个常用手段是跳表。倒排链是有序数组求两个词项的文档交集时可以用双指针或者带跳跃指针的技术快速跳过不匹配的区间避免完全遍历长链。更高阶的方案还有位图Bitmap、Roaring Bitmap 等。说实话这块在面试里问得很细因为它是搜索引擎最抠性能的地方。2.3 索引更新的工程难题全量、增量、近实时倒排索引不是建一次就完事的互联网内容天天在变新闻页下线、论坛帖子更新、新网页不断产生。如果每次变化都重建一份全量索引成本不可接受。业界常规方案是全量增量结合定期用离线任务重建一份全量索引比如每天或每几小时一次同时在线内存里维护一份增量索引新内容先写入增量索引查询时把全量索引和增量索引的结果合并。当增量索引变得很大时再把它合并进全量索引。这里有个很经典的工程概念叫段Segment。很多开源搜索引擎比如 Lucene就是把索引拆成很多小段新文档写进新段后台定期把小段合并成大段删除文档时只打标记不物理清理。头部搜索引擎的工程实现虽然不会完全照搬但核心思路殊途同归用空间换时间用最终一致性换取可用性。注意搜索引库里没有绝对实时这个概念更多是近实时。你发的帖子能在几秒到几分钟内被搜到这才是常态。3. Ranking模型演进从BM25到LTR再到深度语义排序索引解决的是有哪些候选Ranking模型解决的是谁排前面。这一块是搜索技术栈里迭代最快、最热闹的地方也是最容易被外行误解的地方。3.1 召回和排序为什么必须分开先求全再求准很多初学者会问为什么不能直接让一个超强模型把所有文档都算一遍分数答案是算力不允许。假设索引里有10亿篇文档用户搜北京到上海高铁理论上包含北京上海高铁三个词的文档可能有几十万篇。如果精排模型在每篇文档上都跑一次深度神经网络按每篇几毫秒算一次搜索要跑几百秒这是完全不可接受的。所以系统必须把过程拆成两步第一步召回用非常廉价的算法从海量文档里快速挑出看起来有点相关的几千篇或几万篇第二步排序只对这几万篇里进一步算更精细的分数。这就是漏斗模型。阶段候选规模模型复杂度单请求耗时倒排/向量召回万级低词匹配/向量近似几十毫秒粗排千级低到中轻量树模型几十毫秒精排百级高深度模型几十到上百毫秒重排十级中业务规则/小模型十几毫秒这个漏斗结构是所有现代搜索引擎的骨架。你去看任何一家大厂的搜索架构分享基本逃不出这个模式。3.2 从BM25到LTR排序模型为什么不能只看词频经典的排序模型是BM25它本质上是一个词频-逆文档频的打分公式。核心思想很直观一个词在文档里出现得越多文档和查询越相关但如果这个词在几乎所有文档里都出现那它对区分相关性的贡献就很小。比如的字到处都是单独出现说明不了什么。BM25公式里有几个可调参数控制词频饱和度和文档长度归一化这里不展开推导。它的优点是计算极快、无需训练、可解释性强所以至今仍是很多搜索系统的底线策略也是粗排阶段的常见选择。但它解决不了语义问题。用户搜苹果可能想买手机也可能想买水果搜怎么退票和退款流程是同一个意思但字面完全不一样。BM25面对这种情况会力不从心。于是有了学习排序Learning to RankLTR。做法是收集大量训练样本样本包含查询、文档、特征和标注用户点击、人工标注然后训练一个模型去预测这个文档和这个查询有多相关。模型可以是GBDT、LambdaMART也可以是深度神经网络。LTR的关键不是模型多复杂而是特征和训练目标怎么设计这是真正的壁垒。3.3 精排模型的服务化特征、模型、算力之间的倒三角精排模型就算再强也必须被塞进几十毫秒的预算里。所以工程上要做很多事第一特征平台。排序特征可能有上千维包括文本相关性特征、文档质量特征、历史点击特征、时效性特征、个性化特征。这些特征一部分离线算好存起来一部分在线实时计算两者要能无缝拼接。第二模型推理加速。深度模型在CPU上跑慢GPU上跑快但GPU资源贵。所以工程上会做模型裁剪、量化、蒸馏还会把多个请求的样本拼成一个batch并行推理尽量提高吞吐。第三降级策略。精排模型服务如果超时或者抽风系统必须能退回到粗排结果甚至BM25结果不能让用户看到白屏。这种上游强依赖、下游可降级的设计是搜索架构里最常见的容灾哲学。4. 实时检索架构从Query分析到结果聚合的超时预算前面讲的是离线加工和模型逻辑这一章回到实时链路上。一次真实搜索请求打过来后端不是单线程跑一个函数那么浪漫更像是一个分布式调度系统在毫秒级内完成一次多路并发查询。4.1 一次检索请求内部为什么是并行多路召回倒排召回是核心但不是唯一召回通道。现代搜索引擎还会同时发起其他召回策略倒排召回严格按词项匹配拿候选。向量召回把query和文档都embedding成向量用近似最近邻检索找语义相似的文档。热点召回从缓存里直接捞高热内容。时效召回专门召回最新发布的新闻、帖子。这些召回通道是并行执行的这就是经典的fan-out/fan-in架构。协调节点把请求同时发给多个检索分片和召回通道每个分片各查各的然后在协调节点汇总去重。为什么要并行而不是串行因为串行会把整个链路的时延加在一起而并行链路的耗时基本等于最慢的那个通道。只要预算控制得好多路召回增加的延迟很小但召回率和效果提升明显。4.2 超时预算全链路都在卡时间在线检索架构里有一个贯穿全局的设计约束每个环节都必须有超时时间没有超时时间的模块是危险的。为什么因为只要有一个下游服务变慢调用它的人会一直等线程池会被占满最终引起雪崩。我见过不少团队一开始只是图省事给下游调用设置一个足够大的超时时间结果线上一个小抖动就把整条链路拖死。搜索引擎在这种事情上的处理方式非常凶悍宁可结果不完美也不能让用户无限期等下去。场景处理方式向量召回超时等它到超时点用倒排结果顶替精排服务超时直接返回上一级粗排结果Query理解超时用最简单的默认分词结果某个索引分片超时把它对应的部分结果丢弃用其他分片结果搜索系统甚至会做短超时优先策略先给下游一个很小的超时值比如20ms如果下游没返回再给一个宽限时间比如50ms。这种两阶段超时能在尽量等结果和防止拖死之间取平衡。说实话这套超时预算体系是搜索架构里最值得后端工程师偷师的智慧。4.3 大模型搜索结果的SSE流式输出与中断abort机制最近两年搜索引擎里又多了一个新的角色大模型生成式回答。用户搜一个问题系统不再只给一堆蓝色链接而是直接基于搜索结果生成一段答案。这带来了一个全新的工程问题——生成答案不可能几百毫秒内完成大模型生成一段话可能需要几秒甚至十几秒。传统HTTP请求是一次请求一次完整响应用户如果等到答案全部生成完才看到第一个字体验会非常差。所以现在主流方案是SSEServer-Sent Events流式输出服务端把生成的token一块一块推给前端前端每收到一块就渲染一块看起来就像AI在一边打字一边回答。一个典型的实现思路是前端用fetch发起请求拿到ReadableStream在while循环里读取并解析SSE格式的数据。同时创建一个AbortController把这个signal传给fetch。一旦用户点了某条搜索结果或者重新输入了一个新问题前端就立刻调用abort()断开连接。const controller new AbortController(); async function fetchAnswer(query) { const response await fetch(/api/search/answer, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ query }), signal: controller.signal, }); const reader response.body.getReader(); const decoder new TextDecoder(); while (true) { const { value, done } await reader.read(); if (done) break; const chunk decoder.decode(value, { stream: true }); // 解析 SSE 的 data 字段并渲染 renderChunk(parseSSE(chunk)); } } // 用户点击链接或发起新搜索时 function stopStreaming() { controller.abort(); }服务端收到abort信号后要主动把这次生成任务取消掉释放GPU和线程资源而不是继续傻傻生成完一整段没人看的文字。这套流式输出中断取消的交互逻辑是搜索与大模型结合后非常重要的技术栈能力。提示很多做AI应用的人只关注prompt写得好不好却忽略了交互层的实时渲染和资源释放。SSE和abort看起来是前端知识实际上决定了整个AI搜索产品的服务端资源利用率和成本。5. 从一次失败的搜索体验反推工程指标可观测性、容灾与效果评估搜索引擎不是上线即完事它更像一个需要持续调优的生命体。而调优不能靠感觉必须靠一整套指标体系和工程设施。5.1 线上检索质量如何被量化你可能觉得搜索结果好不好是主观问题但工程上必须有量化口径。搜索引擎的核心指标大致分两类延迟类和效果类。延迟类很好理解平均耗时、P90/P99耗时、超时率、错误率。效果类稍微复杂一点比如无结果率query返回0条结果的比例、首条点击率、点击位置分布、搜索后跳出率等。还有一个很重要的口径叫搜索满意度通常通过用户在结果页上的二次行为来判断点了就满意、点了又马上返回可能不满意、完全不点也可能不满意。离线层面会有另一套指标NDCG、MRR、RecallK。这些指标在训练和评测模型时用但它们和线上真实用户满意度并不完全等价。所以大厂普遍的做法是离线评测在线实验双轨并行离线过不了关的模型不许上线离线过了关的模型还得去线上小流量试跑。5.2 服务降级与容灾搜索不是所有模块都必须活着搜索系统依赖很多外部组件索引服务、模型服务、配置中心、缓存。一旦某个组件出问题怎么办好的架构会提前设计好降级路线。故障点降级方案Query理解服务挂了放弃高级意图识别直接字面分词向量召回服务挂了只走倒排召回精排模型服务超时降级到粗排结果粗排服务超时直接按BM25分数排序广告服务异常只出自然结果不出广告配置中心不可用使用本地缓存配置这些降级逻辑不是等到故障发生才临场写的而是在开发时就要反复演练。Google和百度这种体量的搜索引擎都有一个专门的方向叫稳定性做的事情就是保P99、防雪崩、做容灾切换。多机房部署、流量调度、数据副本这些基础设施看起来不性gan但没有它们一切算法都是空中楼阁。5.3 A/B实验平台是搜索迭代的基础设施搜索迭代特别频繁今天优化一个排序特征明天调整一个召回通道怎么知道改动到底有没有效答案是做A/B实验。把线上流量随机分成两组一组走旧策略一组走新策略然后对比指标。这个机制说起来简单做起来很难。搜索实验有几个特有的坑用户行为不是完全独立的一个人可能被分到实验组又分到对照组体验不一致。实验之间会互相干扰比如同时改了召回和排序结果很难归因。单日指标波动大需要积累足够样本量和置信度才有意义。所以大厂一般会建实验平台支持分层分桶、参数配置快速生效、实验报告自动计算。搜索工程师的日常很大一部分就是在实验平台上做实验、看数据、决定是否全量。6. 如果你想动手复刻一套迷你搜索引擎怎么开始讲了这么多架构和模型如果你不上手写点东西很容易变成听懂了但不会做。我个人建议任何人想理解搜索技术栈都应该自己动手捏一个迷你搜索引擎。6.1 先实现一个最简单的倒排索引与BM25打分不用做网页爬取先拿几篇自己手写的文档做实验。构建倒排索引实现查询再用BM25打分排序。这个过程能让你把索引和排序从抽象概念变成手里的代码。一个很小的Python示例就能跑通流程import math from collections import Counter docs { 1: 北京到上海的高铁票可以提前预订, 2: 上海虹桥站高铁换乘指南, 3: 北京公园门票价格一览, } # 简化分词按空格和常用停用词切分 def tokenize(text): for w in [的, 可以, 了]: text text.replace(w, ) return text.split() # 构建倒排索引 inverted {} doc_terms {} for doc_id, text in docs.items(): terms tokenize(text) doc_terms[doc_id] terms for term in set(terms): inverted.setdefault(term, []).append(doc_id) N len(docs) def bm25(query, doc_id, k11.5, b0.75): score 0.0 doc_len len(doc_terms[doc_id]) avg_len sum(len(t) for t in doc_terms.values()) / N for term in tokenize(query): df len(inverted.get(term, [])) idf math.log((N - df 0.5) / (df 0.5) 1) tf doc_terms[doc_id].count(term) score idf * (tf * (k1 1)) / (tf k1 * (1 - b b * doc_len / avg_len)) return score query 上海高铁 results sorted(docs.keys(), keylambda d: bm25(query, d), reverseTrue) print(results) # 输出按BM25分数排序的文档ID列表真要实现这个代码框架也就几十行但要真正理解每个变量的含义、调参会带来什么变化需要花不少功夫。建议你把停用词表扩展一下、换几篇不同风格的文档、观察排序结果的变化。6.2 用现成的开源引擎理解工程化看看Lucene和Elasticsearch自己手写一遍是打地基接下来建议去读开源搜索引擎的工程实现。Lucene是Java生态里最经典的倒排索引库Elasticsearch又是基于Lucene封装出来的分布式搜索系统。它们帮你把单机索引和分布式检索之间那条鸿沟补上了。你用Elasticsearch建一个索引、导入几万条数据再看它的分片shard和段segment机制就能理解我前面说的全量增量合并段是怎么落地的。特别是Elasticsearch的profile API可以让你看到一次查询在Lucene层面是怎么走的哪些倒排链被加载、哪些打分被计算、哪些缓存被命中。这些细节比任何PPT都直观。6.3 再往后走向量召回、重排策略和评估闭环当你能熟练用Elasticsearch做关键词检索之后我建议你去接触一下向量检索。方法也简单用一个预训练模型把文档转成向量存到向量数据库里查询时也把query转成向量做相似度检索。最后把倒排结果和向量结果合并这就是目前主流搜索引擎的双路召回。你可以试着做这样一个端到端项目步骤内容1准备几百篇本地文档比如技术博客2用Python实现倒排索引和BM25做一个基础检索接口3用现成embedding模型生成向量加一路向量召回4把两路结果合并去重用一个简单的线性加权做排序5记录每次查询的返回结果和用户点击做一个简易指标看板做完这套你已经算是一只脚踏进搜索技术栈的门槛了。之后再去看大厂搜索架构文章会发现每个名词你都能对应到一个明确的组件和问题不再是一头雾水。我自己做迷你搜索引擎时最大的感触是倒排索引、Ranking模型、实时检索架构这些词单独拎出来都能写一篇论文但把它们真正串起来的是时间预算和降级意识。你在搜索引擎里看到的每一次流畅秒开背后都是无数个如果这个模块慢了我就放弃它的工程妥协。理解了这种妥协你才算真正理解了搜索的工程实现逻辑。