# 为什么 RAG 会信心十足地引用已作废的政策

> 嵌入向量没有时间概念，解法是给每个分块加有效期，每次查询带 as-of 日期，再按日期测试。

原文: https://fdetimes.net/zh/guides/version-aware-rag-effective-policy/

设想客户修订了退款政策：退款期限从 30 天缩短为 14 天，2026 年 7 月 1 日起生效。两个版本都存在向量库里。一名员工问聊天机器人“客户有多长时间可以申请退款？”，机器人信心十足地回答 30 天，还附上了引用。

问题不在大模型。Particula Tech 指出，修订版与它替换的旧版在嵌入空间中几乎是重复内容，过期版本很容易排在第一位。语义相似度没有时间概念。

在企业 RAG 项目中，前线部署工程师（FDE）几乎一定会遇到这个问题，因为每份政策、合同和流程迟早都会出新版本。本指南从 payload 讲到过滤条件，再到测试集，目标是让系统只依据所问时点正在生效的版本作答。

## 要做出什么，需要准备什么

最终成果是一条由三部分组成的数据管道：为每个分块附加有效期区间的摄取环节，按 as-of 日期过滤的检索环节，以及按日期设计的测试集。需要准备一个向量库（主示例用 Qdrant，另有章节介绍如何迁移到 Pinecone 和 pgvector）、一份至少有两个版本的文档，以及用于编写摄取代码的 Python。

为什么要费这番功夫？VersionRAG 是一种已发表的方法，与本文介绍的元数据过滤管道不同。其作者在 VersionQA 基准上测得 VersionRAG 准确率为 90%，朴素 RAG 为 58%，GraphRAG 为 64%。这一差距说明，面对会变化的文档，忽略版本的 RAG 会答错多少。

## 第 1 步：生效日期必须写在分块上

最常见的设计错误，是只在文档登记表里记录生效日期。Particula Tech 强调，生效日期必须附加到每个分块上，因为检索过滤的是分块，而不是文档。所以每个分块都要带上生成它的那个版本的完整元数据。

```python
# 简化示例：单个分块的 payload
SENTINEL = "9999-12-31T23:59:59Z"

def make_payload(doc_id, version, text, effective_from, effective_to=None):
    return {
        "doc_id": doc_id,
        "version": version,
        "status": "effective",
        "effective_from": effective_from,           # RFC 3339 格式
        "effective_to": effective_to or SENTINEL,   # 绝不为 null
        "text": text,
    }
```

关键在 `effective_to or SENTINEL` 这一行。当前版本还没有失效日期，直觉上会把这个字段留空。Particula Tech 建议改为写入一个明确的远未来哨兵值，因为在大多数向量库中，缺失的键不会匹配范围条件。换句话说，被过滤掉的恰恰是最新的分块。

**检查：**统计 `effective_to` 为空或缺失的分块数量，正确结果是 0。

## 第 2 步：新版本到来时，关闭旧版本

2026 年 7 月 1 日，v2 以 `effective_from = "2026-07-01T00:00:00Z"` 摄取入库。与此同时，所有 v1 分块的 `effective_to` 都要更新为这个时间戳，`status` 改为 `superseded`。此后 v1 的区间是封闭的，v2 则一直延续到哨兵值。

元数据正确还不足以高枕无忧。Particula Tech 提醒，如果所有版本共用一个集合，只要有一次忘了加过滤条件，系统就会返回一条已被取代、却看似正确的条款。

他们更推荐的做法是把旧版本移到单独的归档集合。主集合只保存正在生效的版本，归档集合保留过期版本，供涉及过去的问题使用。

**要点:** 忘加过滤条件不会报错，系统只会悄无声息地返回旧答案。

**检查：**不加任何过滤条件查询主集合。如果仍有分块带着 `status = superseded`，说明迁移到归档集合的环节出了问题。

## 第 3 步：每次查询都带 as-of 日期

按照 Particula Tech 的建议，每次查询都应带一个 as-of 时间戳，默认取当前时间，并只保留有效期区间包含该时点的分块。这样，“5 月份的时候，客户有多长时间可以退款？”这类问题，区别只在于传入的 as-of 日期不同。

Qdrant 支持对 RFC 3339 格式的 datetime payload 设置范围条件。`must` 子句只有在其中所有条件都成立时才为真，正适合把状态和有效期区间组合在一起。下面是简化后的过滤条件：

```json
{
  "must": [
    { "key": "status", "match": { "value": "effective" } },
    { "key": "effective_from", "range": { "lte": "2026-10-26T00:00:00Z" } },
    { "key": "effective_to",   "range": { "gt":  "2026-10-26T00:00:00Z" } }
  ]
}
```

注意 `lte` 与 `gt` 的搭配。区间左闭右开，所以 7 月 1 日 00:00 整只有 v2 匹配，两个版本永远不会同时通过。

对于涉及过去的问题，去掉 `status` 条件，保留两个区间条件并换上新的 as-of 日期，同时检索主集合和归档集合。如果只查归档集合，问到当前版本已经生效的日期时会一无所获，因为该版本仍在主集合里。

**检查：**先把 as-of 设为 2026 年 6 月 15 日，再设为 2026 年 7 月 15 日，在两个集合中问同一个问题。第一次必须引用 v1（30 天），第二次必须引用 v2（14 天）。

## 换成 Pinecone 或 pgvector 会出什么问题？

每种向量库都有自己的陷阱，最好在给客户演示之前就搞清楚。

Pinecone 为元数据提供了 `$gte`/`$lte` 运算符，但只对数值有效。因此生效日期必须存成数值，比如 epoch 秒数，哨兵值也必须是数值。下面是简化示例：

```json
{
  "status": { "$eq": "effective" },
  "effective_from": { "$lte": 1792972800 },
  "effective_to":   { "$gt":  1792972800 }
}
```

其中 `1792972800` 是 2026-10-26T00:00:00Z 对应的 epoch，相应的哨兵值是 `253402300799`（9999-12-31T23:59:59Z）。如果不小心把日期存成字符串，范围过滤就不会按预期工作。

pgvector 的陷阱更隐蔽。使用近似索引时，WHERE 子句要等索引扫描结束后才生效。结果是：请求前 10 条，索引返回最相近的 10 个分块，其中大多是旧版本，过滤后剩下的不足 10 条。

pgvector 的 README 建议使用迭代扫描（iterative scan）；如果过滤字段只有少数几个不同取值，可以使用部分索引，`status = 'effective'` 正是这种情况。下面的语句仅作示意，请对照所用版本的 pgvector 文档核实：

```sql
CREATE INDEX ON policy_chunks
USING hnsw (embedding vector_cosine_ops)
WHERE status = 'effective';
```

**检查：**请求前 k 条，统计返回的行数。如果经常少于 k，就开启迭代扫描或使用部分索引。

## 第 4 步：按日期设计的测试集，是给客户的证明

过滤条件只解决了一半问题。同一项 VersionRAG 研究显示，在识别版本间隐性变化的问题上，各基线方法几乎全军覆没，准确率只有 0-10%，而 VersionRAG 达到 60%。

由此可见，即便元数据干净，“退款政策改了什么？”这类问题依然很难。与其向客户许诺，不如把它们放进测试集。

最小的测试集由三元组构成：问题、as-of 日期、预期版本。要覆盖修订日期之前、之后、恰好在切换时点的问题，再加几道询问改了什么的题。

每次重新摄取或更换嵌入模型时，都在 CI 中运行这套测试，检查引用的版本，而不只是答案文本。

## 哪些错误最常让管道失效？

把系统交给客户之前，再过一遍上面各步骤提到的五个错误。每个都有快速检查方法。

| 错误 | 症状 | 修复 |
|---|---|---|
| 生效日期只记录在文档登记表中 | 检索过滤没有可用字段 | 在每个分块上写入 effective_from、effective_to 和 status |
| 当前版本的 effective_to 留空 | 最新版本从结果中消失 | 用远未来哨兵值回填 |
| 旧版本与新版本共用一个集合 | 忘加一次过滤就返回旧条款 | 把旧版本移到归档集合 |
| 在 Pinecone 中把日期存成字符串 | 范围过滤没有按预期生效 | 存 epoch 数值，哨兵值也用数值 |
| pgvector 中 WHERE 在近似索引之后才生效 | 请求前 k 条，返回不足 k 条 | 开启迭代扫描或使用部分索引 |

如果时间只够修一处，先从第二行做起：统计缺少 effective_to 的分块只需几分钟，却能堵住最难察觉的故障。

## 在客户现场，这项技能是什么样子？

到客户那里，第一件事不是写代码，而是去问文档的负责人：版本怎么标注，生效日期存在哪里，由谁来判定某个版本已被取代。

答案往往藏在文件名里、某张电子表格里，或者只在法务部某个人的脑子里。拿不到这些信息，第 1 步就无从做起。

如果在申请应用 AI 方向的 FDE 岗位，可以留意那些提到基于内部文档、合规文档或法律文档做 RAG 的职位描述。

简历上不要只写“搭建了 RAG”，而要写清楚具体解决了什么问题：杜绝引用过期版本，并用带 as-of 日期的测试集加以证明。招聘方会看出你理解真实的客户数据。

向量库里只有一个版本时，任何演示都能跑通。客户发布第一次修订的那一天，系统才真正接受考验。

**本周动手试试:**

- 在向量库中找一份有两个版本的文档，专门问改动过的那条条款，记下排第一的是哪个分块
- 扫描全部分块，统计缺少 effective_to 或其值为 null 的数量，再用哨兵值回填
- 编写 10 道带 as-of 日期的测试题，一半在修订日期之前，一半在之后，放进 CI 运行

## 信源

- [RAG Returns Outdated Documents: Effective Dates and Ranking](https://particula.tech/blog/version-aware-rag-superseded-documents)

- [VersionRAG: Version-Aware Retrieval-Augmented Generation for Evolving Documents](https://arxiv.org/abs/2510.08109)

- [Filtering - Qdrant](https://qdrant.tech/documentation/concepts/filtering/)

- [Filter by metadata](https://docs.pinecone.io/guides/search/filter-by-metadata)

- [GitHub - pgvector/pgvector: Open-source vector similarity search for Postgres](https://github.com/pgvector/pgvector)
