企业知识库检索的坏例里,有一类反复出现:语义对,条件错。用户问「报销流程」,检索命中了一份 2021 年的旧制度——语义上毫无问题(它讲的就是报销流程),但公司去年换了新制度,旧文档根本不该出现。反过来也有:用户问「2024 年的报销流程和现在有什么区别」,检索只按语义命中,旧文档新文档混作一团。这类问题的根源在于:嵌入向量只编码了「关于什么」,没有编码「什么时候的、谁家的、哪个版本的」——这些判别性信息住在元数据里,需要过滤机制把它们接进来。这篇讲元数据过滤的正确接法,以及那个著名的翻车点:过滤让召回率骤降。
过滤与语义:正交的两轴
先建立正确的模型:语义相似度与元数据条件是正交的两个维度。语义回答「内容上像不像」,过滤回答「资格上够不够」。正确的关系是先圈地、再排序——用元数据把候选集圈到「资格合格」的范围内,语义排序只在圈内进行。省略过滤步骤的后果不只是排序瑕疵:旧制度文档和新制度文档在「报销流程」这个语义上几乎一样近,语义层根本没有能力替用户做「只要新的」这个决定——资格判定是语义的天生盲区。
过滤器该存什么?工程上最有价值的四类字段:时间(文档生效日期/版本号,几乎每个知识库都需要)、组织范围(部门、地域、权限组——权限是特殊而致命的一类,单独立一篇《权限感知检索》讲)、类型(政策/FAQ/操作手册/表格,不同类型的答案风格与可信度不同)、状态(草稿/生效/废止)。入库时就要把这些字段结构化地挂在 chunk 上——事后从正文里猜元数据,成本和质量都远不如入库时一次做对。
经典翻车点:过滤为什么会让召回骤降
在 HNSW 这类图索引上,「先过滤再检索」不是免费的。《HNSW 调参实战》提过一笔,这里展开:图索引的查询是沿着邻居链路贪心游走的,带过滤条件后,链路上的节点可能大批不合格,游走到一半发现「此路不通」,视野内的合格节点还没找够,召回率就塌了。这是所有图索引 + 过滤组合的共性问题,各家引擎的应对不同(有的支持过滤感知的图遍历,有的退化为「预过滤扫描」),但共同点是:带过滤的查询需要比纯语义查询更大的搜索预算(放大 efSearch 或等价参数),否则过滤越严、召回掉得越狠。
另一个反直觉的坑是过滤过严导致空结果。用户查询里抽出的条件组合(「2024 年 + 华东区域 + 经销商渠道」)可能在库里只有零星几份文档,甚至为空。这时系统不能冷冰冰地返回「没有结果」,而要有逐级放宽的兜底:先去掉最可疑的条件重试(通常是最细粒度的那个),再放宽时间范围,最终至少给出「语义最相关 + 部分条件满足」的结果,并明示用户哪些条件被放宽了——让用户知道结果可能不精确,比悄悄给一个错结果诚实得多。
条件从哪来:让查询理解层抽取
过滤条件的取值有两个来源,可靠性天差地别。查询侧:用户的自然语言里带着条件(「最新的」「华东区的」「2023 年之前的规定」),需要抽取成结构化过滤表达式——这正是《查询路由》和《查询改写》里查询理解层的活:让模型在改写查询的同时输出结构化的 filter(如 {"version": "latest", "region": "华东"})。抽取质量靠 schema 设计保证:字段的取值必须是受控枚举(地区用标准区划表,部门用组织架构表,类型用固定分类),自由文本进过滤器是灾难之源。
语料侧:入库时为文档打标。时间与版本通常能从文档本身抽取(发布日期、文件名、文末落款);部门归属可能需要目录结构或人工维护;类型可以规则分类加人工复核。字段命名与取值规范要在第一天就定好,后期改字段名等于全库重刷。
# 查询理解层输出的结构化过滤示例(伪代码)
parsed = llm.json(
"从问题中抽取检索条件,取值必须来自给定枚举,抽不出就置 null:\n"
f'问题:{query}\n'
'可选字段:{"topic": str, "year": int|null, "region": "华东|华南|华北|全国", '
'"type": "policy|faq|manual", "status": "active|deprecated"}')
filters = {k: v for k, v in parsed.items() if v is not None}
results = index.search(embed(query), top_k=10, filter=filters, ef=256) # 带过滤放大 ef
if len(results) < 3 and "region" in filters: # 逐级放宽兜底
del filters["region"]
results = index.search(embed(query), top_k=10, filter=filters, ef=256)
时间语义:过滤条件里最难啃的一块
四类过滤字段里,时间的抽取与落地最容易翻车,因为自然语言的时间表达太灵活。「最新的报销制度」里「最新」是相对语义(生效时间最晚的那份),「上季度的数据」是计算语义(要拿到当前日期才能算出区间),「2024 年之前的规定」是边界语义(开区间还是闭区间?)。工程上分三层处理:入库层把时间规范化——每个文档带 effective_date(生效日)与 deprecated_date(废止日,可空),「最新版」的判定转化为「deprecated_date 为空且 effective_date 最大」,让语义留给查询层,库里只存硬事实;查询层解析相对时间——把「上季度」「近三个月」「最新」翻译成绝对区间(依赖当前日期的函数,别让模型裸猜日期,给它注入今天日期再让它算);兜底层处理歧义——解析不出明确区间时退化为「不过滤时间 + 答案里注明时间属性」(每个片段带日期标注,让用户自己判断新旧)。时间过滤做扎实,「新旧制度打架」这一企业知识库最高频的坏例就少了一大半。
布尔组合与受控枚举的纪律
真实查询的过滤条件常常是组合式的(「华东区 且 2024 年 或 全国试点的」),过滤器接口要原生支持嵌套布尔结构(主流向量库的 filter DSL 都支持 must/should 嵌套),不要把组合逻辑塞进字符串匹配里猜。另一条纪律重申并细化:枚举值的受控性是过滤系统的生命线——「华东」「华东区」「华东大区」三个写法在过滤器眼里是三个不同的值,漏一个就查不全。解法是把枚举收敛到一张受控词表(区域表、部门表、文档类型表),入库打标只能从词表取值,查询侧抽取时让模型输出「最接近的词表项」(few-shot 给足词表样例),两端的枚举一旦对齐,过滤器才有「确定性工具」应有的可靠性。词表本身要版本化(《增量更新》的纪律),组织架构调整时批量回填历史数据。
过滤层的观测指标
过滤层也值得单独观测,三个指标:过滤命中率(带过滤的查询占总量比例——持续走高说明用户的时间/范围诉求在增长,可能是产品引导在起作用);过滤后空结果率(过滤导致零结果的比例——高企说明枚举对不上或过滤过严,兜底放宽逻辑要检查);放宽触发率(逐级放宽兜底被触发的比例——异常升高常意味着查询侧抽取质量劣化或语料侧打标缺漏)。三个指标和《评测集构建》的分层报告对上(「带时间条件的题目」单独一组),过滤层的效果变化就有归因能力了。
pre-filter 还是 post-filter
两种时序各有代价,按引擎能力和过滤严格度选。post-filter(先检索后过滤):先按语义取 top-100,再滤掉不合格的。实现最简单、不伤图索引;但过滤很严时,100 条里可能只剩两三条甚至为零,top-k 配额被无资格文档浪费。它适合过滤选择性好的场景(预计不合格的占比不高)。pre-filter(先过滤后检索):只在合格子集上做语义检索,资格判定严格时结果准确;但如上所述伤图索引的召回,需要引擎支持(部分数据库提供 filtered HNSW 或分区索引)。第三条路:分区(partition):把过滤字段作为分区键(按部门/按时间分 collection),查询路由到对应分区——把「过滤」变成「选址」,彻底绕开图索引过滤难题,代价是分区数膨胀与跨区查询。规则可以概括为:过滤字段基数小且稳定(部门、类型、年份)用分区;基数大或组合灵活用引擎级 pre-filter 并放大搜索预算;万不得已才 post-filter。
同义词与归一化
枚举受控之外还有一个高频细节:用户表述与枚举值的归一。用户说「深圳」而枚举是「华南」,说「公积金」而字段是「五险一金」——查询侧抽取时要做一层同义词映射(维护一张业务同义词表:口语词到枚举值的对照),映射不到的取值宁可置空走兜底放宽,也不要硬塞一个错误的过滤条件——错条件比无条件更危险,它悄悄把正确答案挡在门外。同义词表从查询日志的「过滤后零结果」案例里长出来,那些案例里往往就藏着「用户词不等于枚举词」的证据。
小结
元数据过滤的价值一句话:语义管「像不像」,过滤管「算不算」,资格问题别指望语义层替你回答。工程上三件事决定成败——入库时元数据结构化打标(受控枚举)、查询理解层抽取结构化 filter(抽不出就置空并明示)、按过滤严格度选 pre/post/分区策略并配搜索预算放大与逐级放宽兜底。把时间、版本、组织范围这三类最高频的资格条件接好,企业知识库检索的「答非所问」能砍掉一大半——而且这一刀,几乎不花模型的钱。
参考资料
- Qdrant: Filtering —— 过滤与向量检索结合的引擎侧实现细节
- Milvus: Partition & Partition Key —— 分区方案规避过滤难题的思路
- 本系列前篇:《查询路由》《查询改写》《HNSW 调参实战》《权限感知检索》《评测集构建》
读者留言
COMMENTS 暂无还没有留言,来说第一句?