Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

当向量相似度把“2020 Q4“当成“2025 Q4“

问系统:“腾讯 2025 年第四季度的营收是多少?“秒回:“根据财报披露,公司第四季度营收为 1,287 亿元,同比增长 8.4%。“语气专业,格式标准,数字和增长率一应俱全。唯一的问题是:这组数字是 2020 年的【待核实:该数字是素材中的错答示例,与腾讯 2020 Q4 实际披露值(1,336.69 亿元、同比 +26%)核对不上,按大纲口径标注为示意性例子,发布前确认是替换为真实数字还是保留示意】。

这是我在财报 RAG 项目里真实踩过的坑。先交代一句背景:RAG 是“先从文档库检索、再让模型基于检索结果回答“的架构。上面这种错法,是 RAG 里一类很隐蔽的失败——检索时的时间错配。更要命的一点:那条 2020 年的结果,相似度分数还很高。

一次“答错得很流畅“的检索现场

为什么 2020 Q4 和 2025 Q4 在向量空间里是邻居?拆开看并不难理解。财报里管理层讨论的措辞模板,每年只换几个数字;利润表的表头格式,十年没怎么变过。embedding 读到的是“营收““第四季度”“同比增长“这些词,两段文本在语义上几乎同构,它给出的向量距离自然很近——近到在 top-k 里总是相邻。

但这不是全部。更关键的是个工程失误:chunk 的 metadata 里明明有 report_year 字段,入库时就标好了每个 chunk 来自哪一年。问题是这个字段只被拿去做 citation 展示——生成答案时显示一句“来源:腾讯 2020 年年报“——没有参与召回,也没有参与排序。系统“知道“每个 chunk 是哪一年的,但检索时“没用上“。

这种失误比我以为的普遍。我见过的几个项目里,metadata 被采集了、存了、甚至在前端展示了,唯独没进检索层。

所以这类失败真正危险的地方,不在于答错,在于答错得很流畅。语气稳定、格式正确、还附带同比增长率,用户不刻意核对年份根本发现不了。“答不上来“用户一眼就看见了;“答错得很流畅“要核对到具体数字才暴露。在财报场景里,后者才是事故。

换个领域,同样的错法:1000kV 与 220kV

这不是财报特供问题。我在电力系统的规范文档上又碰到过一次,换了一副面孔。

电力文档里,1000kV 特高压和 220kV 高压的运行规程、维护手册,文字上几乎是孪生:都涉及变电站、断路器、线路保护;章节结构高度相似;还共享同一套术语表——“主变压器”“冷却方式”“差动保护”,两边都这么写。

用户问“1000kV 变电站主变压器的冷却方式“,系统完全可能召回 220kV 的条目。光看文本,连模型都难判断哪一段适用于哪个电压等级——而电压等级恰恰是这个问题最关键的约束。外行看不出差别,但两个电压等级的设备规格、保护策略、维护周期完全不同。把 220kV 的规程当 1000kV 的答案用,在工程上是真会出事的。

共同根因:向量检索找的是“像“,不是“符合条件“

两个场景并排摆,根因是同一个:

场景强约束维度metadata 里有没有参与检索了吗
财报跨年report_year / period_type / quarter有没有
电力规范voltage_level看实现,经常缺没有

文档天然带强约束,约束字段在 metadata 里也明明存在,但它不参与召回和排序。两行的答案都是“有,但没用“。

往深说一句:向量检索的本质是找“语义最像的“,不是找“符合约束的“。这两件事在 demo 里是一致的——测试库只有一份年报的时候,“Q4 营收“怎么搜都对。但只要库里出现“语义近似、约束不同“的孪生文档,纯向量检索就开始失灵。

而在我见过的企业文档库里,这种孪生几乎躲不掉:跨年财报、跨版本规范、跨规格设备手册、跨地区合同模板。demo 里没事,生产环境一上量就显形。这不是例外,是常态——至少在我碰过的项目里是。

四层解法,按优先级排

处理这类问题,我后来固定成一个顺序。四层不是并列清单,是按“根本性 × 实现成本“排出来的优先级,能往前做就不往后拖。

第一层:metadata 预过滤(根本解)

检索之前,先把 query 里的约束解析出来,在数据库层硬过滤,过滤完再谈语义相似:

user_query = "腾讯 2025 年 Q4 的营收"

# 第一步:从 query 解析约束(LLM parsing,或规则匹配高频模式)
parsed = {
    "query_text": "腾讯 营收",
    "filters": {"report_year": "2025", "period_type": "quarterly", "quarter": "Q4"},
}

# 第二步:先硬过滤,再做语义检索
results = vectorstore.search(query=parsed["query_text"], filters=parsed["filters"])

关键在顺序:filters 发生在向量相似度之前,2020 年的 chunk 根本进不了候选集。Chroma、Milvus、Pinecone、Qdrant、Weaviate 这些主流向量库都支持 metadata filter,改动通常只是检索调用多个参数,不换模型、不重建索引。改动最小、效果最大、最确定,这是它排第一的原因。

唯一的前提:query 里抽得出约束。财报场景里,年份、季度、指标名几乎必然出现在 query 里,LLM parsing 或规则匹配都够用。抽不出的情况,后面单独说。

第二层:query 改写,把约束塞进文本

如果向量库不支持 filter(少数嵌入式场景),退而求其次:把约束显式写进检索文本和 chunk 文本。原始 query“腾讯第四季度营收“,改写成“腾讯 2025 年第四季度财报 营收 利润 业绩“,让“2025““第四季度“作为 token 参与语义匹配,把 2020 年的相似度自然压下去:

# chunk 入库时,把约束写进文本前缀
chunk.text = f"【{report_year}年{period_type}】{original_text}"

# 电力文档同理
chunk.text = f"[电压等级:1000kV] {original_chunk}"

这是软手段,不是硬过滤。代价有两笔:一是 token 浪费,每个 chunk 都多背一段前缀;二是稳定性没保证——效果取决于 embedding 对这段前缀的敏感度,它不买账的时候你一点办法都没有。能用 filter 就别用它。

第三层:时间衰减,排序层兜底

就算初筛漏了,排序阶段还能再压一次:

def time_decay_score(base_score, doc_year, current_year=2025):
    age = current_year - int(doc_year)
    return base_score * (0.85 ** age)  # 每旧一年打八五折,2020 年的分数只剩 0.85^5 ≈ 0.44

2020 年的文档即使相似度和 2025 年一样高,final score 也只剩四成多。但它是兜底不是主力,因为它分不清“用户就是要查旧数据“——人家问“2020 年腾讯收入是多少“,衰减反而把正确答案压掉了。所以系数要温和,还要按 query 的时间意图动态调整:query 里明确带了年份,这一层就该直接跳过。

第四层:入库去重和版本管理

入库时按 (company, section, period) 去重只留最新版,或者保留多版本、但打上 is_latest=True 和 supersedes 标记,检索时优先命中 is_latest。这一招只适合“只关心最新“的场景;业务要做历史比较就禁用——会把可比对象全删掉。

四层收束成一个框架,其实就三步:入库时把可能成为查询约束的字段都抽进 metadata(打约束标签);检索时第一道筛子是 metadata filter,不是向量相似度(硬过滤);召回之后用 reranker 兜底——cross-encoder 对约束差异确实比 bi-encoder 敏感,但它是补充不是替代。只靠 reranker,延迟高还不稳;只靠 filter,query 解析一失败链路就断;只靠改写,天花板太低。三层都得在。

query 里抽不出约束怎么办

上面四层有个共同前提:query 里抽得出约束。但真实 query 大量是模糊的——“腾讯营收是多少”,没说哪一年;“变压器冷却方式怎么配”,没说电压等级。

这种时候我的原则是别悄悄猜。三种降级策略:

默认值策略。 财报场景默认“最近一份报告“,但必须在答案里写清楚“以下基于 2025 年数据“,把隐式假设显性化。我自己的项目里,问最新数据的查询占大头,凭印象有八成上下【待核实:无统计来源,纯个人项目观察口径,发布前确认保留还是删除】——默认值策略在我的场景成立,在你的场景未必,先看过自家日志再定。

模糊优先策略。 让模型把候选列出来请用户选:“我找到了 2020、2022、2025 三个年份的相关数据,你具体想看哪一年?“适合对话型应用,代价是多一轮交互;一次性问答的场景用不上。

上下文继承。 用户先问“腾讯 2025 中报营收“,再问“毛利呢“——第二个问题应该自动继承上一轮的约束。这需要维护对话状态,实现复杂度高一截,但交互体验收益最大。拿复杂度换体验,值不值看产品形态。

三条策略的共同点是同一个:假设可以默认,猜测必须暴露——要么告诉用户你假设了什么,要么问清楚。

两个边界:别把消歧当混检,别指望换 embedding 模型

方法论说完,划两条边界。这两个“别“,我都见人踩过。

边界一:别把消歧当混检。 如果你的问题是“用户搜’苹果’,系统返回了水果苹果而不是苹果公司“——这不是混检,是消歧。这种场景下文档没有显式约束字段,用户也没说“水果“还是“公司“,metadata filter 帮不上忙。该走的是上下文消歧(对话里有没有“股票““投资“这类 hint)、query rewriting(让模型先判意图再改写)、reranker(给 cross-encoder 看完整上下文)。判断标准就一条:文档天然有没有强约束字段——年份、版本、规格。有,走 metadata filter;没有、只有隐含主题,走消歧那条路。两类问题弄混,优化方向会完全错。

边界二:别指望换更准的 embedding 模型。 “换个更强的 embedding,是不是就能区分了?“不能。“Q4 营收 1287 亿、同比增长 8.4%“和“Q4 营收 1800 亿、同比增长 6.2%”【待核实:同篇首,错答示例数字,发布前一并处理】这两个句子,在语义上是真的高度相似——同样的概念、同样的句式、同样的上下文结构——换什么模型,向量距离都拉不开。问题不在向量检索不够准,在于向量检索的“准“和用户要的“准“不在同一个维度:一个保证语义相似,一个要求约束匹配。库里没有孪生文档时,这两者是同义词;有,就不是。

约束不是显示用的,是检索用的

如果你的 chunk metadata 里已经有约束字段、检索时却没用上,先把这一步做了——它是我知道的所有 RAG 优化里,改动最小、收益最大的一步。

这篇是《RAG 心得系列》发出的第一篇。这个系列会沿着“数据上游 → 检索的错觉 → 系统判断 → 边界与展望“这条线,把一条财报 RAG 链路逐环走完:从 PDF 解析、分片、metadata 标注这些上游环节讲起,穿过检索层的几种错觉,再到评估、运维和边界。时间错配,只是检索错觉的第一层。

第二层是口径。就算时间对齐了、检索到的确实是 2025 年的文档,事情也没完——同名指标在不同报告里的口径可能不一样,同样叫“营收“,中报和年报未必是同一个数。“时间对、口径错”,是另一种答错得很流畅,第 7 篇《财报 RAG 的口径陷阱:同名指标,不同意思》专门讲它。