从零学RAG0x09:AdvancedRAG假设性文档 & 元数据索引优化

5 阅读11分钟

前言

书接上文(AdvancedRAG预检索-索引优化(一)),我们继续一起学习 AdvancedRAG 预检索的索引优化。

  • 假设性答案索引
  • 元数据索引

假设性答案索引

是一种“查询转换与增强”的策略。其核心思想是:将抽象的假设性问题,分解、映射或重写为一系列具体的、与现有知识库(向量库)相关的事实性命题或实体关键词,再进行检索。

准备

#获得访问大模型和嵌入模型客户端
client,embeddings_model = get_ali_clients()

# 初始化文档加载器列表
loader = TextLoader("../data/deepseek百度百科.txt", encoding="utf-8")
docs = loader.load()

# 初始化递归文本分割器(设置块大小和重叠)
text_splitter = RecursiveCharacterTextSplitter(chunk_size=1024, chunk_overlap=100)
docs = text_splitter.split_documents(docs)

# 初始化Chroma向量数据库(存储生成的问题向量)
vectorstore = Chroma(
    collection_name="hypo-questions", embedding_function=embeddings_model
)
# 初始化内存存储(存储原始文档)
store = InMemoryByteStore()

id_key = "doc_id" # 文档标识键名

# 配置多向量检索器
retriever = MultiVectorRetriever(
    vectorstore=vectorstore, #  向量数据库,存储生成的问题向量(调用对话模型生成)
    byte_store=store, # 字节存储,存储原始文档
    id_key=id_key,
)

# 为每个原始文档生成唯一ID
doc_ids = [str(uuid.uuid4()) for _ in docs]

假设性答案

HyDE(Hypothetical Document Embeddings,假设性文档嵌入)是一种解决“语义鸿沟”问题的预检索优化策略。

  • 核心逻辑:利用LLM生成一个“像答案的文本”,再用这个文本去检索真实文档,而不是直接用原始查询去检索

  • 本质:语义对齐

  • HyDE解决了传统RAG在零场景下的两大痛点:

    • 词汇不匹配(Lexical Gap) :用户口语化提问与知识库书面化文档在词汇上不重叠,导致向量检索失败。
    • 模态差异(Modality Gap) :短查询与长文档在向量空间中的分布不同,直接计算相似度效果不佳。
  • 核心流程:

    1. 生成假设文档:LLM根据用户Query生成一段详细的、包含专业术语和逻辑结构的假设性文本。
    2. 对称检索:由于假设文档在长度、风格和语义密度上更接近真实文档,它们处于同一向量子空间,检索相似度计算更准确

image.png

HypotheticalQuestions

在实际工程中,我们常用PydanticBaseModel严格定义这个“假设性问题”处理过程中的数据结构,确保每一步的数据流都清晰、可验证。

# 用于定义假设性答案的规定格式
class HypotheticalQuestions(BaseModel):
    """约束生成假设性问题的格式"""
    questions: List[str] = Field(..., description="List of questions")

HypotheticalQuestionIndex

封装一个方法,生成所需要的假设性答案,返回HypotheticalQuestions类型。

# 定义一个简单的“索引”和处理器(心智模型的代码化),用于生成假设性答案
class HypotheticalQuestionIndex:
     def process(self) -> HypotheticalQuestions:
        # hy_question = HypotheticalQuestions(original_query)
        # 查询理解与增强(此处可调用大模型或规则引擎)
        # 此处使用双括号 {{ 和 }} 是为了在字符串中转义出单个 { 和 },以确保最终输出的 JSON 格式正确。
        prompt = ChatPromptTemplate.from_template(
            """请基于以下文档生成3个假设性问题(必须使用JSON格式):
            {doc}

            要求:
            1. 输出必须为合法JSON格式,包含questions字段
            2. questions字段的值是包含3个问题的数组
            3. 使用中文提问
            示例格式:
            {{
                "questions": ["问题1", "问题2", "问题3"]
            }}"""
        )

        # 创建假设性问题链
        '''
        其中的client.with_structured_output可以理解为输出解析器的一种更高级用法
        将大模型的输出转换为HypotheticalQuestions所限定的格式,
        而HypotheticalQuestions要求的格式是:
        定义了一个字段 questions,它具有以下特性:
        类型注解:List[str] 表示 questions 字段应该是一个字符串列表。
        必需性:Field(...) 中的省略号 ... 表示这个字段是必需的。
        描述信息:description="List of questions" 为该字段添加了描述,这对于生成文档或帮助理解模型结构很有用。
        '''
        chain = (
                {"doc": lambda x: x.page_content}
                | prompt
                # 将LLM输出构建为字符串列表
                | client.with_structured_output(
            HypotheticalQuestions
            )
                # # 提取问题列表
                # | (lambda x: x.questions)
        )

        # 测试-在单个文档上调用链,链的最终输出是大模型答复的假设性问题列表
        # print("测试:",docs[0])
        # print("测试生成问题:",chain.invoke(docs[0]))

        # 批量处理所有文档生成假设性问题(最大并行数5),每个切块后的文档块都对应的生成三个问题
        hy_question = chain.batch(docs, {"max_concurrency": 5})
        # print("假设性问题列表:",hypothetical_questions)

        return hy_question

假设性答案-文档块 绑定

这里是不是特别像前面摘要索引summary_MultiVectorRetriever时的机制一样?假设性答案也需要和文档块做绑定。其实,这里和前者的差别就是就要换成了假设性答案

# 取出答案索引生成器中生成的问题列表
questions = [hy_questions.questions  for hy_questions in HypotheticalQuestionIndex().process()]

# 将生成的问题转换为带元数据的文档对象
question_docs = []
for i, question_list in enumerate(questions):
    question_docs.extend(
        [Document(page_content=s, metadata={id_key: doc_ids[i]}) for s in question_list]
    )

# print(question_docs)


retriever.vectorstore.add_documents(question_docs) # 将问题文档存入向量数据库
retriever.docstore.mset(list(zip(doc_ids, docs))) # 将原始文档存入字节存储(通过ID关联)
#以上的过程是可以在构建知识库的时候提前完成的

测试代码

query = "deepseek受到哪些攻击?"

prompt =  ChatPromptTemplate.from_template("根据下面的文档回答问题:\n\n{doc}\n\n问题: {question}")

# 生成问题回答链
chain = RunnableParallel({
    "doc": lambda x: retriever.invoke(x["question"]),
    "question": lambda x: x["question"]
}) | prompt | client | StrOutputParser()

# 生成问题回答
answer = chain.invoke({"question": query})
print("-------------回答--------------")
print(answer)
#  返回的是知识块
retrieved_docs = retriever.invoke(query) 
print("-------------检索到的问题--------------")
print(retrieved_docs)

Meta Index

image.png

元数据索引是一种通过为文档附加“标签”来缩小检索范围、提升精度的核心技术。

  • 核心目标:解决朴素 RAG 仅靠语义相似度检索时 “找不准”和“找得慢” 的问题。
  • 本质:预筛过滤器,不参与予以匹配
  • 作用:在向量数据库执行昂贵的向量相似度计算之前,先通过元数据快速圈定一个小的候选集,从而降低计算量排除无关噪声
  • 核心流程:
    • 定义元数据标签,如果文档本身没有,可以利用大模型推理出输入问题的元数据
    • 通过标签先对文档进行过滤
    • 结合向量检索进一步定位到最相关的前 K 个知识块

SelfQueryRetriever

LangChain 中借助SelfQueryRetriever实现元数据索引优化。 SelfQueryRetriever 是 LangChain 框架中一种 “智能”的检索器

  • 核心能力:让大模型(LLM)自己分析用户的提问,并自动生成数据库的查询指令。
  • 核心流程:
    • 定义蓝图:你告诉它数据库里有哪些字段(如 year, author)。
    • 自然语言拆解:用户提问(如“找一部2023年的科幻片”),它自动拆成两部分:
    • 语义部分科幻片):用于向量相似度搜索。
    • 过滤部分year == 2023):用于精确筛选。
    • 复合查询:将这两部分指令发给向量数据库,同时执行“语义搜索”和“元数据过滤”。

其核心流程是不正好与元数据索引优化不谋而合。

准备

#获得访问大模型和嵌入模型客户端
llm,embeddings_model = get_ali_clients()

# 加载文档:构造示例文档
docs = [
    Document(
        page_content="作者A团队开发出基于人工智能的自动驾驶决策系统,在复杂路况下的响应速度提升300%",
        metadata={"year": 2024, "rating": 9.2, "genre": "AI", "author": "A"},
    ),
    Document(
        page_content="区块链技术成功应用于跨境贸易结算,作者B主导的项目实现交易确认时间从3天缩短至30分钟",
        metadata={"year": 2023, "rating": 9.8, "genre": "区块链", "author": "B"},
    ),
    Document(
        page_content="云计算平台实现量子计算模拟突破,作者C构建的新型混合云架构支持百万级并发计算",
        metadata={"year": 2022, "rating": 8.6, "genre": "云", "author": "C"},
    ),
    Document(
        page_content="大数据分析预测2024年全球经济趋势,作者A团队构建的模型准确率超92%",
        metadata={"year": 2023, "rating": 8.9, "genre": "大数据", "author": "A"},
    ),
    Document(
        page_content="人工智能病理诊断系统在胃癌筛查中达到三甲医院专家水平,作者B获医疗科技创新奖",
        metadata={"year": 2024, "rating": 7.1, "genre": "AI", "author": "B"},
    ),
    Document(
        page_content="基于区块链的数字身份认证系统落地20省市,作者C设计的新型加密协议通过国家级安全认证",
        metadata={"year": 2022, "rating": 8.7, "genre": "区块链", "author": "C"},
    ),
    Document(
        page_content="云计算资源调度算法重大突破,作者A研发的智能调度器使数据中心能效提升40%",
        metadata={"year": 2023, "rating": 8.5, "genre": "云", "author": "A"},
    ),
    Document(
        page_content="大数据驱动城市交通优化系统上线,作者B团队实现早晚高峰通行效率提升25%",
        metadata={"year": 2024, "rating": 7.4, "genre": "大数据", "author": "B"},
    )
]

vectorstore = Chroma.from_documents(docs, embeddings_model)

# 文档内容描述(指导LLM理解文档内容)
document_content_description = "技术文章简述"

元数据定义

# 元数据字段定义(指导LLM如何解析查询条件)
metadata_field_info = [
    AttributeInfo(
        name="genre",
        description="文章的技术领域,选项:['AI ','区块链','云','大数据']",
        type="string",
    ),
    AttributeInfo(
        name="year",
        description="文章的出版年份",
        type="integer",
    ),
    AttributeInfo(
        name="author",
        description="署名文章的作者姓名",
        type="string",
    ),
    AttributeInfo(
        name="rating",
        description="技术价值评估得分(1-10分)",
        type="float"
    )
]

检索器

# 创建自查询检索器(核心组件)
retriever = SelfQueryRetriever.from_llm(
    llm,
    vectorstore,
    document_content_description,
    metadata_field_info,
    # enable_limit=True, 限定只返回一个结果
)

测试

print("============ 测试代码 - 元数据索引 ============")
print("---------------------评分在9分以上的文章-------------------------------")
#查询条件:查询只约束分数 rating>9
print(retriever.invoke("我想了解评分在9分以上的文章"))

print("---------------------作者B在2023年发布的文章-------------------------------")
# 第二个查询只约束作者和年份 author="B", year=2023
print(retriever.invoke("作者B在2023年发布的文章"))

原理探究

是不是感觉像是sql查询,那么我们不妨对其原理一探究竟。

  • get_query_constructor_prompt:SelfQueryRetriever 工作流程中的 “指令生成器”
    • 核心:为你自动生成一个专门用于“教”大模型如何拆解用户查询的提示词模板
  • StructuredQueryOutputParser:SelfQueryRetriever 流程中的 “质量检验员”
    • 核心:将大模型返回的、非结构化的文本,强制解析并转换为一个标准的、结构化的查询对象

查询提示模板

prompt = get_query_constructor_prompt(
    document_content_description,
    metadata_field_info,
)

解析器

output_parser = StructuredQueryOutputParser.from_components()
#链式操作,先将用户查询填入提示模板,接着由语言模型生成结构化输出,最后通过解析器得到结构化查询。
query_constructor = prompt | llm | output_parser

Prompt探究

print("------------ 提示词显示 ------------")
print(prompt.format(query="我想了解评分在9分以上的文章"))

我们发现,他内部会生成一个特别详尽的 Prompt,我们可以管中窥豹简单分析下:

  • query:原始查询

  • filter:过滤

  • compare:比较

    • eq:equal to
    • lt:less than
  • logical:and、or、not

  • ...

可见其内置了丰富的过滤方式。

image.png

我们继续往下翻提示词,发现它还使用了few shot少样本提示。

image.png

# 打印结构化查询的结果

print("------------ 结构化查询结果 ------------")
print(query_constructor.invoke(
    {
        "query": "作者B在2023年发布的文章"
    }
))

我们再看,结构化查询的结果,简而言之理解就是:

  • aythor = ‘B’
  • year = ‘2023’
  • 以上两个条件使用 ‘and’连接,查询

image.png

综上简而言之,元数据索引就是先定义一些数据的附加数据,然后借助 Langchain 的SelfQueryRetriever 根据 query 自动生成了带过滤属性的 Prompt,然后将 Prompt 抛给LLM执行。

小结

优化方法优点缺陷典型应用场景
摘要索引降低索引和检索的噪声与计算成本;提升相关性判断的效率和精度。摘要可能丢失细节;摘要质量依赖摘要模型的能力;二次查询的精度瓶颈。处理长文档(论文、报告、书籍章节);知识库问答;需快速定位章节的场景。
父子索引兼顾检索精度与生成质量;有效规避“截断上下文”问题。增加存储和索引的复杂度;子块划分策略对效果影响大。需要精确检索片段(如代码函数、产品参数),但生成答案需更完整上下文的场景。
假设性文档将语义检索转换为“语义匹配”,对齐查询空间与文档空间,极大提升零样本能力。依赖大模型生成能力,增加延迟和成本;可能引入“幻觉”导致检索偏差。查询与文档表述差异大(如口语vs专业术语);冷启动或文档稀疏的场景。
元数据索引实现高效、精准的维度过滤;可与其他优化方法结合,形成多级检索管道。依赖高质量的元数据提取与标注,可能需额外工程;硬过滤可能误伤相关结果。对检索结果有明确维度要求(如时效性、来源权威性、类型);垂直搜索和电商。

源码

github