“我们需要一个 RAG 系统”,很多团队的下一步会直接变成:切块、生成 embedding、部署向量数据库、加 reranker,再开始讨论检索质量。
问题在于,用户可能只想找到一篇写着“如何重置密码”的文档。
Lighthouse Newsletter 的原文《RAG Is Simpler Than You Think》给出了一个很实用的判断:先从全文检索和查询改写开始,只有当数据特征和实测结果提出要求时,才逐层增加向量化、混合检索和预计算。
本文把原文的六种方案重新整理成一张可执行的选择表,并补充三件工程工作:
- 如何建立一个不依赖向量库的检索基线;
- 如何根据查询失败类型选择下一步升级;
- 如何处理多意图问题、数据新鲜度、延迟、模型更换和线上观测。
先给结论:RAG 是一条升级路径
六种检索方式可以看作从简单到复杂的路径:
| 层级 | 方案 | 解决的主要问题 | 主要代价 |
|---|---|---|---|
| 1 | 全文检索 | 关键词、编号、专有名词和精确匹配 | 同义词与隐含语义覆盖较弱 |
| 2 | 查询改写 | 用户表达与文档词汇不一致 | 每次查询增加一次模型调用 |
| 3 | 混合检索 | 关键词精度和语义召回需要同时保留 | 需要向量字段、融合和更完整的评测 |
| 4 | 在线向量化 | 文档变化快,索引很难及时重建 | 查询延迟更高,调用链更长 |
| 5 | 热冷分层 | 热门文档要快,长尾文档要新 | 需要访问统计、分层和两种检索路径 |
| 6 | 全量预向量化 | 规模大、查询量高、延迟目标严格 | 存储、重建、模型升级和运维成本高 |
核心规则可以压缩成一句话:
先建立简单方案的测量基线,再根据具体失败增加一层复杂度。每次只改变一个关键变量,才能知道改进来自哪里。
原文给出的查询量、数据变化比例和延迟数字适合作为起始假设。它们不能直接替代自己的数据集、预算和服务目标。
为什么很多 RAG 项目一开始就变复杂
RAG 通常包含两段工作:
- 从知识库找出相关内容;
- 把这些内容放进上下文,让模型生成回答。
第二段再强,也无法弥补第一段找错文档。检索问题却经常被误判成“需要更强的 embedding 模型”,于是团队先投入到切块、向量库和重排器,查询本身的表达问题反而没有被检查。
这里有三个常见原因:
用户说的是自然语言,文档写的是工程术语
用户会问“怎么修复启动失败”,文档可能写着“解决 worker 初始化异常”。两句话含义接近,词面却不一致。查询改写有机会把用户表达转换成文档更容易匹配的词。
用户输入里包含必须精确保留的词
产品编号、错误码、内部框架名、客户名称和 API 名称都需要精确匹配。通用 embedding 对内部术语缺少上下文时,可能把它映射到更常见的含义。全文检索或查询改写里的词汇表可以保留这些词。
一个问题里混着多个动作
“读取 CSV、清理缺失值、画出结果”包含三个子问题。把整句送进一次检索,结果可能只覆盖其中一个动作。拆分、分别检索,再按依赖顺序组织答案,通常更容易控制。
因此,RAG 的第一项架构决策应当是:当前失败来自词面、语义、新鲜度、规模,还是查询本身包含多个意图?
四个选择维度
原文把选择条件归纳成数据新鲜度、语料特征、查询模式、规模和团队能力。实际做方案判断时,可以先填写下面这张表:
| 维度 | 需要观察什么 | 倾向的方案 |
|---|---|---|
| 数据新鲜度 | 文档每天变化很多,还是数周甚至数月才变化 | 高频变化倾向在线向量化;稳定语料可以预先计算 |
| 语料访问 | 是否有明显热门文档,是否存在大量几乎不访问的长尾内容 | 热冷分层,或对长尾按需处理 |
| 查询词形 | 编号、姓名、产品码、专有名词是否很多 | 全文检索和查询改写优先 |
| 查询语义 | 用户是否大量使用“类似”“怎么解决”“有什么替代方案” | 混合检索或向量检索 |
| 查询结构 | 单意图还是包含多个目标与依赖关系 | 多意图拆分与并行检索 |
| 查询量和延迟 | 每日请求量、可接受延迟、峰值并发 | 从简单方案起步,按测量结果增加优化 |
| 团队能力 | 是否有检索、机器学习和索引运维经验 | 先选择团队能够解释和维护的方案 |
原文给出的经验分界包括每天少于 1000 次查询、1000 到 10000 次查询、超过 10000 次查询,以及每天超过 10% 的文档变化。这些数字适合帮助团队开始讨论,最终仍需要用真实流量和评测集校准。
方案一:全文检索,先把基线跑起来
全文检索使用倒排索引和词项匹配。Elasticsearch 默认使用 BM25 相似度,官方文档说明了 BM25 的默认行为和可调参数。PostgreSQL 也提供了基于 tsvector 和 tsquery 的全文检索能力。
它适合这些场景:
- 用户输入本身就是关键词;
- 查询中有发票号、错误码、版本号或产品编号;
- 文档包含大量内部术语;
- 需要快速看到“为什么命中”;
- 团队希望先验证内容组织和词汇质量。
全文检索的优势很直接:
- 不需要 embedding API;
- 低延迟,成本结构容易理解;
- 命中词和评分原因更容易排查;
- 文档可以整体参与检索,不必一开始就决定切块策略;
- 模型升级不会触发整库重向量化。
它的短板也很明确:
- “汽车”和“轿车”可能无法自动关联;
- 对“我该如何处理这个问题”这类语义查询理解有限;
- 查询词与文档词汇差异较大时,召回会下降。
一个最小基线可以只有这样一个接口:
def retrieve(query, limit=10):
return full_text_index.search(query, limit=limit)
基线阶段不要急着追求复杂指标。先准备一组真实问题,并为每个问题标记至少一个相关文档,记录:
- 相关文档是否出现在前 5 或前 10;
- 用户是否点击或继续追问;
- 是否命中了错误的同名内容;
- 查询耗时和结果数量;
- 生成回答引用了哪些文档。
如果这一步已经能解决大部分查询,后面的向量组件就缺少充分理由。
方案二:查询改写,先处理“问法”问题
查询改写用一个语言模型把自然语言问题转换成更适合全文检索的查询。它的价值在于:许多看起来像语义检索的问题,根源可能只是用户问法与文档词汇不一致。
例如:
- “修复登录后白屏”可以补充“authentication、blank screen、startup”;
- “Atlas 怎么跑批处理”可以保留 Atlas,同时补充 batch job 和 data processing;
- “读取 CSV、清理缺失值并画图”可以拆成三个关键词组。
改写器要有一份领域词汇表,明确哪些词必须原样保留。建议让模型输出结构化结果:
{
"search_terms": ["Atlas", "batch jobs", "data processing"],
"must_keep": ["Atlas"],
"sub_queries": [],
"reason": "preserve internal framework name"
}
这里的 must_keep 很重要。内部框架、产品代号、错误码和接口名被模型替换后,全文检索可能失去最有价值的精确命中。
查询改写的优点:
- 不需要重新向量化文档;
- 改提示词后可以立即做对比;
- 适合有内部术语和自然语言问题的系统;
- 仍然保留全文检索容易解释的命中结果。
它也有自己的风险:
- 模型可能添加无关词;
- 同一个词在不同业务上下文里含义不同;
- 改写调用会增加延迟和费用;
- 输出不稳定时,回归评测必须覆盖改写结果。
可以把改写器放在检索前,并给它一个有限的重试次数:
def retrieve_with_rewrite(question, max_attempts=2):
rewritten = rewrite_question(question)
results = full_text_index.search(rewritten, limit=50)
for attempt in range(max_attempts):
if retrieval_quality_is_enough(results):
return results
rewritten = refine_query(
original=question,
previous=rewritten,
results=results,
)
results = full_text_index.search(rewritten, limit=50)
return results
实际服务中,quality_is_enough 可以结合规则和人工标注,例如结果里是否出现必须命中的文档、前几项是否来自允许的产品范围、用户是否点击了结果。不要让模型凭空判断“看起来不错”就结束循环。
方案三:混合检索,合并词面与语义
混合检索同时运行全文检索和向量检索,再把两边的结果合并或重排。它适合这样的查询:
- 用户使用自然语言描述目标;
- 查询里又包含必须精确匹配的词;
- 单独使用 BM25 已经有改写,但质量仍然不足;
- 业务能够接受额外的检索延迟;
- 文档变化速度适中,可以维护向量字段。
混合检索有两种常见组织方式:
先全文召回,再向量重排
先用 BM25 找出 50 到 100 个候选,再对候选文档做向量相似度计算,最后返回前 10 个。候选集合限制了向量计算范围,也让全文检索承担精确过滤。
全文与向量并行,再做排名融合
两条检索路径分别返回结果列表,再用 Reciprocal Rank Fusion 等方法合并排名。Azure AI Search 的混合检索文档说明了这种并行执行和排名融合的结构;其相关性文档也说明了文本检索使用 BM25、向量结果使用向量近邻算法,混合结果通过 RRF 形成统一排序。
混合检索解决了两类入口的互补问题:
- 全文检索擅长编号、姓名、术语和精确词面;
- 向量检索擅长表达不同但含义接近的查询。
它也会引入新的工作:
- 需要选择文档切块方式;
- 需要选择 embedding 模型和维度;
- 需要决定两个结果列表如何融合;
- 需要为检索质量和生成质量分别做评测;
- 需要处理文本索引与向量索引的更新同步。
不要直接比较 BM25 分数和向量相似度分数的大小。两种算法的分值含义和范围不同,应当使用排名融合、归一化或经过验证的加权方式。
方案四:在线向量化,优先保证新鲜度
在线向量化把文档或候选文档的向量计算推迟到查询时。它适合高频变化的内容,例如新闻、社交内容、实时状态和持续更新的内部数据。
原文给出的思路是:先用 BM25 找到少量候选,再对这些候选做在线向量化,避免为很少被访问的全部文档提前存向量。
它的特点是:
- 文档内容可以直接使用最新版本;
- 不需要为整个语料库提前建立向量;
- embedding 模型切换更容易;
- 每次查询都要承担额外计算和网络延迟。
原文用每次查询处理约 50 个文档、增加 200 到 500 毫秒延迟作为示例。实际延迟取决于文档长度、并发、模型服务和网络,不能直接当成服务承诺。
在线向量化适合“新鲜度比速度更重要”的场景。以下情况需要谨慎:
- 查询量大且每次候选很多;
- 用户对延迟非常敏感;
- embedding 服务偶发超时;
- 文档切块后候选数量失去控制。
一个可靠的实现应当限制候选数、设置超时、保留全文检索结果,并在在线向量化失败时返回可解释的降级结果。
方案五:热冷分层,照顾访问分布
很多知识库并不会平均访问所有文档。一部分高频文档承载了大多数请求,另一部分文档很少被访问。原文用 Pareto 分布和 20/80 作为理解这种现象的启发式。
热冷分层的做法是:
- 热文档提前计算向量,追求稳定低延迟;
- 冷文档保留原文,需要时在线向量化;
- 定期根据访问统计调整文档所在层;
- 模型更换时优先重新处理热层。
它适合:
- 访问频率差异明显;
- 语料规模已经较大;
- 文档同时包含稳定内容和高变化内容;
- 常见问题需要快速返回,长尾问题可以接受稍高延迟。
热冷分层的关键问题不在于把文档分成两组,而在于维护这两组:
- 多久更新一次访问统计;
- 新文档进入哪一层;
- 访问突然升高时如何提升;
- 热层向量过期时如何切换;
- 两层结果如何统一排序;
- 访问统计是否受到机器人或单一客户的偏斜影响。
如果系统还没有稳定的访问日志,直接上热冷分层会增加许多难以验证的状态。先建立查询、文档命中和访问时延记录更稳妥。
方案六:全量预向量化,把复杂度换成查询速度
全量预向量化会提前处理所有文档,把向量存入向量索引,查询时使用近似近邻或其他向量检索算法。
它通常适合:
- 查询量很高;
- 目标延迟严格;
- 语料变化速度低;
- 访问范围较广,长尾很短;
- 团队能够维护向量索引、模型版本和重建流程。
原文把每天超过 10000 次查询、目标延迟低于 50 毫秒、每月变化低于 5% 作为一种高规模场景的起点。它还提醒,全量预向量化会把模型更换变成一次大规模重建工作。
需要提前算清楚的内容包括:
- 文档和切块后的向量数量;
- 向量在内存和磁盘上的占用;
- 索引构建时间;
- 增量更新和删除策略;
- 新旧模型并存期间的查询切换;
- 重建失败后的回滚路径;
- 质量回归和旧结果对照。
全量预向量化适合有充分规模证据的系统。小规模、低查询量或仍在探索数据结构的项目,先使用全文检索与查询改写通常更容易发现真正问题。
多意图查询:先拆,再检索
考虑这个问题:
如何读取 CSV,清理缺失数据,然后画出结果?
它包含至少三个意图:
- 读取 CSV;
- 清理缺失数据;
- 绘制结果。
它们还有依赖关系:先读取,才能清理;先清理,才能绘图。可以让查询理解步骤输出结构化计划:
{
"query_type": "multi_intent",
"sub_queries": [
{
"id": "read",
"text": "pandas read csv",
"depends_on": []
},
{
"id": "clean",
"text": "pandas handle missing data",
"depends_on": ["read"]
},
{
"id": "plot",
"text": "matplotlib plot dataframe",
"depends_on": ["clean"]
}
]
}
执行时可以按依赖关系调度:
- 没有依赖的子查询先并行执行;
- 后续子查询使用前一步的结果或摘要;
- 简单子查询使用停用词处理和 BM25;
- 需要同义词时使用查询改写;
- 语义跨度较大时使用混合检索;
- 最后按原问题的顺序合并结果。
并行执行的总延迟接近最慢的一支,再加上拆分和合并开销;串行执行则会累加每一支的延迟。前提是子查询之间确实没有依赖。对有依赖的步骤强行并行,可能得到互相矛盾的上下文。
这种路由方式还有一个好处:昂贵的向量检索只出现在确实需要的子查询上,简单问题继续使用低成本路径。
一个可执行的升级流程
第一步:定义检索目标
先写清楚系统要找什么:
- 找到一篇唯一的操作文档;
- 找到多个可合并的说明;
- 找到带有精确错误码的排障步骤;
- 找到语义相近的替代方案;
- 找到最新状态或最近更新的内容。
不同目标需要不同的评测方式。唯一文档查找关注前几名命中;多文档问答还要关注覆盖范围和引用完整性。
第二步:建立全文检索基线
用真实用户问题建立评测集,至少记录:
{
"query_id": "q-001",
"question": "怎么修复登录后白屏",
"relevant_doc_ids": ["doc-17"],
"must_match_terms": ["登录"],
"expected_intent": "troubleshooting"
}
运行一段时间后,按失败类型分类:
- 文档确实存在,但词面没命中;
- 命中了同名文档;
- 命中内容过旧;
- 结果只覆盖了多意图问题的一部分;
- 结果正确,但排序靠后;
- 结果正确,生成回答却没有使用。
第三步:只针对失败增加一层
可以按下面的顺序尝试:
- 词面不一致:先加查询改写;
- 内部术语被改坏:加领域词汇表和 must_keep;
- 精确词与语义查询同时存在:加混合检索;
- 内容更新太快:考虑在线向量化;
- 热门与长尾差异明显:考虑热冷分层;
- 查询量和延迟目标足够高:评估全量预向量化;
- 一个问题包含多个目标:加入意图拆分和路由。
每次改动都保留旧路径作为对照。一次同时换模型、切块方式、索引和排序,最终很难解释结果变化。
第四步:把检索过程记录下来
建议为每次查询记录最小事件:
{
"query_id": "q-001",
"strategy": "rewrite_then_bm25",
"original_query": "怎么修复登录后白屏",
"rewritten_query": "authentication blank screen login",
"candidate_doc_ids": ["doc-17", "doc-23"],
"retrieval_latency_ms": 34,
"answer_doc_ids": ["doc-17"],
"user_feedback": "accepted"
}
有了这类记录,团队可以回答:
- 查询改写是否真的提高了前 10 命中率;
- 混合检索增加的延迟换来了多少有效结果;
- 哪些文档经常被访问,却没有进入热层;
- 模型升级后哪些查询退化;
- 失败来自检索、排序、上下文截断,还是生成。
选型表:什么时候停,什么时候继续
| 当前情况 | 建议 |
|---|---|
| 还没有搜索能力 | 先做全文检索和基础结果页 |
| 结果中有相关文档,但用户问法差异大 | 加查询改写,保留领域词汇 |
| 精确术语和语义问题并存 | 做混合检索并使用排名融合 |
| 文档每天频繁变化 | 评估在线向量化,限制候选数量 |
| 热门文档和长尾文档差异明显 | 评估热冷分层 |
| 查询量高、语料稳定、延迟目标严格 | 评估全量预向量化 |
| 查询由多个动作组成 | 先拆意图,再按依赖检索 |
| 当前指标已经满足目标 | 先停在当前方案,把时间用在产品功能和内容质量上 |
原文最后给出的方向很清楚:很多系统在全文检索加查询改写这一层就已经足够;少数系统才需要把所有文档提前向量化。真正重要的判断依据是自己的查询日志、评测集、延迟预算和更新频率。
常见陷阱
把“有 RAG”直接等同于“必须有向量数据库”
RAG 的关键是找到并使用相关上下文。全文检索同样可以提供上下文,向量数据库只是其中一种检索基础设施。
只看最终回答,不看检索结果
回答有时会用模型常识补全,看起来流畅,却没有使用正确文档。评测时应把检索命中、排序、引用和最终回答分开看。
让查询改写器随意修改专有名词
模型应该知道哪些词必须保留。把产品码、错误码和内部项目名放入词汇表,并在结果里记录原查询和改写查询。
直接相加 BM25 分数和向量分数
两种分数没有天然可比性。先做排名融合或归一化,再用评测集选择权重。
预先处理所有文档,却没有更新方案
向量索引需要面对新增、修改、删除、模型升级和重建期间的切换。没有这些流程时,向量质量可能很快落后于原文。
把经验数字当成硬规则
“每天 1000 次”“变化 10%”“延迟 50 毫秒”都可以帮助开始讨论,实际阈值需要结合文档规模、查询峰值、模型服务和用户容忍度。
多意图查询全部串行执行
没有依赖的子查询可以并行,串行会放大延迟。先建依赖关系,再决定执行顺序。
最后:把复杂度留给真正需要它的地方
一套可维护的 RAG 检索系统,往往从下面这条路径开始:
- 用全文检索建立可解释的基线;
- 用查询改写处理自然语言问法;
- 用混合检索补足语义召回;
- 用在线向量化回应高频变化内容;
- 用热冷分层平衡热门请求和长尾新鲜度;
- 用全量预向量化服务稳定的大规模查询;
- 对多意图问题做拆分、路由和依赖调度;
- 用查询日志和评测集证明每一层增加了价值。
如果当前问题只是“用户找不到已经存在的文档”,先检查全文检索和查询改写。向量索引、重排器和复杂代理流程都有适用场景,但它们应该由检索失败、数据变化和性能目标共同推动。
如果你正在构建 AI 助手、开发工具或知识库系统,欢迎关注 Aide Hub,后续会继续分享检索、评测和软件工程实践。