元数据的过滤场景
前面讲了怎么给文本块"上户口"(元数据设计),这篇来聊聊检索的时候怎么用这些户口信息——也就是元数据过滤。
向量检索的核心能力是语义匹配——找到意思最接近的文档。但有些场景下,语义相似不等于答案正确。
举个实际遇到的例子:一个智能客服系统,知识库里存了三个版本的产品手册(V1.0、V2.0、V3.0)。用户问"怎么连接蓝牙设备",三个版本的手册里都有蓝牙连接的说明,语义上几乎一模一样。但V1.0是"设置→蓝牙→搜索设备",V2.0改成了"下拉通知栏→长按蓝牙图标",V3.0又变成了"设置→连接→蓝牙"。
向量检索返回了什么?三个版本的内容混在一起,因为它们的语义相似度几乎相同。大模型拿到这三段内容,可能会把不同版本的步骤混着说,给出一个根本不存在的操作流程。
问题出在哪?向量检索只看语义,不看版本号。
这就是元数据过滤要解决的问题:在语义匹配的同时,用结构化条件把不相关的文档排除掉。
三个典型的过滤场景
场景一:版本/分类精确过滤
这是最常用的场景。当知识库中存在同一主题的多个版本或多个分类时,必须用元数据过滤来限定范围。
回到刚才的例子,如果用户明确说了"V3.0的蓝牙怎么连",我们就应该只在V3.0的文档中检索:
SearchRequest request = SearchRequest.builder()
.query("怎么连接蓝牙设备")
.topK(5)
.similarityThreshold(0.5)
.filterExpression("version == '3.0'")
.build();
List<Document> results = vectorStore.similaritySearch(request);
类似的场景还有很多:
- 多语言文档:
filterExpression("language == 'zh'") - 多产品线:
filterExpression("product == 'ProMax'") - 时间范围:
filterExpression("createTime >= '2026-01-01'")
场景二:提供引用来源
在RAG的回答中附上引用来源,能大幅提升用户信任度。元数据就是引用信息的载体。
List<Document> docs = vectorStore.similaritySearch(request);
for (Document doc : docs) {
String fileName = (String) doc.getMetadata().get("fileName");
Integer page = (Integer) doc.getMetadata().get("pageNumber");
String version = (String) doc.getMetadata().get("version");
log.info("引用来源: {} 第{}页 (版本{})", fileName, page, version);
}
// 在Prompt中加入引用要求
String systemPrompt = """
根据参考资料回答问题。回答末尾请标注引用来源,格式:
[来源:文件名, 页码]
""";
用户看到的回答效果:
连接蓝牙设备的步骤:打开设置 → 点击"连接" → 选择"蓝牙" → 打开蓝牙开关 → 等待搜索到目标设备后点击配对。
[来源:产品手册V3.0.pdf, 第23页]
场景三:访问权限控制
企业级RAG系统中,不同用户能看到的文档范围不同。财务部的薪资文档不能被其他部门检索到,机密级文档不能被普通员工看到。
@GetMapping("/chat")
public String chat(@RequestParam String question,
@RequestParam String userId) {
UserInfo user = userService.getUser(userId);
String department = user.getDepartment();
String accessLevel = user.getAccessLevel();
// 构建权限过滤表达式
String filter = String.format(
"department == '%s' && accessLevel <= '%s'",
department, accessLevel);
SearchRequest request = SearchRequest.builder()
.query(question)
.topK(5)
.similarityThreshold(0.5)
.filterExpression(filter)
.build();
List<Document> docs = vectorStore.similaritySearch(request);
// ... 后续RAG流程
}
权限过滤必须在检索层做,不能依赖大模型"不要回答敏感内容"这种Prompt约束。Prompt约束是可以被绕过的(Prompt注入),但元数据过滤是在数据库层面执行的,用户根本看不到被过滤掉的文档。
Spring AI中的filterExpression语法
Spring AI通过filterExpression参数支持在向量检索时做元数据过滤,语法和SQL的WHERE子句类似。
语法速查
// 等值匹配
"version == '3.0'"
// 不等于
"category != 'deprecated'"
// 大于/小于(适合日期、数字)
"created_at >= '2026-01-01'"
// 逻辑组合
"version == '3.0' && category == 'product-manual'"
"department == '研发部' || department == '产品部'"
// IN 操作
"version in ['2.0', '3.0']"
入库时写入元数据
过滤的前提是入库时就把元数据写好了。回顾一下入库流程:
@Service
public class DocumentIngestionService {
private final VectorStore vectorStore;
public void ingest(String filePath) {
// 1. 读取和切分文档
Resource resource = new FileSystemResource(filePath);
TikaDocumentReader reader = new TikaDocumentReader(resource);
List<Document> rawDocs = reader.get();
TokenTextSplitter splitter = new TokenTextSplitter();
List<Document> chunks = splitter.apply(rawDocs);
// 2. 给每个chunk附加元数据
String fileName = Path.of(filePath).getFileName().toString();
String version = extractVersion(fileName);
for (Document chunk : chunks) {
chunk.getMetadata().put("fileName", fileName);
chunk.getMetadata().put("version", version);
chunk.getMetadata().put("ingestTime", LocalDateTime.now().toString());
chunk.getMetadata().put("category", "product-manual");
}
// 3. 存入向量库
vectorStore.add(chunks);
log.info("导入完成: {} 个文档块, 文件={}", chunks.size(), fileName);
}
private String extractVersion(String fileName) {
var matcher = Pattern.compile("[Vv](\\d+\\.\\d+)").matcher(fileName);
return matcher.find() ? matcher.group(1) : "unknown";
}
}
配合QuestionAnswerAdvisor使用
如果你用的是Spring AI的QuestionAnswerAdvisor,可以通过参数传入过滤表达式:
@GetMapping("/chat")
public Flux<String> chat(@RequestParam String question,
@RequestParam(required = false) String version) {
String filter = version != null
? "version == '" + version + "'"
: null;
var builder = chatClient.prompt().user(question);
if (filter != null) {
builder.advisors(spec -> spec.param(
QuestionAnswerAdvisor.FILTER_EXPRESSION, filter));
}
return builder.stream().content();
}
过滤条件从哪来
上面的代码都假设过滤条件是现成的(用户传了version参数)。但实际场景中,用户不会说"请在V3.0文档中搜索",他们只会说"我用的是最新版,蓝牙怎么连"。
怎么从用户的自然语言中提取出过滤条件?两种方案:
方案一:从用户画像获取(推荐)
如果系统知道用户购买的是哪个版本的产品,直接从用户画像中获取,不需要从问题中提取。
String version = userProfileService.getProductVersion(userId);
String filter = "version == '" + version + "'";
方案二:让大模型从问题中提取
private static final String EXTRACT_PROMPT = """
从用户的问题中提取以下信息(如果有的话):
- version:产品版本号
- category:文档类别(操作指南/故障排查/技术规格)
用户问题:{question}
输出JSON格式,没有的字段不要输出:
""";
方案一更靠谱,方案二依赖大模型的提取准确率,可能出错。实际项目中优先用方案一,方案二作为补充。
元数据过滤解决的是"语义相似但条件不符"的问题。三个核心场景:版本/分类精确过滤(最常用)、引用来源追溯(提升信任度)、访问权限控制(安全底线)。Spring AI通过filterExpression参数支持元数据过滤,语法类似SQL WHERE子句。过滤条件的来源优先从用户画像获取,其次用大模型从问题中提取。元数据的设计和字段规划可以参考前面的《别让文本块变成信息孤岛》那篇。