第4章 元数据与过滤¶
学习目标¶
- 理解文档元信息(payload)在 RAG 中的关键作用
- 掌握 Qdrant payload 过滤语法与常见过滤模式
- 学会多轮查询与查询改写(Query Rewriting)技术
- 理解 HyDE(Hypothetical Document Embeddings)原理与适用场景
- 能够实现带权限控制与意图纠错的检索管道
问题导入¶
某企业知识库上线后频繁出错:普通员工搜"薪酬制度"却检索到了高管专属的薪资调整方案;用户问"去年的销售数据"却返回了今年的内容;用户口语化提问"那个管代码的工具叫啥"完全检索不到"GitLab 代码托管平台"。
这三个问题的根源都不是向量检索本身,而是缺乏元数据治理与查询优化:
- 第一个是权限过滤缺失——检索前没按用户角色过滤
- 第二个是时间过滤缺失——没把"去年"映射成 year=2025 的 payload 条件
- 第三个是查询改写缺失——口语化 query 与文档表述不匹配
本章讲解如何用元数据与查询优化技术解决这些问题。
核心概念¶
1. 文档元信息(Payload)¶
每个分块除向量与文本外,应附带结构化元数据。常见字段:
| 字段 | 示例 | 用途 |
|---|---|---|
doc_id |
"doc-001" | 文档级增删改 |
source |
"confluence" / "gitlab" | 来源过滤 |
category |
"hr" / "tech" / "legal" | 分类过滤 |
tenant_id |
"tenant-A" | 多租户隔离 |
acl_roles |
["hr", "manager"] | 权限控制 |
created_at |
"2025-06-01" | 时间范围过滤 |
version |
"v2.3" | 版本管理 |
page |
12 | 引用定位 |
元数据让检索从"纯语义"升级为"语义 + 业务约束"。
2. Payload 过滤¶
Qdrant 支持在向量检索时附加 Filter,先按 payload 过滤再在子集中做 ANN 检索。这种"先过滤后检索"比"检索后过滤"更准确——避免过滤后剩余结果不足 Top-K。
常见过滤模式:
- 精确匹配:
category == "hr" - 范围过滤:
created_at >= "2025-01-01" - 集合包含:
"manager" in acl_roles - 多条件组合:
must(AND)、should(OR)、must_not(NOT)
3. 多轮查询¶
对话场景中用户提问往往依赖上下文:
用户:Qwen3 是哪家公司发布的?
系统:阿里。
用户:它支持多少种规模? ← "它"指 Qwen3
直接检索"它支持多少种规模"毫无意义。需用 LLM 做多轮查询改写:把"它"消解为"Qwen3",生成独立查询"Qwen3 支持多少种规模"再检索。
4. 查询改写(Query Rewriting)¶
将用户原始查询改写为更适合检索的表述:
- 口语化 → 规范化:"那个管代码的工具" → "代码托管平台 GitLab"
- 缩写展开:"K8s" → "Kubernetes"
- 拆分子查询:"对比 GPT-4 和 Claude" → "GPT-4 的特点" + "Claude 的特点"
- 关键词增强:补充同义词提高召回
可用 LLM(如 GPT-4o-mini)做改写,Prompt 示例:"请将用户问题改写为更适合文档检索的关键词查询,保留原意,输出不超过 20 字。"
5. HyDE(Hypothetical Document Embeddings)¶
HyDE 的核心思想:文档与文档的语义相似度,高于 query 与文档的相似度(因为 query 短、文档长,分布不同)。
流程: 1. 用 LLM 根据用户 query 生成一个"假设答案"(哪怕有幻觉也无所谓) 2. 用这个假设答案的 Embedding 去检索 3. 检索到的真实文档拼回 Prompt 让 LLM 作答
HyDE 对"query 与文档表述差异大"的场景(如口语化提问、跨语言)效果显著。
6. 意图纠错¶
用户输入可能有错别字、术语错误。常见做法:
- 用 LLM 判断 query 是否包含错误并纠正
- 用拼写检查库(如
pyspellchecker)预处理 - 同义词词典扩展("K8s" → "Kubernetes")
原理图¶
flowchart TD
User[用户提问] --> Context{有无对话上下文}
Context -->|有| Rewrite[多轮查询改写<br/>消解代词]
Context -->|无| Skip[直接进入]
Rewrite --> Intent[意图识别/纠错]
Skip --> Intent
Intent --> HyDE{是否用 HyDE}
HyDE -->|是| Gen[LLM 生成假设答案]
HyDE -->|否| Q[原始查询]
Gen --> EmbedQ[Query Embedding]
Q --> EmbedQ
EmbedQ --> Filter[附加 Payload 过滤<br/>权限/时间/分类]
Filter --> Search[向量检索]
Search --> TopK[Top-K 候选]
TopK --> Rerank[可选 Reranker]
Rerank --> Result[带元数据的检索结果]
Result --> Prompt[拼装 Prompt + LLM 生成]
图:带元数据过滤与查询优化的检索流程
示例代码¶
带权限过滤与查询改写的检索实现:
import os
from qdrant_client import QdrantClient
from qdrant_client.models import (
Distance,
VectorParams,
PointStruct,
Filter,
FieldCondition,
MatchValue,
Range,
MatchAny,
)
from openai import OpenAI
os.environ.setdefault("OPENAI_API_KEY", "sk-xxx")
oai = OpenAI()
qdrant = QdrantClient(":memory:")
COLLECTION = "kb_acl"
DIM = 1536
def setup() -> None:
if qdrant.collection_exists(COLLECTION):
qdrant.delete_collection(COLLECTION)
qdrant.create_collection(
collection_name=COLLECTION,
vectors_config=VectorParams(size=DIM, distance=Distance.COSINE),
)
def embed(text: str) -> list[float]:
return oai.embeddings.create(
model="text-embedding-3-small", input=text
).data[0].embedding
def index_with_acl() -> None:
"""写入带权限与时间元数据的分块。"""
docs = [
{"id": 1, "text": "2025 年全员调薪方案。", "roles": ["hr", "manager"], "year": 2025},
{"id": 2, "text": "2024 年销售数据汇总。", "roles": ["employee", "manager"], "year": 2024},
{"id": 3, "text": "GitLab 是代码托管平台。", "roles": ["employee", "manager"], "year": 2025},
]
points = [
PointStruct(
id=d["id"],
vector=embed(d["text"]),
payload={"text": d["text"], "roles": d["roles"], "year": d["year"]},
)
for d in docs
]
qdrant.upsert(collection_name=COLLECTION, points=points)
def rewrite_query(query: str, history: list[dict] | None = None) -> str:
"""用 LLM 改写查询:消解代词 + 口语化转规范。"""
messages = [
{"role": "system", "content": "请将用户问题改写为适合文档检索的独立查询,消解代词,保留原意,不超过 30 字。"},
]
if history:
messages.extend(history)
messages.append({"role": "user", "content": query})
resp = oai.chat.completions.create(model="gpt-4o-mini", messages=messages, temperature=0)
return resp.choices[0].message.content.strip()
def hyde_document(query: str) -> str:
"""HyDE:让 LLM 生成假设答案用于检索。"""
resp = oai.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "请用 1-2 句话给出该问题的假设性答案,用于检索参考。"},
{"role": "user", "content": query},
],
temperature=0.3,
)
return resp.choices[0].message.content.strip()
def search_with_filter(
query: str,
user_roles: list[str],
year_range: tuple[int, int] | None = None,
use_hyde: bool = False,
top_k: int = 3,
) -> list[dict]:
"""带权限与时间过滤的检索。"""
q_text = hyde_document(query) if use_hyde else query
conditions = [
# 权限过滤:用户角色必须命中文档的 roles 列表
FieldCondition(key="roles", match=MatchAny(any=user_roles))
]
if year_range:
conditions.append(
FieldCondition(key="year", range=Range(gte=year_range[0], lte=year_range[1]))
)
results = qdrant.search(
collection_name=COLLECTION,
query_vector=embed(q_text),
query_filter=Filter(must=conditions),
limit=top_k,
with_payload=True,
)
return [{"score": r.score, "text": r.payload["text"], "year": r.payload["year"]} for r in results]
if __name__ == "__main__":
setup()
index_with_acl()
# 普通员工只能看到自己角色范围内的文档
print("员工检索调薪方案:", search_with_filter("调薪方案", user_roles=["employee"]))
print("HR 检索调薪方案:", search_with_filter("调薪方案", user_roles=["hr"]))
# 时间过滤
print("仅看 2024 年:", search_with_filter("销售数据", user_roles=["manager"], year_range=(2024, 2024)))
# HyDE 检索口语化提问
print("HyDE 检索:", search_with_filter("那个管代码的工具", user_roles=["employee"], use_hyde=True))
运行结果说明:员工检索"调薪方案"因 roles 不含 employee 的调薪文档而返回空或仅返回公开文档;HR 则能命中。时间过滤把结果限定在 2024 年。HyDE 把"管代码的工具"改写为"代码托管平台 GitLab"后能正确命中。
企业案例¶
案例 1:权限控制检索¶
某金融机构知识库按"部门 + 密级"做 payload 双重过滤。投行部员工只能检索 department=ibd 且 clearance<=3 的文档,合规部可检索 clearance<=5。实现了"千人千面"的检索结果,且过滤发生在向量检索前,保证 Top-K 不被过滤稀释。
案例 2:多轮对话 RAG¶
某客服机器人接入对话历史,每轮用 LLM 改写 query 消解代词。"它支持多少钱"在上下文"你们有什么理财产品"中被改写为"理财产品支持多少起投金额",召回率从 45% 提升到 88%。
案例 3:HyDE 提升跨语言检索¶
某跨境电商用户用中文搜"便宜又好看的鞋子",HyDE 生成英文假设答案"affordable stylish shoes",用其 Embedding 检索英文商品库,召回率比直接用中文 query 检索英文库提升 30%。
常见问题¶
Q1:先过滤后检索 vs 先检索后过滤,哪个好? A:先过滤后检索(pre-filter)更准,因为检索空间已缩小到合规子集,Top-K 全是有效结果。先检索后过滤(post-filter)可能因过滤后剩余不足 K 个而需补检索。Qdrant/Milvus 都支持高效的 pre-filter。
Q2:HyDE 一定有效吗? A:不一定。当 query 与文档表述风格差异大(口语 vs 书面、跨语言)时效果好;当 query 已经很规范时收益有限,反而增加一次 LLM 调用延迟。建议 A/B 测试后决定。
Q3:查询改写用什么模型? A:用快且便宜的模型(GPT-4o-mini、Qwen-Turbo)即可,改写任务不需要强推理。temperature 设 0-0.3 保证稳定。
Q4:多轮查询怎么避免上下文丢失? A:每次检索前把最近 3-5 轮对话作为 history 传给改写 LLM,让它消解代词。过长历史可先做摘要再传。
面试题¶
1. 如何在 RAG 中实现权限控制?
参考答案:在每个分块 payload 中记录 acl_roles(可访问的角色列表),检索时用 MatchAny 过滤条件把用户角色传入,先过滤后检索。这样保证用户只能看到自己权限内的文档,且不影响 Top-K 数量。
2. HyDE 的原理与代价? 答题要点:原理是"文档与文档相似度 > query 与文档相似度",用 LLM 生成假设答案作为检索代理。代价是增加一次 LLM 调用(约 200-500ms),且假设答案可能有幻觉(但只用于检索不影响最终答案)。适合 query 与文档表述差异大的场景。
3. 多轮对话中如何处理代词消解? 参考答案:检索前用 LLM 把当前 query 与对话历史合并改写为独立查询,消解"它/那个/上面提到"等代词。改写后 query 自包含完整语义,可直接检索。
4. payload 过滤对检索性能有何影响? 答题要点:pre-filter 会缩小检索空间,HNSW 图上可能需要跳过不可达节点,略微增加查询耗时(通常 <10ms)。Qdrant 对过滤做了专门优化(payload 索引),千万级数据下过滤查询仍能保持毫秒级。
练习题¶
- 权限模型实现:为知识库设计三级权限(public/internal/confidential),实现"用户只能检索自己级别及以下的文档"。
- 查询改写对比:对 10 个口语化问题,分别用原始 query 和改写后 query 检索,对比 Top-3 命中率。
- HyDE 实验:对同一组跨语言查询(中文 query 检索英文库),对比直接检索与 HyDE 检索的 Recall@5。
小结¶
元数据与查询优化是 RAG 从 demo 走向生产的关键。本章讲解了 payload 元信息管理、Qdrant 过滤语法、多轮查询改写、HyDE 与意图纠错。生产环境务必为每个分块附加 doc_id/source/roles/created_at 等元数据,并在检索前做 pre-filter,实现权限隔离与业务约束。下一章将整合所有环节,构建完整的企业知识库。