第2371篇:时序文档的RAG设计——让AI理解"最新的"和"之前的"信息
大约 7 分钟
第2371篇:时序文档的RAG设计——让AI理解"最新的"和"之前的"信息
适读人群:处理有时间维度知识库的AI工程师 | 阅读时长:约18分钟 | 核心价值:解决RAG系统中时序信息的检索混乱问题,掌握文档时效性管理的工程方案
去年有个让我印象深刻的Bug。
客户打来电话说,他们的AI助手告诉员工"我们公司的产假是90天",但实际上公司在三个月前已经把产假延长到了128天,员工已经按照AI说的错误信息来规划了,影响很大。
查了一下原因:知识库里同时存着2021年的员工手册和2024年更新版,两份文档都被检索到了,LLM混合了两个版本的信息,恰好引用了旧版本的数据。
这就是时序文档问题的典型场景:当知识库里存在同一个主题的多个时间版本时,RAG必须知道哪个是"最新的",并优先使用它。
时序文档问题的几种形态
不是只有"新版本覆盖旧版本"这一种情况,还有更复杂的:
/**
* 时序文档的四种典型场景
*
* 场景1:版本替换
* - 2024年员工手册 替代 2021年员工手册
* - 正确处理:只使用最新版本
*
* 场景2:累积叠加
* - 基础合同 + 补充协议一 + 补充协议二
* - 正确处理:所有文档都有效,但需要理解叠加关系
*
* 场景3:历史查询
* - "2022年的产假政策是什么?"
* - 正确处理:必须找2022年的旧版本,不能用新版本回答
*
* 场景4:变化追踪
* - "我们的产假政策变化了几次,每次怎么变的?"
* - 正确处理:需要所有版本,并按时间排序展示
*/文档元数据设计:时序信息的基础
解决时序问题,先从文档元数据设计下手。
/**
* 带时序信息的文档元数据
*/
@Data
@Builder
public class TimedDocumentMetadata {
/**
* 文档的生效日期(比创建日期更重要)
* 例如:一份文件2024-12-01发布,但从2025-01-01起生效
*/
private LocalDate effectiveDate;
/**
* 文档的失效日期(如果有的话)
* null表示"至今有效"
*/
private LocalDate expirationDate;
/**
* 文档版本号
* 语义化版本或简单的数字版本
*/
private String version;
/**
* 文档所属的"主题域"
* 用来识别哪些文档是同一个主题的不同版本
* 例如:"员工手册"、"产品定价表"、"服务条款"
*/
private String topicKey;
/**
* 是否是当前有效版本
*/
private boolean isCurrentVersion;
/**
* 被哪个文档取代了(如果已过期)
*/
private String supersededBy;
}
@Service
public class TimedDocumentIngester {
/**
* 文档写入时的处理逻辑
*/
public void ingestDocument(String content, TimedDocumentMetadata metadata) {
// 1. 如果是某个topicKey的新版本,将旧版本标记为非当前版本
if (metadata.getTopicKey() != null && metadata.isCurrentVersion()) {
markPreviousVersionsObsolete(metadata.getTopicKey(), metadata.getDocId());
}
// 2. 文档内容加入元数据后存储
Document doc = Document.builder()
.content(content)
.metadata(Map.of(
"effective_date", metadata.getEffectiveDate().toString(),
"expiration_date", metadata.getExpirationDate() != null
? metadata.getExpirationDate().toString() : "9999-12-31",
"version", metadata.getVersion(),
"topic_key", metadata.getTopicKey(),
"is_current", String.valueOf(metadata.isCurrentVersion())
))
.build();
vectorStore.add(List.of(doc));
}
/**
* 将同一topicKey的历史版本标记为非当前版本
*/
private void markPreviousVersionsObsolete(String topicKey, String newDocId) {
// 查找同主题的所有文档
List<Document> existing = findByTopicKey(topicKey);
for (Document doc : existing) {
if (!doc.getId().equals(newDocId)) {
// 更新元数据,标记为历史版本
updateMetadata(doc.getId(), "is_current", "false");
updateMetadata(doc.getId(), "superseded_by", newDocId);
}
}
}
}检索层:时序感知的查询处理
@Service
public class TemporalAwareRetriever {
private final VectorStore vectorStore;
private final ChatClient chatClient;
/**
* 时序感知检索
* 先分析问题的时间意图,再决定检索策略
*/
public RetrievalResult retrieve(String question) {
TemporalIntent intent = analyzeTemporalIntent(question);
return switch (intent.getType()) {
case CURRENT -> retrieveCurrentOnly(question);
case HISTORICAL -> retrieveHistorical(question, intent.getTargetDate());
case COMPARISON -> retrieveMultipleVersions(question, intent.getTopicKey());
case TRACKING -> retrieveAllVersions(question, intent.getTopicKey());
case UNSPECIFIED -> retrieveCurrentWithFallback(question);
};
}
/**
* 分析问题的时间意图
*/
private TemporalIntent analyzeTemporalIntent(String question) {
// 快速规则判断
if (containsHistoricalIndicators(question)) {
LocalDate targetDate = extractTargetDate(question);
return TemporalIntent.historical(targetDate);
}
if (containsComparisonIndicators(question)) {
return TemporalIntent.comparison(extractTopicFromQuestion(question));
}
if (containsTrackingIndicators(question)) {
return TemporalIntent.tracking(extractTopicFromQuestion(question));
}
// 默认:最新版本
return TemporalIntent.current();
}
private boolean containsHistoricalIndicators(String question) {
List<String> patterns = Arrays.asList(
"\\d{4}年", "上个季度", "之前", "原来", "以前",
"历史上", "过去", "当时"
);
return patterns.stream().anyMatch(p -> question.matches(".*" + p + ".*"));
}
/**
* 只检索当前有效版本
*/
private RetrievalResult retrieveCurrentOnly(String question) {
FilterExpression filter = FilterExpressionBuilder.builder()
.eq("is_current", "true")
.build();
List<Document> docs = vectorStore.similaritySearch(
SearchRequest.query(question)
.withTopK(5)
.withFilterExpression(filter)
);
return RetrievalResult.of(docs, RetrievalStrategy.CURRENT_ONLY);
}
/**
* 检索特定时间点有效的版本
*/
private RetrievalResult retrieveHistorical(String question, LocalDate targetDate) {
String dateStr = targetDate.toString();
// 找在targetDate当天生效(effective_date <= targetDate)
// 且未失效(expiration_date >= targetDate)的文档
FilterExpression filter = FilterExpressionBuilder.builder()
.lte("effective_date", dateStr)
.gte("expiration_date", dateStr)
.build();
List<Document> docs = vectorStore.similaritySearch(
SearchRequest.query(question)
.withTopK(5)
.withFilterExpression(filter)
);
return RetrievalResult.of(docs, RetrievalStrategy.HISTORICAL);
}
/**
* 检索某个主题的所有版本(用于变化追踪)
*/
private RetrievalResult retrieveAllVersions(String question, String topicKey) {
FilterExpression filter = FilterExpressionBuilder.builder()
.eq("topic_key", topicKey)
.build();
// 检索所有版本,按时间排序
List<Document> docs = vectorStore.similaritySearch(
SearchRequest.query(question)
.withTopK(20) // 多拿一些,要覆盖所有版本
.withFilterExpression(filter)
);
// 按effective_date排序
docs.sort(Comparator.comparing(
d -> LocalDate.parse((String) d.getMetadata().get("effective_date"))
));
return RetrievalResult.of(docs, RetrievalStrategy.ALL_VERSIONS);
}
}Prompt设计:引导LLM处理时序信息
@Service
public class TemporalPromptBuilder {
/**
* 根据检索策略构建不同的Prompt
*/
public String buildPrompt(String question, RetrievalResult retrieval) {
return switch (retrieval.getStrategy()) {
case CURRENT_ONLY -> buildCurrentPrompt(question, retrieval);
case HISTORICAL -> buildHistoricalPrompt(question, retrieval);
case ALL_VERSIONS -> buildTrackingPrompt(question, retrieval);
default -> buildDefaultPrompt(question, retrieval);
};
}
private String buildCurrentPrompt(String question, RetrievalResult retrieval) {
String context = buildContextWithDates(retrieval.getDocs());
return """
以下是当前有效的文档内容。请基于这些信息回答问题。
注意:这些都是当前最新有效的规定,请直接基于这些内容回答。
%s
问题:%s
""".formatted(context, question);
}
private String buildHistoricalPrompt(String question, RetrievalResult retrieval) {
String context = buildContextWithDates(retrieval.getDocs());
LocalDate targetDate = retrieval.getTargetDate();
return """
以下是%s时期有效的文档内容。请回答关于这一历史时期的问题。
重要提示:这些是历史文档,可能不反映当前规定。如果用户询问的是历史情况,
请直接基于历史文档回答;如有必要,可以说明当前规定已有变化。
%s
问题:%s
""".formatted(
targetDate != null ? targetDate.getYear() + "年" : "指定历史时期",
context,
question
);
}
private String buildTrackingPrompt(String question, RetrievalResult retrieval) {
StringBuilder versionsText = new StringBuilder();
for (Document doc : retrieval.getDocs()) {
String effectiveDate = (String) doc.getMetadata().get("effective_date");
String version = (String) doc.getMetadata().get("version");
versionsText.append(String.format(
"【版本%s,生效日期:%s】\n%s\n\n",
version, effectiveDate, doc.getContent()
));
}
return """
以下是按时间顺序排列的所有版本文档,请据此分析变化历程。
%s
问题:%s
请按时间顺序说明变化情况。
""".formatted(versionsText.toString(), question);
}
private String buildContextWithDates(List<Document> docs) {
StringBuilder sb = new StringBuilder();
for (Document doc : docs) {
String effectiveDate = (String) doc.getMetadata().getOrDefault("effective_date", "未知");
boolean isCurrent = Boolean.parseBoolean(
(String) doc.getMetadata().getOrDefault("is_current", "false")
);
sb.append(String.format(
"[文档,生效日期:%s,%s]\n%s\n\n",
effectiveDate,
isCurrent ? "当前有效" : "历史版本",
doc.getContent()
));
}
return sb.toString();
}
}自动过期检测与维护
@Component
public class DocumentExpirationManager {
/**
* 定时任务:每天检查文档时效性
*/
@Scheduled(cron = "0 0 2 * * *") // 每天凌晨2点
public void checkDocumentExpiration() {
LocalDate today = LocalDate.now();
// 查找今天生效的文档
List<Document> newlyEffective = findDocumentsEffectiveOn(today);
for (Document doc : newlyEffective) {
String topicKey = (String) doc.getMetadata().get("topic_key");
if (topicKey != null) {
// 将同主题的旧文档标记为失效
markPreviousVersionsObsolete(topicKey, doc.getId());
// 标记当前文档为有效
updateMetadata(doc.getId(), "is_current", "true");
log.info("Document {} is now effective for topic: {}", doc.getId(), topicKey);
}
}
// 查找今天失效的文档
List<Document> expiredToday = findDocumentsExpiringOn(today);
for (Document doc : expiredToday) {
updateMetadata(doc.getId(), "is_current", "false");
log.info("Document {} has expired", doc.getId());
}
}
}一个容易忽略的细节
除了文档版本管理,还有个细节经常被忽略:检索到的文档要在上下文里标注时间。
即使Prompt里说"基于以下当前有效文档回答",如果文档里本身包含"根据2021年规定..."这样的表述,LLM很容易被文档内的历史叙述带偏。
解决方法是:在每个文档片段前加注生效时间,并在系统提示中明确说明——当文档内部引用的历史规定与文档本身的生效时间矛盾时,以文档的生效时间为准。
这个细节在实际产品里能解决相当一部分时序混乱问题。
