混合检索与 RAG 回答可信度:从召回 chunk 到带来源回答

目录

混合检索与 RAG 回答可信度:从召回 chunk 到带来源回答

系列定位:这是 my-rag 的 MVP 实战记录,重点是用真实链路解释 RAG 如何从“能回答”走向“值得信任”。
阅读时间:11 分钟
适合读者:正在调试 RAG 召回质量、来源引用和 Chat 可靠性的开发者
关键词:pgvector、关键词检索、RRF、rerank、来源引用、Chat log

RAG 回答不好,通常不是一个问题

用户问了一个问题,RAG 系统回答得不理想。表面上看是“模型答错了”,但实际原因可能有很多:

  • 文档还没进入 READY,根本没有参与检索。
  • chunk 切得不合适,正确答案被拆散了。
  • 向量召回方向对,但漏掉了精确关键词。
  • 关键词召回找到了词,但语义不相关。
  • 上下文构建时丢掉了关键片段。
  • 大模型没有严格根据资料回答。

所以优化 RAG 不能只盯着 prompt。必须把问题拆开看:召回是否正确,排序是否合理,上下文是否充分,回答是否有依据

这篇文章讲 my-rag 在检索和问答阶段做了哪些设计,以及这些设计如何帮助我们定位问题。

一、检索范围:先确认哪些文档有资格回答

用户提问时,系统不是直接搜全库,而是先解析检索范围。

当前有三种情况:

  1. 用户指定了具体文档 ID。
  2. 用户指定了知识库 Collection。
  3. 用户没有指定范围,系统使用所有 READY 文档。

这一步由 DocumentScopeResolver 负责。它的一个关键原则是:只返回 READY 状态的文档

这能避免一个常见问题:用户刚上传文档,还没完成 embedding,就马上提问。如果系统把这个文档纳入范围,就会出现“看起来选中了文档,但实际搜不到”的体验断层。

如果用户明确指定了范围,但解析后没有任何可用文档,系统会直接返回:

当前资料中没有找到明确依据。

这个回答很朴素,但比模型硬编一个答案要好。

二、向量检索:RAG 的第一条召回通道

向量检索的流程是:

  1. 将用户问题转成 embedding。
  2. 使用 pgvector 在 rag_chunk_embedding 中计算相似度。
  3. 按相似度排序,取 topK。
  4. 应用 score threshold,过滤低质量结果。

核心 SQL 思路是:

SELECT
    d.id AS document_id,
    d.title AS document_title,
    c.id AS chunk_id,
    c.content AS content,
    (1 - (e.embedding <=> :questionVector::vector)) AS score
FROM rag_chunk_embedding e
JOIN rag_document_chunk c ON c.id = e.chunk_id
JOIN rag_document d ON d.id = c.document_id
WHERE d.status = 'READY'
  AND e.embedding_model = :embeddingModel
ORDER BY e.embedding <=> :questionVector::vector
LIMIT :topK;

向量检索最大的优势是能理解语义。

比如用户问“这本书怎么看待长期主义”,资料里未必出现“长期主义”这个词,但可能有“延迟满足”“长期投入”“复利效应”等表达。向量检索更容易把这些片段召回来。

但它也有弱点:遇到配置项、专有名词、章节名、数字条件时,向量检索可能会过度联想。

这就是为什么还需要关键词通道。

三、关键词检索:给精确问题补强

关键词检索不是为了替代向量检索,而是补它的短板。

比如这些问题就很适合关键词召回:

  • RAG_CHAT_MODEL 在哪里配置?
  • 第三章提到的退货条件是什么?
  • embedding batch size 默认是多少?
  • PostgreSQL 的 vector_cosine_ops 用在哪里?

my-rag 的关键词检索会先生成若干查询变体,再调用 PostgreSQL 全文搜索。生成查询时会尽量保留强信号:

  • 全大写环境变量。
  • 英文配置项。
  • 数字条件。
  • “第几章”这样的章节表达。
  • “区别、配置、费用、来源”等意图词。

这样做不是为了让关键词检索变得很聪明,而是让它别把最关键的字面信息丢掉。

四、混合检索:用 RRF 合并结果,而不是硬加分

向量检索和关键词检索会返回两组结果。接下来要解决的是排序融合。

一种简单做法是给两边分数加权,比如向量 0.6、关键词 0.4。但不同检索通道的分数分布不一定可比,直接加权很容易调参困难。

my-rag 使用的是 RRF(Reciprocal Rank Fusion)思路:更关注结果在各自列表里的排名,而不是原始分数。

直观理解是:

  • 一个 chunk 在向量结果里排第 1,应该加分。
  • 一个 chunk 在关键词结果里也靠前,更应该加分。
  • 两边都出现的 chunk,通常比只在一边出现更可信。

RRF 的简化公式是:

score = 1 / (k + vectorRank) + 1 / (k + keywordRank)

其中 k 用来平滑排名差异。默认配置里可以通过 RAG_RETRIEVAL_RRF_K 调整。

这种方式的好处是,融合逻辑更稳定,也更容易解释:不是“这个分数为什么是 0.734”,而是“它在两个召回通道里都排得靠前”。

五、rerank:先留扩展位,不强依赖

混合召回之后,还可以接 reranker,对候选 chunk 做更精细排序。

不过第一版没有强依赖真实 reranker 服务,而是提供了可配置扩展位。默认可以使用 noop reranker,也就是不额外重排。

这个设计有两个好处:

  1. 本地开发不需要额外模型服务。
  2. 后续接入 reranker 时,不需要改检索主流程。

我不建议一开始就把 reranker 当救命药。它确实能提升排序质量,但如果 chunk 切分和基础召回有问题,rerank 只是在坏候选里挑相对不坏的结果。

六、上下文构建:不是召回多少就塞多少

检索拿到 chunk 后,还要构建给大模型的上下文。

这里有一个现实限制:上下文窗口不是无限的。即便模型支持很长上下文,也不应该把所有召回结果原样塞进去,因为这会带来三个问题:

  • 成本上升。
  • 关键片段被无关内容稀释。
  • 模型更容易输出泛泛解释。

my-rag 的上下文构建会根据配置控制可进入 prompt 的 chunk 数和最大字符数。默认思路是:召回阶段可以多拿一些候选,真正进入 Chat 的证据片段要更克制。

这也是为什么检索配置里会区分:

  • vector-top-k
  • keyword-top-k
  • rrf-top-k
  • rerank-top-k
  • context-top-k
  • max-context-chars

这些参数不是为了显得专业,而是因为每一层的目标不同:召回要广,排序要稳,上下文要精。

七、回答约束:宁可说没依据,也不要编

RAG 的可信度最终体现在回答上。

my-rag 给 Chat 模型的系统约束很明确:

请只根据用户提供的资料片段回答问题。
如果片段中没有足够依据,必须回答“当前资料中没有找到明确依据。”。
不要编造资料中没有出现的信息。
回答后列出引用来源,格式为 [source_1]、[source_2]。

这段约束有几个关键点。

第一,它把模型角色限制为“资料问答助手”,而不是通用聊天助手。

第二,它明确允许模型拒答。RAG 系统最重要的能力之一,不是每个问题都回答,而是知道什么时候没有依据。

第三,它要求引用来源。引用不是装饰,而是用户判断答案可信度的入口。

如果模型没有主动输出来源,系统还会在回答末尾补充引用来源,包括文档标题、章节标题和 chunkId。

八、Chat log:让失败样本变成优化材料

没有日志,就没有真正的 RAG 优化。

my-rag 会记录:

  • 用户问题。
  • 模型回答。
  • 实际使用的文档 ID。
  • 被引用的 chunk ID。
  • topK。
  • minScore。
  • 整体延迟。

这些字段可以帮助我们复盘一次回答。

比如用户说“答案不对”,可以按顺序看:

  1. 当时选中了哪些文档?
  2. 这些文档是否都是 READY
  3. 召回了哪些 chunk?
  4. chunk 内容是否包含答案?
  5. 如果包含,模型为什么没答出来?
  6. 如果不包含,是向量召回、关键词召回还是 chunk 策略出了问题?

这比直接改 prompt 有效得多。

九、调试 RAG 时,我会先看这五个问题

Q:文档是否真的参与了检索?

A:先看文档状态和检索范围。很多问题不是模型问题,而是文档根本还没进入 READY

Q:召回片段是否包含答案?

A:如果召回片段里没有答案,后面所有 prompt 优化都只是碰运气。应该先调 chunk、topK、score threshold 或混合检索。

Q:正确片段排名是否太靠后?

A:如果正确片段被召回了,但没有进入上下文,就要看 context-top-k 和 rerank 策略。

Q:片段是否缺少上下文?

A:如果 chunk 看起来相关,但回答缺少关键条件,可能是 chunk 太小或 overlap 不够。

Q:模型是否违反资料约束?

A:如果证据充足但模型自由发挥,就要加强系统提示、降低 temperature,或者在回答后做引用校验。

十、不要急着追求“最佳参数”

RAG 检索参数没有通用最优解。

技术文档、制度文件、电子书、客服知识库,对 chunk 大小、关键词权重、topK、上下文长度的要求都不一样。

我更倾向于用失败样本驱动调参:

  • 如果常常漏掉精确配置项,增强关键词查询。
  • 如果语义问题召回太窄,提高向量 topK。
  • 如果正确片段召回了但没进 prompt,调整 rerank 或 context topK。
  • 如果模型经常泛泛而谈,缩短上下文并强化引用约束。
  • 如果回答经常无依据,降低 score threshold 要谨慎,先看资料是否真的存在答案。

参数不是一次配出来的,而是被真实问题慢慢磨出来的。

十一、这一版仍然只是起点

当前检索链路已经比纯向量 demo 更完整,但它还不是终点。

后续可以继续演进:

  • 接入真实 reranker 服务。
  • 建立标准问答集,做离线评测。
  • 对不同文档类型使用不同 chunk 策略。
  • 增加引用一致性检查。
  • 用用户反馈反推召回和回答质量。
  • 增加更细的检索调试面板。

这些都值得做,但前提是基础链路能解释清楚。

结语:RAG 优化的核心是可解释

RAG 系统不是“把资料塞给大模型”这么简单。一个可信的回答背后,至少有四层判断:

  1. 哪些文档有资格参与回答?
  2. 哪些 chunk 被召回?
  3. 哪些 chunk 进入了上下文?
  4. 模型是否基于这些证据回答?

my-rag 这一阶段的目标,就是让这四层都能被看见、被记录、被调试。

当回答错了,我们不应该只说“模型不行”。更好的状态是能指出:这次错在范围解析、召回、排序、上下文,还是生成。只有这样,RAG 系统才会从一个演示 demo,慢慢变成一个可以长期打磨的工具。