LlamaIndex 系列【23】检索增强策略:元数据过滤(Metadata Filtering)
2026/9/11 18:08:05 网站建设 项目流程

文章目录

  • 1. 前言
  • 2. 核心概念
    • 2.1 元数据
    • 2.2 元数据过滤
    • 2.3 元数据过滤 vs 相似度排序
  • 3. 案例演示
    • 3.1 加载文档
    • 3.2 默认元数据
    • 3.3 添加元数据
    • 3.4 元数据过滤器
    • 3.5 混合检索 + 元数据过滤
    • 3.6 执行检索 & 结果分析
    • 3.7 完整代码

1. 前言

在之前我们学习了核心检索技术:

  • Keyword Search(关键词检索 /BM25):根据关键词出现频次、精确匹配度打分排序。
  • Semantic Search(语义检索 / 稠密向量检索):根据文本语义含义打分排序,匹配方式更加灵活,不依赖字面相同。
  • Hybrid Search(混合检索):组合上面多种检索方式,把多路召回的文档合并、重新排序,输出一份统一的结果列表。

接下来我们继续学习元数据过滤Metadata Filtering)。

2. 核心概念

2.1 元数据

元数据是挂在文档或节点上的键值对,描述它是什么、来自哪、属于谁等。常见的有标题、作者、创建日期、访问权限等。

在LlamaIndex 系列【9】RAG 核心对象:Document(文档)中已经详细介绍过文档元数据。

2.2 元数据过滤

元数据过滤Metadata Filtering)是在检索时,先用节点上附加的结构化属性做硬性筛选,只保留符合条件的那部分节点,然后再在剩下的节点里做向量/BM25打分排序。

工作流程:

元数据过滤本身不执行检索打分;它是基于用户属性(而非提问文本内容),对文档做范围裁剪、过滤筛选,是RAG检索体系里非常常用的前置过滤手段,

最常见是权限和范围控制,防止LLM看到不该看的内容:

  • 租户隔离:只检索tenant_id == "A 公司"的文档
  • 时间范围:只检索publish_date >= 2024-01-01
  • 文档类型:只检索doc_type == "合同"
  • 语言、版本、是否已审核等

2.3 元数据过滤 vs 相似度排序

这是两种完全不同的机制,别混淆:

  • 相似度打分BM25/ 向量给每个节点算一个分,按分数软排序,谁相关谁靠前,是排名
  • 元数据过滤:用一个布尔条件直接决定要不要这个节点,是筛除,和相似度无关。

典型流程是:先过滤(缩小候选集)→ 再打分(在候选集里排序)。

比如只检索category == 部署的节点,那售后政策价格这些节点即使文本再相似也直接被排除。

3. 案例演示

目标:在混合检索(BM25稀疏 + 向量稠密)基础上叠加元数据过滤,先按结构化条件硬性筛除不符合的节点,再在剩余节点里按相似度排序,实现「先过滤、后排序」的召回控制。

在权限隔离、时间范围、文档类型过滤等场景这是标准做法。

3.1 加载文档

这里的Document是文档的原始载体,还没切块,后面会进一步切成检索单元Node

fromllama_index.coreimportSimpleDirectoryReader documents=SimpleDirectoryReader("./data").load_data()print(f"读取文档数:{len(documents)}")

3.2 默认元数据

SimpleDirectoryReader读文件时会自动写入一批默认元数据,无需手动指定,可直接用来做来源、时间、类型过滤。

default_meta=documents[0].metadatafork,vindefault_meta.items():print(f"{k}={v}")

实际输出:

file_path = D:\...\data\公司介绍.md # 文件绝对路径 file_name = 公司介绍.md # 文件名 file_type = text/markdown # 文件类型 file_size = 757 # 文件大小(字节) creation_date = 2026-08-28 # 创建日期 last_modified_date = 2026-08-28 # 最后修改日期

注意:这些默认字段只有文件层面的信息,不含业务语义(比如这份文档属于什么类别、来自哪个系统、优先级多少)。所以下一步要手动补充。

3.3 添加元数据

业务上通常需要category(类别)、source(来源)、priority(优先级)等自定义字段。

关键做法是在分块之前把元数据写到Document.metadata,这样SentenceSplitter切出来的每个Node会自动继承这些元数据,无需逐个节点设置。

fromllama_index.core.node_parserimportSentenceSplitter category_map={"公司介绍.md":"公司","售后政策与常见问题.md":"售后","星云知识库产品说明.md":"产品",}priority_map={"公司":1,"售后":2,"产品":3}fordocindocuments:file_name=doc.metadata.get("file_name","")doc.metadata["category"]=category_map.get(file_name,"其他")doc.metadata["source"]="官网"doc.metadata["priority"]=priority_map.get(doc.metadata["category"],0)nodes=SentenceSplitter(chunk_size=512).get_nodes_from_documents(documents)

分块后每个节点的metadata都带上了category / source / priority,这是过滤的基础。

3.4 元数据过滤器

过滤条件用MetadataFilters表达,它由「一组MetadataFilter+ 一个FilterCondition」组成:

  • MetadataFilter(key, value, operator):对某个元数据字段做比较,默认==
  • FilterCondition.AND / OR / NOT:多条条件之间的逻辑关系;
  • operator支持EQ(==)NE(!=)GTGTELTLTEINNINCONTAINSTEXT_MATCHIS_EMPTY等。
fromllama_index.core.vector_stores.typesimport(MetadataFilters,MetadataFilter,FilterCondition,FilterOperator,)# 单条件:category == "产品"filter_product=MetadataFilters(filters=[MetadataFilter(key="category",value="产品")],condition=FilterCondition.AND,)# 数值比较:priority >= 2filter_high_priority=MetadataFilters(filters=[MetadataFilter(key="priority",value=2,operator=FilterOperator.GTE)],condition=FilterCondition.AND,)# 列表:category IN {"售后", "产品"}filter_in_categories=MetadataFilters(filters=[MetadataFilter(key="category",value=["售后","产品"],operator=FilterOperator.IN),],condition=FilterCondition.AND,)

3.5 混合检索 + 元数据过滤

BM25和向量两路检索器都通过各自的filters参数接收过滤器,先各自过滤、再交给QueryFusionRetriever做结果融合。

向量那路用as_retriever(filters=...)BM25那路用from_defaults(filters=...)

defbuild_fusion_retriever(filters=None):bm25=ChineseBM25Retriever.from_defaults(nodes=nodes,similarity_top_k=3,filters=filters)vector=vector_index.as_retriever(similarity_top_k=3,filters=filters)returnQueryFusionRetriever(retrievers=[vector,bm25],llm=llm,num_queries=1,# 不做 LLM 查询扩展,仅结果融合mode="reciprocal_rerank",# RRF 融合similarity_top_k=3,use_async=False,)fusion_product=build_fusion_retriever(filters=filter_product)

实现细节(重要):新版BM25filters只把不匹配节点的分数置0(通过corpus_weight_mask),但仍会返回这些节点,和向量那路的"真剔除"不一致。因此要在BM25检索器的_retrieve里补一句「剔除0分结果」,否则融合结果尾部会残留被过滤的节点。

def_retrieve(self,query_bundle):query_bundle.query_str=zh_seg(query_bundle.query_str)results=super()._retrieve(query_bundle)ifself.corpus_weight_maskisnotNone:# 有过滤器时,剔除被压成 0 分的节点results=[rforrinresultsifr.scoreandr.score>0]returnresults

3.6 执行检索 & 结果分析

说明:用同一个查询词对比「不过滤」和「各种过滤」的召回差异,验证过滤是否生效。

query="星云"defshow(title,retriever):print(f"---{title}---")fori,iteminenumerate(retriever.retrieve(query),1):cat=item.node.metadata.get("category")print(f" [{i}] 融合分={item.score:.4f}category={cat}")show("不过滤",fusion_no_filter)show("过滤 category=='产品'",fusion_product)show("过滤 priority>=2",fusion_high_priority)show("过滤 category IN ['售后','产品']",fusion_in_categories)

结果:

不过滤 → 产品 / 公司 / 售后(3 个) 过滤 category=='产品' → 产品(1 个) 过滤 priority>=2 → 产品 / 售后(2 个) 过滤 category IN ['售后','产品'] → 产品 / 售后(2 个)

结果分析

  1. 查询词「星云」在三份文档正文里都出现,所以不过滤时三份都会命中;加category=='产品'后,其余两份被硬性剔除,只剩1条——过滤生效。
  2. priority>=2时「售后」仍被召回,但融合分更低。原因是「售后」这份文档语义上和「星云」相关(同属星云科技),但正文里未必包含「星云」这个关键词——于是向量那路能命中它,而BM25关键词那路命中不了。这正体现了混合检索"关键词 + 语义互补"的价值。
  3. 融合分是RRF分数:对每个节点,在各路检索器的排名rank上累加1/(rank + 60),所以同时被两路命中的节点分数更高、排序更靠前。

3.7 完整代码

""" 3. 案例演示:元数据过滤 在混合检索(BM25 稀疏 + 向量稠密)的基础上叠加「元数据过滤」: 先按结构化条件(category / source / priority 等)硬性筛除不符合条件的节点, 再在剩余节点里做相似度打分排序,实现"先过滤、后排序"的召回控制。 流程: 3.1 准备数据 —— 读取本地文档并分块 3.2 默认元数据 —— 观察 SimpleDirectoryReader 自动写入的元数据 3.3 添加元数据 —— 给文档/节点补充业务元数据 3.4 元数据过滤器 —— 构造 MetadataFilters 3.5 混合检索 + 元数据过滤 —— BM25/向量两路都带上 filters 后融合 3.6 执行检索 & 结果分析 —— 对比"不过滤"与"过滤后"的召回差异 """importcopyimportosimportjiebafromdotenvimportload_dotenvfromllama_index.coreimportSettings,SimpleDirectoryReader,VectorStoreIndexfromllama_index.core.node_parserimportSentenceSplitterfromllama_index.core.retrieversimportQueryFusionRetrieverfromllama_index.core.vector_stores.typesimport(FilterCondition,FilterOperator,MetadataFilter,MetadataFilters,)fromllama_index.core.vector_stores.utilsimportnode_to_metadata_dictfromllama_index.embeddings.openai_likeimportOpenAILikeEmbeddingfromllama_index.llms.openai_likeimportOpenAILikefromllama_index.retrievers.bm25importBM25Retriever# ==================== 通用配置 ====================load_dotenv()api_key=os.environ["DASHSCOPE_API_KEY"]API_BASE="https://ws-jfb8j8mx0n7e2k6a.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"embed_model=OpenAILikeEmbedding(model_name="text-embedding-v3",api_key=api_key,api_base=API_BASE,)llm=OpenAILike(model="qwen3.8-max",api_key=api_key,api_base=API_BASE,is_chat_model=True,)Settings.llm=llm# ==================== 中文 BM25 检索器 ====================defzh_seg(text:str)->str:return" ".join(jieba.cut(text))classChineseBM25Retriever(BM25Retriever):"""支持中文分词的 BM25 检索器,同时支持 metadata filters。"""@classmethoddeffrom_defaults(cls,nodes,similarity_top_k=2,**kwargs):original_nodes=nodes segmented_nodes=[copy.deepcopy(n)forninnodes]forninsegmented_nodes:n.set_content(zh_seg(n.get_content()))retriever=super().from_defaults(nodes=segmented_nodes,similarity_top_k=similarity_top_k,token_pattern=r"(?u)\b\w+\b",skip_stemming=True,**kwargs,)retriever.corpus=[node_to_metadata_dict(n)|{"node_id":n.node_id}forninoriginal_nodes]returnretrieverdef_retrieve(self,query_bundle):query_bundle.query_str=zh_seg(query_bundle.query_str)results=super()._retrieve(query_bundle)# BM25 的 filters 只把不匹配节点的分数置 0(weight_mask),但仍会返回这些节点;# 这里剔除 0 分结果,使元数据过滤成为"硬性筛除"而非"压分"。ifself.corpus_weight_maskisnotNone:results=[rforrinresultsifr.scoreandr.score>0]returnresults# ==================== 3.1 准备数据 ====================documents=SimpleDirectoryReader("./data").load_data()print(f"3.1 读取文档数:{len(documents)}")fordocindocuments:print(f" -{doc.metadata.get('file_name')}长度={len(doc.text)}字")# ==================== 3.2 默认元数据 ====================print("\n3.2 SimpleDirectoryReader 自动写入的默认元数据(以第一个文档为例):")default_meta=documents[0].metadatafork,vindefault_meta.items():print(f"{k}={v}")# ==================== 3.3 添加元数据 ====================# 按文件名给每个文档补充业务元数据:类别 / 来源 / 优先级category_map={"公司介绍.md":"公司","售后政策与常见问题.md":"售后","星云知识库产品说明.md":"产品",}priority_map={"公司":1,"售后":2,"产品":3}fordocindocuments:file_name=doc.metadata.get("file_name","")doc.metadata["category"]=category_map.get(file_name,"其他")doc.metadata["source"]="官网"doc.metadata["priority"]=priority_map.get(doc.metadata["category"],0)# 分块:节点会继承所属文档的 metadatasplitter=SentenceSplitter(chunk_size=512)nodes=splitter.get_nodes_from_documents(documents)print("\n3.3 补充元数据并分块后,各节点的 category / priority:")fornodeinnodes:print(f" category={node.metadata.get('category'):<4}"f"priority={node.metadata.get('priority')}"f"{node.text[:20]!r}")# ==================== 3.4 元数据过滤器 ====================# 例 1:单条件 —— 只要 category == "产品"filter_product=MetadataFilters(filters=[MetadataFilter(key="category",value="产品")],condition=FilterCondition.AND,)# 例 2:数值比较 —— priority >= 2filter_high_priority=MetadataFilters(filters=[MetadataFilter(key="priority",value=2,operator=FilterOperator.GTE),],condition=FilterCondition.AND,)# 例 3:IN 列表 —— category 属于 {"售后", "产品"}filter_in_categories=MetadataFilters(filters=[MetadataFilter(key="category",value=["售后","产品"],operator=FilterOperator.IN),],condition=FilterCondition.AND,)print("\n3.4 已构造 3 组过滤器:")print(" filter_product : category == '产品'")print(" filter_high_priority : priority >= 2")print(" filter_in_categories : category IN ['售后', '产品']")# ==================== 3.5 混合检索 + 元数据过滤 ====================# 向量索引只建一次,两路检索器各自携带 filters 后交给 QueryFusionRetriever 融合vector_index=VectorStoreIndex(nodes=nodes,embed_model=embed_model)defbuild_fusion_retriever(filters=None):"""构造"BM25 + 向量"的融合检索器,可选传入 metadata filters。"""bm25=ChineseBM25Retriever.from_defaults(nodes=nodes,similarity_top_k=3,filters=filters)vector=vector_index.as_retriever(similarity_top_k=3,filters=filters)returnQueryFusionRetriever(retrievers=[vector,bm25],llm=llm,num_queries=1,# 不做 LLM 查询扩展mode="reciprocal_rerank",# RRF 融合similarity_top_k=3,use_async=False,)fusion_no_filter=build_fusion_retriever(filters=None)fusion_product=build_fusion_retriever(filters=filter_product)fusion_high_priority=build_fusion_retriever(filters=filter_high_priority)fusion_in_categories=build_fusion_retriever(filters=filter_in_categories)# ==================== 3.6 执行检索 & 结果分析 ====================query="星云"defshow(title,retriever):print(f"\n---{title}---")results=retriever.retrieve(query)ifnotresults:print(" (无结果)")returnfori,iteminenumerate(results,1):cat=item.node.metadata.get("category")pri=item.node.metadata.get("priority")print(f" [{i}] 融合分={item.score:.4f}category={cat}"f"priority={pri}{item.node.text[:24]!r}")print(f"\n3.6 查询词:{query!r}(三份文档正文都含「星云」)")show("不过滤(应返回全部 3 个节点)",fusion_no_filter)show("过滤 category=='产品'(应只剩 1 个节点)",fusion_product)show("过滤 priority>=2(应剩 售后+产品 2 个节点)",fusion_high_priority)show("过滤 category IN ['售后','产品']",fusion_in_categories)

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询