一、先把搜索系统这件事定义清楚

「检索加生成」不构成一个搜索系统

工程上最容易犯的错误,是把搜索系统的实现等同于「向量检索加一个大模型总结」。这种做法在 demo 阶段看起来很完整:用户问一个问题,系统找出几段文本,交给模型生成一段通顺的回答。但真正上规模之后,它会在三个地方同时崩塌。

第一个崩塌点是召回。生成模型再强,也只能消化检索层递给它的那几百条内容。如果召回阶段把正确的文档挡在外面,后面的生成环节无论怎么优化都是在对着错误的材料编答案,而且错得很流畅,比直接报错更难排查。第二个崩塌点是延迟。生成是一个串行的、不可并行的、长尾极重的阶段,它的耗时由输出长度和模型规模共同决定。当搜索系统的 P95 延迟目标被压到几百毫秒时,任何形式的在线生成都会直接破坏这个预算。第三个崩塌点是可信度。用户之所以来搜索,通常是因为他需要确认某个事实;一个没有引用、没有出处、没有时间戳的回答,即使内容正确,在用户眼里也和编造没有区别。

所以搜索系统的本质是一个在严格延迟预算内、以可验证的方式交付信息的检索服务,生成只是它的一种可选交付形式。理解这一点,后面所有的架构决策都会有一个统一的标准:任何设计都要回答三个问题,召回是否够、延迟是否够、用户是否敢信。

五层分工与各自的职责边界

一个完整的搜索系统可以拆成五层。离线索引构建层负责把原始语料变成可检索的结构,它不在用户请求路径上,决定的是「能不能被找到」的上限。在线检索层(召回层)负责在毫秒级从千万级文档里筛出几百个候选,它决定的是「候选集合的质量上限」。重排层负责对候选做更精细但更昂贵的相关性判断,它决定的是「最终排序的精度」。生成层负责把排序后的材料组织成自然语言回答,它决定的是「答案的表达质量与可信度」。结果呈现层负责决定用户看到什么,包括摘要卡片、答案正文、引用列表和相关追问,它决定的是「信息是否被有效消费」。

这五层之间最重要的工程共识是:上层可以补偿下层的能力不足,但代价是成本和延迟呈非线性上升。例如在召回偏弱时,可以用更强的重排模型和更长的上下文来弥补,但那会把一个 200 毫秒的请求变成 8 秒。反过来,如果召回足够好,重排就只需要处理很小的候选集,生成也可以放心地被约束在少量高置信材料上。架构设计的核心工作,就是把每一层的能力需求压到恰好够用的程度。

延迟预算怎么分配

延迟预算必须在动手写代码之前就切分好,否则每个环节都会顺手多花一点,最后总量失控。下面是一个典型的高频搜索场景的预算切分方式,其中生成环节是可选的,如果命中预计算答案或直接返回结构化列表,生成段落的预算可以整体划给召回与重排。

层P50 预算P99 预算超预算时的降级动作
查询理解15 ms60 ms跳过改写,直接用原查询
多路召回40 ms120 ms裁掉最慢的一路,保留兜底路
重排80 ms200 ms退化为粗排分数直接排序
生成600 ms2500 ms改为返回 top 十条标题摘要
呈现与网络40 ms100 ms流式首屏,剩余内容后台补齐

这张表真正的价值不在数字本身,而在于最后一列。每个环节都要事先想清楚「预算用完了怎么办」。工程实践中降级策略如果没有事先设计,而是在故障时才临时想,通常会退化成直接报错;而一个精心设计的降级链路,在过载时依然能提供可用的结果。降级本身也是一条需要单独测试的代码路径,它往往比主路径更容易出问题,因为它平时很少被执行到。

还要注意预算分配和流量形态的关系。搜索流量有明显的峰谷比,重排和生成这类重计算环节在峰值时最容易成为瓶颈。一种常见做法是按流量水位动态调整预算:水位低时全量重排加生成,水位高时先返回召回结果让用户开始阅读,同时在后台补齐重排和生成结果,通过流式返回补齐。这样用户的感知延迟被首屏锚定在召回阶段,而重活被降级成了不影响感知的异步任务。

二、离线索引构建层:正排、倒排与向量

倒排索引的现实形态

倒排索引(Inverted Index)是搜索系统里最古老也最不可替代的部件。它的核心结构是「词项到文档列表的映射」,配合一个前排(Forward Index,文档到原始字段的映射)用来在命中之后取回正文、标题、时间戳这类原始数据。正排和倒排是一对配合关系:倒排负责快速缩小候选集合,正排负责补齐完整信息,这个组合在几乎所有成熟搜索引擎里都存在,从来没有被向量检索取代。

工程上倒排的几个现实要点值得单独说明。第一是分词与规范化。中文场景下分词粒度的选择会直接决定召回质量:粗粒度分词召回更宽但噪声更多,细粒度分词精度更高但容易因为切错而完全漏召。实践中常用「粗切加细纠」的组合,即索引侧存一份较粗的切分,查询侧再对关键片段做精细匹配。第二是停用词与高频词的剪枝。像「的」「怎么」「是什么」这类词几乎出现在大部分文档里,把它们从索引里去掉可以显著减小倒排表的体积和查询代价。第三是字段权重。标题命中、标签命中、正文命中的相关性贡献通常差异巨大,倒排表需要按字段分组存储或者在打分时使用不同的饱和系数。

向量索引的结构选择

稠密检索(Dense Retrieval)把文档和查询映射到同一个向量空间,用近似最近邻(Approximate Nearest Neighbor,ANN)索引换取亚线性的查找速度。索引结构的选择会直接影响召回率和延迟的平衡,而这种平衡在千万级和亿级语料上完全不同。

在百万量级以内,图索引(如基于 proximity graph 的方法)在召回率和延迟上通常都优于倒排式向量索引,代价是构建慢、内存占用大、更新一条文档需要重新入图。到了亿级,图索引的内存与构建成本变得难以接受,倒排式的量化方案(把浮点向量量化成低位宽的整数后按倒排查找)成为更现实的选择,它的延迟略高但内存占用可以压到原始向量的一小部分,工程复杂度和运维成本都低得多。具体的性能差异高度取决于维度、量化位宽、过滤条件和硬件,不存在通用结论,必须在自己的数据和硬件上实测。

索引构建的吞吐是一等公民

很多团队只关注查询延迟,却在上线后才被索引构建的吞吐问题绊住。向量索引的构建成本大致正比于文档数量乘以向量维度,而图索引的构建还要额外考虑边的维护。构建阶段必须支持断点续传,否则一次失败就要从头重来;必须支持并行,否则单机吞吐会成为内容接入的瓶颈。

#增量索引构建的基本骨架(伪代码)
def build_delta(docs, base_index, changed_ids):
    #1.只处理发生变更的文档,先在基线索引里删除旧向量
    for doc_id in changed_ids:
        base_index.delete(doc_id)
    #2.批量计算新向量,批量写入,避免逐条提交
    vectors = embed_in_batches([d for d in docs if d.id in changed_ids], batch_size=256)
    base_index.upsert_bulk(docs, vectors)
    #3.写完后原子切换可见性标记,避免读到半成品
    base_index.commit_generation(new_generation)
    #构建必须可重入:任何一步中断后重跑都能得到一致结果

这段骨架里有三个容易被忽略但极其重要的设计点。一是删除必须先于写入,否则会残留旧向量,在增量更新场景下会造成「一个文档有两份内容」的隐性数据错误。二是批量提交,逐条写入的吞吐通常比批量低一到两个数量级。三是代际切换(generation)机制,查询侧读到的永远是一个完整的代际,即使构建过程被打断,也不会出现「索引里有原文但没有向量」的空洞状态。这三点在单机脚本里可以省略,在持续运行的在线系统里则是必须的。

三、在线检索层:召回的工程取舍

稀疏检索:BM25 及其变体

BM25 至今仍然是搜索系统里最强的稀疏基线,这一点在多次工程实践中被反复验证。它的核心思想是:词频越高贡献越大但有饱和上限,逆文档频率(Inverse Document Frequency,IDF)衡量词的区分度,同时用文档长度归一化来抑制长文档的天然优势。BM25 的参数简单、无需训练、可解释、单文档打分不依赖全局状态,这在需要精确匹配、需要号码或型号这类短字符串检索的场景里几乎无可替代。

围绕 BM25 的变体主要在三个方向上做文章。一是查询侧的加权,比如把查询词按意图权重差异化处理,标题词和修饰词给不同权重。二是文档侧的字段化打分,把 BM25 拆成多个字段版本再线性或非线性组合。三是引入语义层面的信号,比如用知识图谱或者预训练语言模型产生的实体链接结果给查询词加权,让「苹果」这类歧义词在上下文中获得更合适的 IDF 估计。

稠密检索:双塔架构与训练目标

稠密检索的主流结构是双塔(Dual Encoder),查询和文档各自通过一个编码器映射到同一空间,用向量相似度作为相关性判据。优势是查询向量可以离线预计算,查询时只需要一次文档向量库的近似最近邻查找,因此延迟可控。劣势也很明确:query 和 document 在编码时互相看不见,这叫「表示的独立性」,它天然弱化了细粒度的词项匹配能力,尤其在需要精确对齐的场景下会明显掉点。

训练目标的选择对最终效果影响很大。最朴素的对比学习只用正负样本对,容易退化;引入难负样本(Hard Negative,即检索结果里排名靠前但实际不相关的文档)对效果提升非常关键,因为它们恰好覆盖了模型容易混淆的边界区域。此外,在训练中混合一部分原文的词项匹配信号做多任务约束,能在一定程度上缓解双塔对精确匹配的钝化。这些是机制层面的共识方向,具体哪个组合收益更大,取决于语料构成和标注质量,必须在自己的场景上验证。

多路召回如何融合

成熟的搜索系统从不押注单条召回路。典型配置是:稀疏路负责精确匹配,稠密路负责语义泛化,改写路负责扩展表达,图结构路负责实体和多跳关联,最后还有一路基于行为日志的热门或协同召回。每一路返回自己的候选集合和分数,随后进入融合层。

融合最朴素的做法是分数归一化后加权求和,但这种做法有一个隐患:不同路的分数分布差异很大,稀疏路的分数通常集中在某个区间而向量路的余弦相似度集中在另一个区间,直接加权会让某一路事实上被压制。稳妥的做法是先做分位数归一化或者基于统计量的标准化,再做加权;更稳妥的做法是用一小批人工标注的查询去学一个融合模型,让模型自己学出各路的相对权重。融合的另一个关键决策是配额(Quota),即在融合之前就限制每一路最多贡献多少候选,防止某一召回能力强的路把其他路的候选挤掉,导致多路召回退化成单路。

#多路召回的融合与配额(伪代码)
def retrieve_fused(query, plan, k=200):
    #plan:每一路的名称、权重、配额、是否启用、截止时间
    pools = []
    for lane in plan.lanes:
        if not lane.enabled or lane.over_budget():
            continue
        cands = lane.search(query, top_k=lane.quota, timeout=lane.timeout)
        pools.append((lane.name, lane.weight, normalize_by_rank(cands)))
    #分位数归一化后加权,避免不同路的分数尺度互相压制
    merged = defaultdict(float)
    for name, weight, cands in pools:
        for rank, (doc_id, _) in enumerate(cands):
            merged[doc_id] += weight * (1.0 / math.log2(rank + 2))
    return sorted(merged.items(), key=lambda x: -x[1])[:k]

上面这段代码里最值得注意的不是算法细节,而是那个 over_budget() 判断和 timeout 参数。它们体现了一个经常被低估的架构原则:在线检索必须允许部分失败。向量索引偶尔抖动、稀疏索引某个分片超时,这些都不应该导致整个请求失败,而应该表现为这一路的贡献缺失,然后由其余各路和降级策略补上。同时按名次倒数计分的做法也说明了一件事:在召回阶段,「排名」这个序信息比「绝对分数」更可靠,因为它对分数尺度的差异天然免疫。

为什么要留一路传统检索兜底

这一路在很多团队的新系统里会被砍掉,因为语义检索看起来已经能覆盖大部分需求。但它承担的职责和语义检索完全不同,且有几类任务是语义检索结构上做不好的。

第一类是精确标识符检索。用户搜一个订单号、一个版本号、一个邮箱片段、一个错误码,这些 token 的语义向量几乎没有区分度,因为它们在训练语料里不是自然语言。此时词项匹配不仅更准,而且是唯一可行的路径。第二类是稀有实体的长尾。语义模型对高频概念理解得好,对低频专有名词的理解取决于它是否在预训练和微调数据里出现过,而词项匹配对此完全免疫。第三类是实时性。当用户搜索一个刚刚发生的事件时,新写入的索引可能还没有生成向量,而倒排可以做到写入即可见。第四是可解释性。词项命中的高亮是最容易向用户解释的相关性依据,也是纠错系统最可靠的反馈来源。

所以保留传统检索路的成本很低,收益却是把整个系统的尾部失败率压下来一个量级。这是我认为搜索系统里少数几个「几乎不会被优化掉」的设计点。

召回率与延迟的取舍

召回阶段的核心张力是「在给定毫秒预算内能把候选集做到多全」。几个可调的杠杆按影响从大到小排列如下:

一是索引结构的参数。近似最近邻的搜索宽度直接决定召回率与延迟的交换关系,通常调宽一档换来召回率的提升在低召回区间很划算,在高召回区间边际收益迅速衰减。二是每路的候选数量配额。配额太小会截断真正相关的文档,配额太大会显著增加融合与重排的成本。三是是否使用两阶段召回,先用极低成本的过滤缩小范围再做精细查找。四是文档长度截断,向量检索时只编码前若干个段落往往就足以判断文档级相关性,而编码全文的成本会成倍上升。

需要强调的是,这里的召回率不能用「模型觉得相关」来判断,必须用标注集上的真值标签来算。一个实用的做法是先用较宽松的候选集算出理论上界,再逐项削减配置,观察曲线在哪里开始明显变陡,那通常就是延迟预算内的最优停止点。

四、查询理解层:被低估的收益来源

意图分类与查询路由

查询理解(Query Understanding)是搜索系统里投入产出比很高的一层。同样是几十个字的用户输入,意图可能是完全不同的:想找某个具体页面、想了解一个概念、想解决一个报错、想比较两个选项、想跟踪一个动态。这几类意图对应的理想检索方式甚至不同——导航型意图需要精确匹配并快速给出唯一答案,浏览型意图需要多样性覆盖,探索型意图需要语义泛化而不是字面命中。

意图分类的价值在于让系统选择不同的处理路径,而不仅仅是打一个标签。比如判定为导航型意图时,可以跳过重排直接取最高分的页面并做高亮;判定为时效性意图时,把时间过滤从软约束变成硬约束;判定为对比型意图时,把查询拆成多个子查询分别召回再合并。把意图路由做好的收益,往往比把重排模型换大一号更明显。

查询改写与扩展

查询改写(Query Rewriting)包含几个方向:纠错(把输入修正为用户真正想表达的词)、归一化(去掉无意义的修饰、把口语表达转成术语)、扩展(在原查询基础上补充同义词、上位词、下位词和相关实体)、拆分与合并(把复合查询拆成子查询,或把多个子句合并成一个更精确的检索单元)。

改写最大的风险是引入偏差。如果扩展词和原意的关联不够强,改写后的查询会把召回带偏,而这种错误在最终结果里很难被用户识别为「检索错了」——用户只会觉得「这系统真笨」。因此实践中更倾向保守的改写策略:只在置信度足够高时做扩展,保留原查询作为一路独立的召回输入,让原查询和改写查询的结果在融合层汇合,而不是用改写完全替换原查询。这个「并行而非替换」的原则在多数场景下都更稳健。

时效性识别

时效性识别(Freshness)是把时间作为一等信号来处理,而不是简单地按时间倒序。时效信号有三个来源:查询文本里的时间副词(「最新」「2024 年」「上周」)、查询所涉及实体的固有变化速率(软件版本号、股价、天气这类实体天然是时变的)、以及文档本身的元数据更新特征。时效信号的处理方式不是一刀切地加一个时间衰减因子,而是分层:对于明确表达了时间约束的查询,做成硬过滤或强权重;对于实体的时变特性,用适度的平滑衰减;对于稳定知识(数学定理、历史事实),完全不加时间惩罚。

时间衰减函数的参数非常敏感。衰减过快会压制掉恰好重要但稍旧的文档,衰减过慢则会让陈旧但关键词高度匹配的文档排在前面。稳妥的做法是把衰减参数作为一个可实验的变量,配合不同类型的查询分别调优,而不是用一个全局参数打天下。

多轮上下文继承

多轮场景的难点不在于「能不能记住上一句」,而在于判断这一句里哪些成分是继承的、哪些是替换的。用户先问「A 产品的续航」,再问「那 B 呢」,第二个查询的指代对象是隐式的;用户先问「A 产品的续航」,再问「充电要多久」,第二个查询的主语变成了充电而不是产品。一个可行的判定路径是看查询本身的语义完整性:能独立解析成明确查询的不做继承,需要外部信息才能解析的才触发继承。

上下文继承的实现层次可以分成浅和深两种。浅层是查询串接,即把上一轮的查询词拼进来做一次普通的检索;深层是重写,即让模型结合对话历史生成一个完整的、自包含的查询。后者质量明显更好,因为它能正确处理指代消解,但也带来一个工程约束:它引入了模型调用的延迟,且重写错误会直接污染检索结果。折中做法是先用规则判定是否需要继承,只在需要时调用模型,并对重写结果保留原始输入作为兜底召回路径。

拼写与同义词

这两类问题看似基础,实际处理不好会直接吃掉可观的长尾流量。拼写纠错要区分「必须纠」和「不该纠」两类情况:明显的输入错误(键入错误、拼音混输、中英文混输)应该纠;而专有名词、品牌名、人名、型号上的「异常拼写」往往是正确的,贸然纠错比不纠错更糟。判定依据通常来自该 token 在语料中的出现频率和该用户的历史行为,而不是纯粹的编辑距离阈值。

同义词处理的一个实用原则是:同义扩展应该进索引也可以进查询,但不要两处同时做,否则会引入重复计数和权重失衡。在索引侧扩展的方案对长尾查询更友好,因为成本只在构建期付出一次;在查询侧扩展的方案对新词和热点词更友好,因为不需要等索引重建。两者可以按词的更新频率分层处理。

五、重排层:精确性与成本的交界

cross-encoder 解决了什么

召回阶段的双塔结构有一个绕不开的缺陷:查询和文档在编码时互相看不见。打分函数的输入是两个独立的向量,因此它无法建模「这个词在这个具体上下文里是否重要」。交叉编码器(cross-encoder)则把查询和文档拼在一起送入模型,让每一层注意力都能直接看到两者之间的交互,从而做出细粒度的相关性判断。代价是每对查询文档都要一次完整的前向计算,无法预计算,无法用近似最近邻加速,延迟与候选集大小成正比。

这个特性决定了 cross-encoder 的定位:它只能用在已经缩小的候选集上。粗排阶段从百万级缩到千级,重排从千级缩到几十级,几层的乘积效应是整体召回质量的跃升,同时也是延迟预算的主要消耗方。

为什么不能全量重排

算一笔很粗的账:一个中等规模的交叉编码器对单个查询文档对做一次前向计算,在普通硬件上通常是毫秒量级。如果对一千个候选做全量重排,光这一层就是秒级;如果对一万个候选,就是十几秒。就算用 GPU 并行把吞吐提升十倍,在线服务的排队和调度开销仍然会让 P99 完全失控。

更深一层的问题是,重排的边际收益是非线性递减的。粗排排在第一位的候选,重排要解决的是纠正它的误判;而排在第五百位的候选,即使相关性极高,对用户来说也只是在一屏之内的位置差异。把昂贵的算力平均分配在排序尾部,是明显的浪费。合理的资源分配是把重排能力集中在头部,并且允许按需扩展——比如只对前一百个候选做重排,剩下的直接沿用粗排顺序。

级联重排:分层与自适应

既然单个重排模型无法覆盖全候选,最实用的做法是级联(Cascade)。第一层用轻量级的重排模型处理较大规模候选,它可以是蒸馏得到的、被截断层数的语言模型、或者一个专门训练的相关性分类器,输出一个足够可靠的排序。第二层再用高成本的强模型处理头部几十个候选。第三层在极端情况下介入,比如对最相关的那几个候选做细粒度的相关性判定或生成片段级匹配。

级联层候选规模模型形态承担职责
粗排10⁵~10⁶稀疏与向量索引高召回,允许大量噪声
轻量重排500~2000蒸馏交叉编码器或低层截断把噪声压掉,形成可信头部
强重排30~100完整交叉编码器头部精排,决定最终顺序
精判5~10细粒度匹配或片段级判定片段定位、相关片段抽取

表里的候选规模区间只是数量级的参考,真实的分界线应当通过标注集上的曲线来确定:具体取多少,取决于每一层模型的实际区分能力,取决于线上的延迟预算,也取决于业务对「前几条必须准」的硬要求。

级联之上还有两个值得考虑的增强手段。一是自适应级联:先用一个廉价的判别器判断当前查询的难度,简单的查询(比如导航型)直接跳过重排,困难的查询才走完整链路,这样能把平均成本降下来而质量损失很小。二是蒸馏对齐:让轻量重排模型去拟合强重排模型的输出分布,让它在一个更便宜的形态下逼近强模型的效果,从而把级联的第一层做得更可靠。

重排之外的信号

相关性重排不是唯一决定最终顺序的因素。最终排序通常是一个多目标融合:文本相关性、内容质量(来源权威度、时效、结构完整度)、用户个性化(历史兴趣、当前会话)、以及业务目标(点击率预估、转化预估)。这些信号之间的权重需要通过线上实验确定,硬编码一个「相关性优先」的排序在实践中往往会让相关性被淹没。

一个容易踩的坑是把业务信号做得过强。当点击率模型足够强时,它会学会把用户最可能点击的内容排到最前面,而这往往与「用户最需要的内容」存在系统性偏差——标题党式的条目在点击率上永远占优。缓解办法是给业务信号设置上限,并把「点击后是否继续消费」这类负反馈信号纳入模型,而不是只优化点击本身。

六、生成环节:幻觉与引用溯源

幻觉从哪来

在搜索场景里,生成模型的幻觉有多个来源,混在一起讨论会导致优化方向错误。

第一类是检索空载型幻觉:检索没有提供相关信息,模型只能依靠参数记忆作答,而参数记忆在长尾和时效性上不可靠。这类幻觉的根因在召回,正确做法是先修召回,而不是改提示词。第二类是约束失效型幻觉:检索提供了正确材料,但模型在生成时忽略了材料。这通常源于上下文过长导致中间部分信息被稀释(所谓的中部遗忘现象),或者指令没有明确要求「只依据材料作答」。第三类是综合型幻觉:材料本身零散、不一致或有歧义,模型在归纳时自行做了取舍和补全。

这三类的处置手段完全不同。工程上最有价值的一条原则是:把「无解」变成一个合法的答案状态。当检索质量低于阈值时,系统应该明确返回「没有找到足够相关的内容」并给出建议改写方向,而不是让模型硬着头皮作答。一个敢于说不知道的搜索系统,比一个从不拒答但经常编造的系统有用得多。

引用溯源怎么才不是摆设

引用(Citation)这件事在实践中经常被做成装饰:模型被要求「引用编号」,模型就输出了编号,而那个编号对应的文档和那句话其实毫无关系。用户点进去发现对不上,从此不再信任引用。这是引用体系失效的典型路径,也是比没有引用更坏的结果。

要让引用可信,关键是让生成过程被约束在可验证的粒度上。可行的做法有几种:一种是基于句子或段落的引用,即每个输出句子必须绑定到具体的输入片段,绑定关系在生成时强制产生而非事后推测;一种是基于结构化的事实模板,把答案渲染成「结论句加证据片段」的组合,模型只负责在给定片段内做抽取和改写,不负责自由组织;另一种是生成后校验,即对每个引用做一次独立的蕴含判断(Entailment),判断这句话是否真的能从所引材料中推出,过关的才展示,不过关的降级为无引用输出。

#引用对齐与校验的流程(伪代码)
def generate_with_citations(query, materials, k=5):
    context = build_context(materials[:k])
    sentences = llm_generate(query, context, require_span_refs=True)
    verified = []
    for sent in sentences:
        ref_ids = sent.span_refs
        #1.引用必须指向真实存在的片段
        ref_ids = [r for r in ref_ids if r in context.span_index]
        #2.句子必须真的能从该片段推出,而不是编号存在即可
        if not ref_ids:
            verified.append(sent.without_refs())
            continue
        ok = entailment_check(sent.text, context.spans[ref_ids[0]].text)
        if ok:
            verified.append(sent.with_refs(ref_ids))
        else:
            #校验不过不静默丢弃,降级为无引用并保留内部告警
            verified.append(sent.without_refs())
            metrics.inc("citation_rejected")
    return verified

这段流程里有几个细节值得强调。引用编号必须校验存在性,因为模型会非常自然地生成一个不存在的编号,这在长上下文里几乎不可避免。校验必须基于蕴含判断而不是编号有效性,因为前者才是用户真正在意的。如果校验不通过的处理方式是静默丢弃,那系统会悄悄损失正确内容;正确做法是保留文本但降级为无引用,同时记录指标,这样既不损失信息又能观测引用体系的健康度。

从产品角度看,引用还必须做到可点击、可定位、可高亮。展示一个跳到整篇文档顶部的链接价值很低,用户需要的是直接跳到支撑这句话的那一段。这个能力看起来是前端的事,实际上依赖索引层是否保留了稳定的片段标识和片段偏移量,因此片段粒度的切分必须在离线阶段就做好,不能在查询时临时切。

生成的延迟与成本

生成环节的耗时与输出长度强相关,而长输出又是准确表达复杂答案所需要的。这里有一个结构性矛盾:越是需要生成的问题,往往答案越长,延迟越高,触发降级的概率也越大。缓解的方向主要有三个:一是流式输出,把首字节延迟压到最低,让用户先看到骨架;二是按需展开,先给出结论句和要点列表,证据细节在用户展开或追问时再补;三是路由,对简单问题用短输出策略,对复杂问题才走长输出。

成本方面,生成是大搜索系统里唯一可能超过检索总成本的组件。常见的优化手段是模型分层路由(小模型处理大多数简单查询)、上下文裁剪(只送入真正相关的片段而不是固定塞满)、以及针对高频查询做答案缓存。这些手段的收益相当可观,但需要注意它们会引入一致性问题:缓存的答案可能与最新数据不符,因此带时效性的答案必须设置较短的过期时间,且过期策略要和索引新鲜度对齐。

七、索引新鲜度与一致性

增量索引的正确姿势

增量索引(Incremental Indexing)的核心挑战不是「能写进去」,而是「写进去之后不会产生不一致」。主要风险有三类:

一是部分写入。如果一个文档的向量已经写入但正排字段尚未更新,查询时可能拿到不完整的数据。解决办法是代际切换或双写缓冲:先在不可见的暂存区完成全部字段的写入,完成后再原子性地切换到可见状态。二是删除不同步。源数据删除而索引未删除,会导致已经不存在的内容仍可被召回,这在权限和合规场景下是严重问题。删除事件必须与写入事件进入同一条处理管道,且删除要可重试、可校验。三是部分文档的多版本。文档更新过程中如果检索到旧版本而用户期望新版本,会产生难以复现的偶发问题,通常通过文档版本号在读取时校验解决。

全量重建的真实代价

全量重建(Full Rebuild)看起来是解决所有不一致问题的银弹,代价却常被低估。它的成本包括:读取全部源数据、重新分词与建表(稀疏)、重新编码与建图(稠密)、写入存储、以及在切换期间双份占用存储。对稀疏索引,全量重建可能只是分钟级;对向量索引,重建时长正比于数据量乘以维度,并且图索引的构建是串行的,难以并行加速。更麻烦的是切换瞬间的服务影响——要么新旧索引并存导致存储峰值翻倍,要么切换时有一次短暂的写入阻塞。

务实的做法是混合策略:稀疏索引用全量重建加增量修正(因为成本低,且倒排的增量写入能力足够好),向量索引用增量为主加周期性的全量重建(用于消除增量累积的碎片和质量漂移),并且全量重建放在流量低谷执行。这个组合在实践中比追求「索引永远绝对一致」更务实,因为后者在经济上不可行。

一致性窗口该怎么定

一致性窗口是从源数据变更到检索可见的时间。这个指标应该按业务场景分级,而不是全站一个标准。客服工单、订单状态这类内部数据要求分钟级甚至秒级可见;公开内容库可能接受小时级;归档数据和静态知识库可以容忍天级。定级之后把不同数据域拆成独立的索引实例,各自设置自己的更新频率和保留策略,比强行做一个全局一致的索引要简单得多,也便宜得多。

还要处理一种特殊情况:用户自己的内容。用户发布后立刻搜索往往搜不到自己的东西,这是一个体验上非常刺眼的问题,而且很容易通过「刚发布的内容以用户标识直接放行」的旁路来解决,不需要等待全链路索引完成。这类旁路还必须配合权限过滤,否则就成了越权访问的后门。

八、非功能性需求:多租户、权限与成本

多租户隔离的三种粒度

多租户(Multi-tenancy)在搜索系统里的隔离粒度有三种,每种对应完全不同的实现代价。

最粗的是索引级隔离,每个租户一个独立的索引实例。隔离最彻底,因为数据在物理上就不共享,实现最简单,权限过滤不需要额外机制。代价是资源利用率低——小租户的索引永远达不到理想的规模效应,且租户数量上百万时,实例管理的开销不可接受。

中间的是集合级隔离,多个租户共用一个索引,但每个租户的数据在索引里带一个租户标识字段,查询时作为过滤条件。这是最常见的方案,因为资源利用率高,实现也不复杂。代价是过滤必须被下推到索引层才能保证不泄漏;如果过滤条件在全量打分之后再做,会泄露无关信息的存在性。

最细的是文档级或字段级隔离,共享索引结构但要求在结果返回前逐条校验权限。这在权限规则复杂(比如基于角色、群组、行列级规则)时不可避免,但它把性能问题从索引层推到了应用层,容易成为线上瓶颈。

实践中的选择标准很清楚:租户数量少且用量差异大,用索引级隔离;租户数量多且用量均匀,用集合级隔离;权限规则本身复杂(这是企业场景的常态),则在集合级隔离之上再叠一层精确校验,并按部门或业务线做混合。

权限过滤必须下推到检索层

这是搜索系统里最容易出安全事故的位置。常见但错误的做法是:检索时不过滤,先把结果取出来,再用权限逻辑过滤掉不可见的部分。这个做法有三个问题:一是它会泄露信息存在性——用户能从响应时间或总数变化中推断出有不可见内容存在;二是它浪费了检索算力——为了返回十条可见结果,可能要取回上百条候选并逐条校验;三是在近似最近邻场景下它可能根本不可行——如果过滤发生在向量查找之后,一个用户可能只召回少量结果而永远凑不满。

正确做法是把权限条件下推为过滤(Filter)而不是向量检索的后置条件。近似最近邻索引通常原生支持带条件的过滤查询,实现方式有先过滤后查、先查后过滤、以及两者的混合。第三种方式最优:先做一次带过滤条件的查找,如果过滤后剩余候选不足,再做一次更宽的查找然后过滤,反复调整宽度直到满足配额。稀疏路同样支持在倒排读取时按过滤字段短路,这比先取全量再过滤高效得多。

权限过滤还有两个容易被忽略的点。第一,过滤条件必须在请求级别被信任,而不是从用户输入里直接取——这是一个典型的注入面。第二,权限变更与索引更新之间存在延迟,一个刚刚被撤销权限的文档在索引里仍带着旧标识。缓解办法是维护一张「权限版本号」的水位表,请求时携带用户的权限版本,低版本请求走一次校验升级路径。

大规模索引的存储与成本

存储成本在千万级以下通常不是问题,到了亿级就成了主要矛盾。需要算清楚的是几个乘积:文档数乘以向量维度乘以每维字节数给出原始向量体积;倒排索引的体积取决于语料的平均 token 数与文档数;正排存储的体积取决于字段总量。正排往往是三者中最大的一个,因为它存的是原文,而且通常有冗余的多份副本。

降低存储成本的常见手段按性价比排序:向量量化(把浮点压成低精度整数,是最直接的手段)、冷热分层(把低频访问的数据迁到更便宜的大容量存储)、正排字段按需加载(只存高频访问的字段,低频字段放对象存储)、以及文档正文与搜索所需的元信息分离(搜索只需要索引和摘要,正文按需从主存储读取)。这些手段的组合效果通常很明显,因为向量部分的占比往往只占总存储的一小半,真正的空间大头在正排和原文上。

另一个常被低估的成本是缓存。查询级别的结果缓存和文档级别的元数据缓存都需要占用内存,而这两者的命中率在搜索系统里通常都很高。设计缓存时要想清楚失效条件:结果缓存的失效由索引代际变更驱动,文档缓存的失效由文档更新驱动。缺少明确的失效驱动会导致用户看到过期内容,而这类问题在测试环境里几乎不会暴露。

可靠性与故障域

搜索系统是典型的「局部故障、全局不可用」的系统:某一路召回超时看起来只是效果下降,但如果降级逻辑没做好,就是整体 500。因此可靠性设计的关键是给每一路召回复独立的超时和熔断,让单路故障只影响该路的贡献。熔断的触发条件不只看错误率,也要看延迟分布——长尾延迟导致的堆积最终会演变成雪崩,而此时错误率可能还是正常的。

九、评测体系:离线指标与线上指标的脱节

离线指标各自测什么

召回率(Recall@k)衡量正确文档有没有被放进候选集,它反映的是召回层的完备性,重排和生成的改进不会影响它。平均倒数排名(Mean Reciprocal Rank,MRR)衡量第一个正确结果出现得有多靠前,它比召回率更关注头部,但完全不管第二名及之后的分布。归一化折损累计收益(Normalized Discounted Cumulative Gain,nDCG)在位置加权的同时考虑多个相关结果的相关度分级,是排序质量最全面的离线指标。

这三个指标回答的是不同问题,用它们做决策时会得到不同的结论。例如改进生成质量时,这三个离线指标全部不变,但你可能仍然看到显著的线上收益;反之,重排模型的离线指标提升两三个点,线上可能毫无变化。原因是这些指标都假设「用户会仔细看完并按相关性判断」,而真实的用户行为是点击、扫视、放弃,三者都对排序的语义化解读完全不同。

脱节的具体表现

离线与线上的脱节通常以几种形式出现。第一种是标注分布偏差:标注集里的查询都是精心构造的,而线上真实流量的长尾占比远高于此,导致离线优化的收益集中在一个用户很少触及的子集上。第二种是点击偏差:用户只点击排在前面的内容,这使得用点击日志构造的训练信号本身带有位置偏差,进而让点击率模型继承并放大偏差。第三种是「正确答案不在候选集里」:用户的真实意图是复合的,单个正确文档不足以回答,标注却只标注了一个文档,于是线上用户仍然不满意。第四种是延迟被完全忽略:所有离线评测都不计延迟,一个把延迟从 200 毫秒推到 2 秒的方案在离线指标上依然「胜出」。

A/B 实验该怎么设计

搜索系统的 A/B 实验有几个特殊之处需要特别注意。

分流必须是按用户而不是按查询分流的。搜索有很强的重复查询现象,按查询分流会导致同一用户看到两个版本,产生混淆,并且让结果难以归因。

实验周期必须覆盖完整的使用周期。周内的行为模式和工作日、休息日差别很大,只跑两天的实验很容易得到与长期相反的结论。同时要避免节假日和重大事件污染期。

指标必须是复合的。一个合理的组合是:以成功率(用户没有在首个结果后立刻回退)为护栏指标,以有效点击率或点击后停留为主指标,以人工满意度抽样为校准指标。护栏指标不达标时不予放量,这个规则要在实验开始前就定好,避免事后选择性解读。

样本量必须事前估算。以点击率为例,基线值决定了检测小幅提升所需的样本量,基线越高需要的样本越大。在高点击率的搜索场景里,几个百分点的差异往往需要相当可观的流量才能达到统计显著,这意味着很多看起来很小的改动短期内无法被验证。这本身就是一条重要的架构约束:那些需要长时间实验才能确认的改动,不适合放在核心链路上。

不要忽略的护栏指标

除了主指标和护栏指标,A/B 实验还应该固定监控几个「安全型」指标,它们本身不需要变好,但一旦变差就说明系统出了问题:P95 与 P99 延迟、检索失败率(含超时、熔断、降级触发比例)、无结果率、以及引用校验的拒绝率。这些指标构成了架构变更的安全网,比任何事后复盘都有效。

十、2026 年的几个进展方向

混合检索的融合方式在往前推

稀疏与稠密的融合,早期是结果级的分数加权,现在已经推进到多个更细的粒度。比较有意思的几个方向:一是词项级的混合,即不再对两份独立的排序做加权,而是在同一个打分函数里显式地把词项匹配信号与向量相似度作为两个特征融合,让重排模型自己学出两者的相对权重;二是把稀疏检索从倒排换成一个可学习的稀疏表示,让稀疏性不再来自分词规则而是来自训练,这样既能保留精确匹配又避开了分词错误;三是端到端训练,把稀疏、稠密、重排放到一个联合目标里优化,让各层的输出分布对彼此可分。这三条路线的收益大小高度依赖语料特性:词表越不规范的场景,词项级混合和可学习稀疏的收益通常越明显。

Agentic Search 的真实收益与账单

让模型自主决定检索几轮、检索什么,是当前变化最快的一个方向。它的吸引力很直接:单轮检索只能匹配显式表达的语义,而复杂问题需要分解——先查实体、再查关系、再查反例,甚至需要在失败之后换一种检索方式。这是把「搜索」升级为「研究」的关键能力。

但代价必须被算清楚,而且要算两笔。

第一笔是延迟。多轮检索是严格串行的,每一轮至少包含一次模型推理加一次检索,轮数增加意味着延迟近似线性增长。在一个以数百毫秒为目标的搜索入口上,两三轮就已经超出预算。这直接决定了 Agentic Search 更适合成为「深度模式」而不是默认模式——用户主动切换到一个可以等待几十秒的入口。

第二笔是成本与不可控性。轮数由模型自主决定,成本上限失去了确定的保证,一个病态的查询可能触发大量无效检索。而且每一轮都是一次决策机会,决策质量决定最终效果,错误决策会级联放大。工程上的必要约束是:设置轮次硬上限、每轮的检索配额、以及在每轮之间做一次「当前信息是否足够」的显式判断,避免无意义续搜。另外还要准备一个兜底——当轮次用尽时,用已有的材料生成一个带不确定性说明的回答,而不是假装信息充分。

模式延迟量级成本量级适用问题
单轮混合检索百毫秒级低事实型查询、导航、精确匹配
查询改写加单轮亚秒级中口语化、别名、多轮指代
生成式摘要秒级中高综述型、需要综合的判断
Agentic 多轮十秒级或更长高且不封顶复合问题、需要对比或多方验证

这张表的关键信息不在具体数字,而在四个模式的延迟和成本是跨越量级的,因此它们必须是不同的产品入口而不是同一个接口里的不同分支。绝大多数流量应该走前两档,把后两档留给真正需要它们的用户——这也是控制整体成本最有效的手段。

语义缓存与相似查询复用

语义缓存(Semantic Cache)用向量相似度判断新查询与历史查询是否语义等价,从而复用结果。它对高重复率场景(客服、企业内部知识库、热点事件)的收益非常可观,因为这类场景里大量查询在语义上是同义的。但它的风险同样明确:

一是时效性风险。缓存了一个关于某事件的答案,事件后续发展了,缓存命中就会给出过期内容。必须给缓存绑定过期时间和索引代际,索引更新时主动失效。二是权限风险。不同用户的查询语义等价,但权限不同,直接复用结果会造成越权。缓存键必须包含用户的权限上下文,或者干脆只缓存不敏感的公共查询。三是错误传播风险。一个错误的答案被缓存后会持续命中,纠正成本很高,因此只有通过高置信判定的结果才应该入缓存。

实践上更稳妥的做法是把语义缓存放在结果标题这一层而不是答案正文这一层——相同查询的 top 结果列表复用起来更安全,而生成答案每次重算,这样时效性和错误传播的风险面小很多。

搜索与 Agent 的边界

随着 Agent(智能体)成为新的入口,「搜索该做成什么形态」的问题被反复提出。一个可用的判据是看接口承诺了什么。

如果接口承诺「给出一个权威答案并可溯源」,它就是搜索系统,必须有严格的召回、延迟预算、引用校验和评测体系。如果接口承诺「完成一个需要多步操作和判断的任务」,它是 Agent 系统,输出是一个副作用——可能是一系列动作、一次决策、一份交付物。

两者的工程重心不同:搜索系统的核心矛盾是延迟与质量的取舍,Agent 系统的核心矛盾是自主性与可靠性的取舍。因此更合理的架构是两套系统共存并互相调用——搜索系统作为一个工具被 Agent 使用,向 Agent 暴露结构化、可溯源、带延迟保证的结果接口;Agent 负责分解与决策,搜索负责检索与验证。让搜索系统自己承担多轮自主决策,会让它既不具备 Agent 的灵活性,又失去搜索的可靠性;而让 Agent 直接去访问原始数据源,则会失去可评测性。这条边界是我认为在 2026 年仍然成立的工程共识。

十一、一份可落地的参考实现

下面这段代码把前面讨论的各层串成一个完整的请求流程。它不是可运行的生产代码,而是把关键决策点显式写出来的骨架:每一层的超时、降级、并行方式以及置信度阈值都写在代码里,因为这些地方才是架构真正落地时的分歧点。

def search_pipeline(req):
    #1.查询理解层:意图、时效、改写,各自独立超时,失败可跳过
    intent = classify_intent(req.q, timeout=15ms)
    fresh = detect_freshness(req.q, intent)
    variants = [req.q]
    if intent.rewrite_needed:
        rw = llm_rewrite(req.q, history=req.history, timeout=60ms)
        #原查询与改写并行召回,而不是替换
        variants.extend(rw.queries)

    #2.召回层:各路并行,按计划分配配额与超时
    plan = build_plan(intent)  #每路的开关、配额、权重来自配置
    pools = parallel_collect([
        sparse_search(req.q, plan.sparse, filter=acl_filter(req)),
        vector_search(req.q, plan.vector, filter=acl_filter(req)),
        sparse_search(variants[1:], plan.expansion, filter=acl_filter(req)),
        hot_search(req, plan.behavior, filter=acl_filter(req)),
    ], timeout=120ms)

    #3.融合层:按配额截断后做分位数归一化加权
    candidates = fuse(pools, quota=plan.quota, target=1000)

    #4.置信度闸门:这一层决定生成是否会引入幻觉
    quality = judge_quality(candidates, intent)
    if quality.score < THRESHOLD[intent.type]:
        #明确返回无解,而不是让模型硬答
        return no_answer(retry_hint=quality.hint, candidates=candidates[:5])

    #5.重排层:轻量层处理千级,强模型处理头部,自适应跳过简单查询
    if intent.simple:
        ranked = candidates[:10]
    else:
        coarse = light_rerank(req.q, candidates[:800], timeout=200ms)
        ranked = strong_rerank(req.q, coarse[:80], timeout=180ms)
        ranked = diversify(ranked)  #去掉同源近重复

    #6.呈现:多档延迟预算,按预算选交付形态
    if budget.remaining() < GENERATION_BUDGET:
        return render_list(ranked[:10])
    if budget.remaining() < GENERATION_BUDGET * 0.5:
        return render_summary_with_stub(ranked)  #首屏流式
    return render_full_answer(req, ranked, budget=budget)

这段骨架里有五个位置值得单独指出来,它们是本文所有结论的落点:

一是第 1 步改写结果与原查询并行,而不是替换,理由是改写会引入偏差,保留原路径是唯一的兜底。二是第 2 步每路召回都带相同的权限过滤条件,过滤下推到索引层,避免后置过滤的信息泄露和候选不足。三是第 4 步的置信度闸门,它把「检索没找到」和「检索找到了但不够好」这两种情况都变成合法的无解响应,这是抑制生成幻觉最有效的一个单点设计。四是第 5 步的自适应跳过,简单查询不消耗重排预算,这直接决定了级联重排的平均成本。五是第 6 步按剩余预算选择交付形态,而不是先做完整答案再超时截断。

排期上的建议顺序

如果从零开始搭建,经验上的优先级是这样:先做召回层的混合检索(稀疏加稠密各一路,加一路传统的模糊匹配兜底),把正确率建立在一个可接受的基线上;然后接入轻量重排并用蒸馏方式对齐强模型,这一阶段的收益通常最大;再叠加查询理解(先做意图和时效识别,改写可以稍后);然后做评测体系——这一步必须早于任何精细调优,否则无法判断改动是好是坏;接下来是引用校验和生成环节,注意把置信度闸门放在生成之前;最后才是 Agentic 深度模式、语义缓存这类优化。

这个顺序的逻辑是:前面几步的收益确定、风险低、可用离线指标验证;后面几步收益依赖具体场景且需要线上实验,盲目提前投入容易做出无法评估的东西。

十二、结语

AI 搜索系统的架构决策,实质上是在四个相互冲突的诉求之间找平衡点:召回的完备性、延迟的预算、生成的可信度、以及成本的可控性。这四个诉求不可能同时最优,因此架构设计的核心工作其实是明确「在什么场景下牺牲哪一个」。

几个贯穿全文的判断可以总结为:每一层都要有降级路径,因为过载时的表现比峰值性能更能说明架构质量;召回阶段保留传统检索路的成本极低而收益极高,不应该被优化掉;权限过滤必须下推到检索层,这是安全问题而非优化问题;引用必须经过独立校验才算数,编号存在不等于引用成立;评测体系要早于精细调优,否则所有优化都在盲飞;延迟预算要在第一行代码之前切分好,并且每个环节都要事先想清楚超预算时怎么办。

最后一点值得强调:搜索系统的质量差距很少来自算法本身的先进程度,而更多来自工程细节的完整性——索引写入的一致性、降级路径的实现、评测集的质量、实验周期的充分性。这些工作没有捷径,也无法通过换一个更强的模型来替代。这也是为什么同一个技术方案在不同团队会产出相差很大的实际效果。

十三、几个容易被低估的工程细节

长文档的处理方式决定了系统上限

文档切分(Chunking)的影响面远超直觉。它同时决定了索引体积、召回粒度、生成的证据完整性和引用可定位性,是少数几个「改一次、收益体现在所有层」的决策点。常见做法有固定长度切分、按语义边界切分、以及按结构切分(标题层级、章节、列表项)。

固定长度实现最简单,但会把一个完整的论述从中间截断,导致两个片段都缺少上下文,重排模型和生成模型都无法判断其相关性。按语义边界切分(自然段、标题)是更合理的选择,因为段落本身就是作者表达的最小完整单元。代价是片段长度不均,长的段落可能超出向量编码的有效范围,需要再叠一层超长切分。

更值得投入的是让片段携带结构信息:所属文档 ID、标题路径(h2/h3 的层级链)、在文档中的序号、以及前后片段的引用。这些元数据有三个用途:让重排特征可以感知片段在文档中的位置(片段出现在文档开头通常比出现在末尾更代表主题),让生成的引用能显示完整路径(「文档标题 › 二级标题 › 三级标题」比一句孤立的句子有用得多),让结果去重可以按文档而非按片段进行。

片段的重叠是一个需要按内容类型决定的问题。技术文档和问答对的重叠可以小甚至为零,因为每段本身就自洽;叙述性文本和长论述需要一定的重叠以保留跨段落的信息。重叠比例的上升直接推高索引体积和召回时的重复候选率,因此它应该按文档类型分别配置,而不是一个全局值。

去重与多样性

向量检索有一个著名的特性:高分结果往往来自同一篇文档的多个相邻片段。这在生成环节是好事(材料连贯),在结果呈现里是坏事(用户看到十条几乎一样的结果)。因此呈现层之前必须有一个去重环节。

去重的强度需要区分场景。如果用户要的是一份综述,保留多个同源片段能让生成有更完整的材料;如果用户要的是找到一篇文档,那么按文档去重、每篇只展示最相关的片段是正确做法。实现上同时做两层去重比较稳妥:先按文档 ID 做硬去重限制单文档的占比,再对剩余候选做语义近重复检测(片段之间的相似度超过阈值就只留一个)。后者需要一次额外的相似度计算,成本可以接受,因为此时候选数量已经很小的。

无结果的处理比有结果更值得设计

无结果场景是搜索系统里最被低估的部分。它出现的频率不高,但直接决定了用户对整个系统的信任度——用户不会认为「搜不到」是正常的,他会认为系统坏了或者没收录。

合理的处理分为三层。第一层是扩展召回:当前所有路都返回空时,放宽约束重试,包括去掉过严的时间过滤、放宽向量搜索的相似度阈值、去掉查询中的低权重修饰词、关闭意图识别出的强过滤条件。第二层是转义改写:仍然无解时,把查询分解为更简单的部分分别检索,或者用向量检索找一个语义上最接近的可用内容。第三层才是明确的告知,但要给出具体可操作的建议而不是一句「未找到」——比如建议的替代词、建议放宽的条件、或者相关的热门内容。

无结果的比率本身应该是一个被持续监控的指标。比率的突然上升通常意味着索引侧出了问题(构建失败、代际切换卡住)而不是用户查询变了,所以它同时是一个很好的健康检查信号。

灰度与索引的兼容

索引结构变更(换分词器、换向量模型、改切片策略)意味着新旧索引无法共存,而重建窗口又不可能为零。工程上可行的做法是双写加双读:新建一个并行的索引实例,内容同时写入两者,读流量按比例在新旧之间分配,逐批对比两者的结果差异,差异稳定后切到新索引并废弃旧的。

这个过程中的一个关键约束是索引代际标识要贯穿到结果里,因为重排和生成的评测需要按索引版本分组统计,否则新旧索引的效果会被混在一起,得不出可信的结论。另一个约束是查询理解层对索引格式的假设——比如查询侧的分词必须和索引侧一致,换分词器时这个假设会在很多散落的地方破裂,因此把分词调用统一收敛到一个模块里,是为了让这类变更只改一处。

成本的可观测性

搜索系统的成本分布经常和直觉相反:流量占比最高的那部分查询可能只消耗了很小比例的成本,而少数复杂查询消耗了大部分。为了让成本可管理,必须按查询维度把成本拆开记录,至少包括:每类意图的请求占比、每条召回路的候选数量、重排实际处理的候选数、生成的输入输出 token 数、以及命中缓存的比例。

有了这组数据,才能做出「把某类意图切到更便宜的检索模式」这类有依据的决策。没有这组数据时,成本优化只能靠全局参数调整,通常会伤害到高价值用户而收益不明显。

十四、从检索到答案的证据链

证据链比引用更重要

引用解决的是「答案的某句话能不能点开验证」,但用户真正需要信任的是一整条完整的证据链:从用户问题出发,经过查询理解如何被解读、检索找到了哪些材料、材料如何被筛选排序、最终答案的每句话分别来自哪里。这条链路上任意一环被隐藏,用户就只能看到结论而无法判断结论的可靠性。

把这条链路显式化有一个具体的实现价值——它让错误可以被定位。当用户报告一个错误答案时,系统能够回答「这次检索的理解是 X,召回了 Y 个候选,正确文档在其中的位置是 Z,被排除的原因是 W」。没有这条链路,排查就只能靠人工复现,而搜索系统的查询分布极长尾,复现率很低。把排查能力做进架构里,比事后补日志便宜得多。

具体要落地的记录项包括:原始查询、意图判定结果、时效参数、每路的召回条数与配额、融合后的 top 排名、每条候选被重排分数的变化(哪个候选在重排中被大幅下调,往往是问题的线索)、最终选入上下文的片段列表、以及每条答案句的引用绑定与校验结果。这些记录可以只在部分比例的请求上采样采样以控制存储成本,但无结果请求和低置信度请求应当全量记录,因为它们的信息密度最高。

片段级的证据定位

要让引用真正可用,定位精度必须到片段而不是文档。一个可行的设计是在生成阶段就要求模型输出片段标识而不是文档标识,事后通过片段到文档的映射还原出可点击的链接。这样做的额外收益是支持片段级的高亮——用户点击引用后直接看到支撑那句话的那一段文字,而不是需要自己在长文档里搜索。这是把「引用」从形式上的标记变成实用功能的关键一步。

片段标识的稳定性也需要考虑。如果片段切分策略变更导致标识全部改变,历史引用就全部失效。可行的做法是用「文档 ID 加内容哈希」而不是「文档 ID 加序号」作为标识基础,这样即使重新切分,相同内容的片段仍然可以匹配上,或者至少能通过内容相似度把旧引用迁移到新片段。

十五、评测集的建设成本

评测集是搜索系统最稀缺的资产

搜索系统的任何决策都依赖评测集,但构造一个可信的评测集的成本远高于写代码。一个最小可用的评测集需要三样东西:一批真实分布的查询、一份人工标注的相关性判断、以及一套稳定的分层方式(至少按意图和难度分层,因为不分层的总分几乎无法指导优化)。

查询来源最实用的做法是从线上日志里采样,而不是让人工构造。人工构造的查询会严重偏向「合理的、有答案的」那一端,而线上真实流量的长尾占比远高于此。更重要的是,真实日志里的查询带着上下文信息(用户是谁、什么时候搜的、来自哪个页面),这些信息决定了正确答案该怎么判断——同一个查询对不同用户的正确结果可能是不同的。

标注环节最容易出问题的是粒度不一致。给标注者「这段文本是否与查询相关」这样一个二元问题,标注结果的相互一致性通常并不理想。更有效的题目形式是给出排序后的候选列表并要求标注者调整顺序(相关性偏好判断),因为这个任务对人来说更自然,也更接近真实的排序需求。具体的标注方法和一致性检验方式取决于标注工具的实现,标注成本和质量的权衡没有通用答案。

分层评测比总分有用

一个包含各类查询的评测集,它的总分几乎没有指导价值——总分上升十个百分点,可能是某一类查询大幅改善而其他类略有退步的结果。真正有用的是分层结果:按意图分类(导航型、事实型、探索型、对比型)分别看指标,按难度分层(头部查询、长尾查询、新兴查询)分别看指标。

有了分层结果,优化方向就变得清晰。比如导航型查询的召回率接近一百分之一而探索型只有六成,那说明问题在语义泛化而不是整体召回;头部查询表现差而长尾好,那问题在重排的头部精度。这些判断靠总分是做不出来的,而它们直接决定了下一轮该改哪一层。

用线上信号构建弱监督集

人工标注集永远太小,不足以驱动模型迭代。另一条路是从线上行为构造弱监督信号:用户点击了某个结果说明至少他认为它是相关的、点击后没有立刻回退说明内容质量尚可、跳过了排在更前的结果说明那个结果可能不相关。这些信号可以用在训练重排模型上,但必须处理位置偏差——最直接的处理是只采信前若干个位置的点击与跳过模式,并对明显的导航型点击(点击后秒退且直接进入目标页)单独建模。

弱监督集不能替代人工标注集,两者用途不同:弱监督集量大、覆盖广,适合训练模型;人工标注集小但准确,适合作为不可妥协的评测基准。如果只保留一个,很容易在这两者之间产生偏差而无法察觉。

十六、质量回归与线上运维

配置即代码,配置就是质量参数

搜索系统里有一大批决定效果的参数:每路召回的配额、融合权重、时效衰减系数、重排层数、去重阈值、各意图的阈值闸门、缓存过期时间。把它们散落在代码和各个配置中心里,会导致「上周效果为什么变差了」这类问题无法回答。因此把它们统一收敛到一个版本化的配置里,并且让每一次配置变更都关联到一次可追溯的发布记录,是搜索系统运维的第一件工程事。

配置集中之后才能做参数扫描式的回归验证:固定评测集,逐个参数在合理范围内取值,观察各分层的指标变化,把结果汇总成一张表。这个过程的价值不只是找出最优参数,更是搞清楚每个参数的敏感度——哪些参数微调一下就大幅影响效果(必须严格管控变更),哪些参数在很大范围内都不敏感(可以放手让业务方调整)。参数敏感度这个认知在实践中的价值被严重低估,它直接决定了团队应该在哪里投入review 精力。

必须常驻监控的健康指标

除了前面提到的护栏指标,下面这组指标的异常通常能在用户投诉之前就发现问题,值得单独列出。

索引侧要监控的是:各路召回的空结果比例(某一路突然归零通常意味着构建或服务异常)、索引代际的滞后时间、以及索引构建的失败率与重试次数。召回侧要监控的是候选数量分布的漂移,分布突然收缩意味着某一类查询遇到了未被察觉的失败。生成侧要监控的是无解返回率、引用校验拒绝率、以及生成的平均输出长度。呈现侧要监控的是结果的点击位置分布。

这组指标的共同特点是「不是质量指标而是系统状态指标」——它们的异常几乎总是指向某个具体故障,而不是效果波动,因此告警的准确率很高。相比之下,相关性指标的短期波动很难判断是故障还是噪声,告警价值反而低。这两类信号应该分开监控,不要混在一起。

故障演练

搜索系统的降级路径平时很少被执行,因此必须主动演练。值得演练的场景包括:向量索引服务完全不可用、某一路召回延迟飙升导致全局超时、重排模型超时的批量发生、生成服务限流、索引构建任务长期挂起导致代际滞后、以及权限服务异常导致过滤条件拿不到。

每个场景的演练都要明确一个回答:用户会看到什么,以及这个结果是否可接受。特别关键的是最后一个场景——权限服务异常时的默认行为必须是「拒绝」而不是「放行」,而这类默认值往往在代码里被写反了,因为它只在平时不可达的分支上。演练的价值就在于把这些平时不可达的分支真正走一遍。

十七、架构选择的判断依据

什么规模需要什么复杂度

搜索架构的复杂度应该与规模匹配,这里给一个粗略的分界参考。语料在十万篇以内时,单进程加一个内存索引加一个内存向量表就足够,不需要分片、不需要代际切换、不需要独立的查询理解服务。百万级时,索引需要持久化并支持分片,向量检索需要引入近似最近邻结构,重排需要一个独立部署的轻量模型。亿级时,前面所有机制都成为必须,再加上存储分层、冷热分离、以及独立的索引构建集群。

这个分界的意义在于,跨越某个规模时一次要解决多个问题,如果在没有到达那个规模时就提前实现这些机制,付出的维护成本会持续拖累团队。用最笨的方案做到下一个分界点,在中小规模阶段通常是正确的工程决策。

自研与采购的边界

检索相关的组件(引擎、向量库、重排模型、生成模型)现在都有可用的开源方案和商业服务,决策的判断依据应该落在四个问题上:数据是否可以出域、性能指标是否满足硬要求、迭代速度是否够用、以及总拥有成本是否真的更低。

其中「迭代速度」常被低估却常常是决定性的因素。搜索系统的效果差异主要来自大量细碎调优——切分策略、权重、阈值、训练数据的构造,这些都需要高频迭代。如果选定的方案每次改动都要走一次漫长的流程或者依赖外部排期,那么它产生不了那些调优收益,最终效果反而不如一个可自由修改的方案。总拥有成本的计算要把工程人力和迭代速度的损失算进去,只算许可费和机器成本往往会得出错误结论。

需要补充一点是:把某一层交给外部方案并不意味着放弃该层的理解。召回、重排、生成这三层的具体实现在不同方案间差异巨大,如果对它们的内部机制没有理解,就无法在它们表现不佳时判断问题出在哪一层、是参数不对还是方案不适配,最终只能反复更换供应商而不是解决问题。保持对每一层机制的理解,是保留优化空间的前提。