跳转至

第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 索引),千万级数据下过滤查询仍能保持毫秒级。

练习题

  1. 权限模型实现:为知识库设计三级权限(public/internal/confidential),实现"用户只能检索自己级别及以下的文档"。
  2. 查询改写对比:对 10 个口语化问题,分别用原始 query 和改写后 query 检索,对比 Top-3 命中率。
  3. HyDE 实验:对同一组跨语言查询(中文 query 检索英文库),对比直接检索与 HyDE 检索的 Recall@5。

小结

元数据与查询优化是 RAG 从 demo 走向生产的关键。本章讲解了 payload 元信息管理、Qdrant 过滤语法、多轮查询改写、HyDE 与意图纠错。生产环境务必为每个分块附加 doc_id/source/roles/created_at 等元数据,并在检索前做 pre-filter,实现权限隔离与业务约束。下一章将整合所有环节,构建完整的企业知识库。