给 RAG 答案做引用证据卡时,我会先把 chunk 版本存下来
最近在做一个内部知识库问答台时,我遇到一个很实际的问题:答案看起来有引用,排查时却很难证明这条引用当时来自哪个 chunk、哪一次索引构建、哪一个重排版本。业务同学只看到“引用 1”,开发同学要去翻向量库、日志和原文,最后还是靠经验判断。后来我把引用从页面装饰改成证据卡,让每个回答都能顺着来源找回去。

原创示意图:把用户问题、候选 chunk、重排结果、回答草稿和引用证据卡拆成可核验链路。 来源:Codex image generation
问题背景
LangChain 的 Retrieval 文档把 RAG 描述为在查询时取回相关外部知识,再把上下文交给模型生成答案。真实项目里,最容易松动的是“相关”两个字。检索结果可能来自不同文档版本,chunk 可能被重新切分,向量也可能换过 embedding 模型。用户看到的是自然语言,真正影响可信度的是背后证据是否稳定。
LlamaIndex 的 CitationQueryEngine 示例给了我一个提醒:引用不能只停在编号层面。它可以从已有 index 构建 citation query engine,也能通过 source_nodes 检查实际来源,并用 citation_chunk_size 控制引用颗粒度。我没有照搬框架实现,但沿用了这个判断:答案、引用编号、来源节点和原文片段要能互相追溯。
关键难点
第一个难点是 chunk 身份会漂移。同一份文档只要标题规则、重叠长度或清洗脚本改了,旧的 chunk_id 就不再可靠。第二个难点是前端展示容易过度简化,只显示文件名和页码,解释不了为什么模型引用这段。第三个难点是向量分数容易被误读。Elastic 的 kNN 文档说明,分数来自查询向量和文档向量的相似度,近似 kNN 还会受 num_candidates 等参数影响,所以证据卡要展示检索上下文,分数只能作为线索。
解决思路
我把证据卡拆成三层。第一层给用户看:文档标题、片段摘要、更新时间和命中位置。第二层给开发排障:source_id、source_hash、chunk_version、embedding_model、retrieval_top_k、rerank_score 和 index_run_id。第三层给审计用:原始片段快照,只保存必要窗口,避免把整份文档塞进回答记录。
向量库里也要配合存元数据。Qdrant 的 payload 文档说明,向量可以携带 JSON 形式的附加信息。我会把来源 hash、文档版本、chunk 版本和权限范围写入 payload,检索返回后再进入证据卡构建器。这样前端拿到的引用是一组带来源身份和版本信息的卡片。
关键步骤
落地时我先固定 chunk 版本规则。切分配置、清洗脚本版本、embedding 模型和文档 hash 一起参与 chunk_version 生成。然后在检索服务里新增 evidence_builder,把召回候选、重排结果和最终注入 prompt 的片段合并成同一份证据对象。回答生成完成后,只允许模型引用已经进入证据对象的编号,前端也只渲染这些编号对应的卡片。
前端部分我会把引用卡放在回答旁边。卡片默认显示标题、片段摘要和命中位置,展开后再显示分数、chunk 版本和索引批次。遇到“答案引用失效”时,先看 chunk 是否来自当前索引,再看原文窗口是否足够,再看重排分数是否异常,最后回到 prompt 和模型输出。
可复用经验
RAG 的可信度不能只靠回答语气。引用要能回到片段,片段要能回到索引版本,索引版本要能回到导入任务。把这三层链路补齐后,很多“答非所问”的问题会变得具体:是没有召回正确文档,召回了但重排靠后,还是模型没有使用已经注入的证据。
我现在做知识库问答,会把证据卡当成第一版工程能力。它对用户是可核验引用,对开发是排障入口,对后续评测也是样本来源。等这些字段沉淀下来,再做 golden set、召回对比和人工反馈闭环会顺很多。
主要来源
LangChain Retrieval: https://docs.langchain.com/oss/python/langchain/retrieval
LlamaIndex CitationQueryEngine: https://developers.llamaindex.ai/python/examples/query_engine/citation_query_engine/
Qdrant Payload: https://qdrant.tech/documentation/concepts/payload/
Elastic kNN search: https://www.elastic.co/docs/solutions/search/vector/knn