--- title: "16-元数据的过滤场景" created: 2026-05-15 aliases: - 元数据的过滤场景 tags: - 项目 --- # 元数据的过滤场景 前面讲了怎么给文本块"上户口"(元数据设计),这篇来聊聊检索的时候怎么用这些户口信息——也就是元数据过滤。 向量检索的核心能力是语义匹配——找到意思最接近的文档。但有些场景下,语义相似不等于答案正确。 举个实际遇到的例子:一个智能客服系统,知识库里存了三个版本的产品手册(V1.0、V2.0、V3.0)。用户问"怎么连接蓝牙设备",三个版本的手册里都有蓝牙连接的说明,语义上几乎一模一样。但V1.0是"设置→蓝牙→搜索设备",V2.0改成了"下拉通知栏→长按蓝牙图标",V3.0又变成了"设置→连接→蓝牙"。 向量检索返回了什么?三个版本的内容混在一起,因为它们的语义相似度几乎相同。大模型拿到这三段内容,可能会把不同版本的步骤混着说,给出一个根本不存在的操作流程。 问题出在哪?**向量检索只看语义,不看版本号。** 这就是元数据过滤要解决的问题:在语义匹配的同时,用结构化条件把不相关的文档排除掉。 ## 三个典型的过滤场景 ### 场景一:版本/分类精确过滤 这是最常用的场景。当知识库中存在同一主题的多个版本或多个分类时,必须用元数据过滤来限定范围。 回到刚才的例子,如果用户明确说了"V3.0的蓝牙怎么连",我们就应该只在V3.0的文档中检索: ```java SearchRequest request = SearchRequest.builder() .query("怎么连接蓝牙设备") .topK(5) .similarityThreshold(0.5) .filterExpression("version == '3.0'") .build(); List results = vectorStore.similaritySearch(request); ``` 类似的场景还有很多: - 多语言文档:`filterExpression("language == 'zh'")` - 多产品线:`filterExpression("product == 'ProMax'")` - 时间范围:`filterExpression("createTime >= '2026-01-01'")` ### 场景二:提供引用来源 在RAG的回答中附上引用来源,能大幅提升用户信任度。元数据就是引用信息的载体。 ```python List 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系统中,不同用户能看到的文档范围不同。财务部的薪资文档不能被其他部门检索到,机密级文档不能被普通员工看到。 ```java @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 docs = vectorStore.similaritySearch(request); // ... 后续RAG流程 } ``` > 权限过滤是安全底线 > > 权限过滤必须在检索层做,不能依赖大模型"不要回答敏感内容"这种Prompt约束。Prompt约束是可以被绕过的(Prompt注入),但元数据过滤是在数据库层面执行的,用户根本看不到被过滤掉的文档。 ## Spring AI中的filterExpression语法 Spring AI通过`filterExpression`参数支持在向量检索时做元数据过滤,语法和SQL的WHERE子句类似。 ### 语法速查 ```text // 等值匹配 "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']" ``` ### 入库时写入元数据 过滤的前提是入库时就把元数据写好了。回顾一下入库流程: ```java @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 rawDocs = reader.get(); TokenTextSplitter splitter = new TokenTextSplitter(); List 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`,可以通过参数传入过滤表达式: ```java @GetMapping("/chat") public Flux 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文档中搜索",他们只会说"我用的是最新版,蓝牙怎么连"。 怎么从用户的自然语言中提取出过滤条件?两种方案: **方案一:从用户画像获取(推荐)** 如果系统知道用户购买的是哪个版本的产品,直接从用户画像中获取,不需要从问题中提取。 ```java String version = userProfileService.getProductVersion(userId); String filter = "version == '" + version + "'"; ``` **方案二:让大模型从问题中提取** ```python private static final String EXTRACT_PROMPT = """ 从用户的问题中提取以下信息(如果有的话): - version:产品版本号 - category:文档类别(操作指南/故障排查/技术规格) 用户问题:{question} 输出JSON格式,没有的字段不要输出: """; ``` 方案一更靠谱,方案二依赖大模型的提取准确率,可能出错。实际项目中优先用方案一,方案二作为补充。 > 小结 > > 元数据过滤解决的是"语义相似但条件不符"的问题。三个核心场景:版本/分类精确过滤(最常用)、引用来源追溯(提升信任度)、访问权限控制(安全底线)。Spring AI通过`filterExpression`参数支持元数据过滤,语法类似SQL WHERE子句。过滤条件的来源优先从用户画像获取,其次用大模型从问题中提取。元数据的设计和字段规划可以参考前面的《别让文本块变成信息孤岛》那篇。 --- **企业级项目导航**:⬅️ [[15-元数据的详细解析|15-元数据的详细解析]] | 16-元数据的过滤场景 | ➡️ [[17-图结构:Graph RAG|17-图结构:Graph RAG]]