RAG 进阶之路6:优化策略Small-to-Big、HyDE 与上下文压缩

在前几篇文章中,我们搭建起了 RAG 的骨架:选好了向量数据库(Milvus),配置了强大的语义模型(BGE),甚至解决了时间衰减的问题。

现在,你的 RAG 系统已经能跑通了。但在实际测试中,你可能会撞上一堵无形的墙:

  • 场景 A: 你问“公司迟到怎么罚款?”,系统搜到了“罚款 50 元”的片段,但 LLM 答错了。因为那个片段太短,丢掉了前面关键的主语——“连续三次迟到者”。
  • 场景 B: 用户问“怎么治那个一直在嗓子眼里的病?”,系统搜不到任何结果。因为数据库里的文档写的是专业的“慢性咽炎临床治疗方案”。

这标志着我们进入了 RAG 开发的深水区——检索质量优化(Retrieval Quality Optimization)

不仅仅是“搜得快”,更要“搜得准”且“读得懂”。今天,我们将探讨三种能够显著提升 RAG 效果的高级魔法:Small-to-BigHyDE 以及 上下文压缩

魔法一:Small-to-Big (父文档检索)

这是解决 “搜索精度”与“上下文完整性”矛盾 的最佳实践。

痛点分析

在做 RAG 时,我们面临一个两难选择:

  • 切片小 (Child Chunk): 语义聚焦,向量特征鲜明,容易被搜到。但信息破碎,缺乏前因后果。
  • 切片大 (Parent Document): 信息完整,LLM 容易读懂。但噪音多,语义被稀释,很难被向量检索命中。

解决方案

Small-to-Big (从小到大) 策略的核心思想是:“索引时用小的,生成时给大的。”

  1. 索引阶段: 将文档切分成细粒度的子切片(比如 128 tokens),并生成向量存入 Milvus。
  2. 存储阶段: 同时在内存存储(如 Redis 或 InMemoryStore)中保存完整的父文档(Parent Document),并建立 Child_ID -> Parent_ID 的映射。
  3. 检索阶段: 用 Query 搜索子切片。
  4. 生成阶段: 命中子切片后,抛弃它,通过映射找出它所属的“父文档”,将内容完整的父文档喂给 LLM。

LangChain 代码实战

from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import InMemoryStore
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.vectorstores import Milvus
from langchain_community.embeddings import HuggingFaceEmbeddings

# 1. 定义两个切分器
# 子切分器:切得很碎,用于做向量索引
child_splitter = RecursiveCharacterTextSplitter(chunk_size=200)
# 父切分器:切得很大,用于给 LLM 阅读
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=1000)

# 2. 准备存储层
# vectorstore 负责存子切片的向量 (搜索用)
vectorstore = Milvus(
    embedding_function=HuggingFaceEmbeddings(model_name="BAAI/bge-base-zh-v1.5"),
    collection_name="rag_small_to_big",
    connection_args={"host": "localhost", "port": "19530"}
)
# store 负责存父文档的原始内容 (回溯用)
store = InMemoryStore()

# 3. 初始化检索器
retriever = ParentDocumentRetriever(
    vectorstore=vectorstore,
    docstore=store,
    child_splitter=child_splitter,
    parent_splitter=parent_splitter,
)

# 4. 使用
# add_documents 时,它会自动切分并建立映射
retriever.add_documents(original_docs)

# 检索时,自动返回大块的父文档
result = retriever.invoke("迟到怎么罚款?")
print(result[0].page_content) # 这里打印出的是包含上下文的完整段落

魔法二:HyDE (假设性文档嵌入)

这是用来跨越 “口语 Query”与“专业 Doc”语义鸿沟 的策略。俗称“用魔法打败魔法”。

痛点分析

向量检索依赖于 Query 和 Document 在语义空间上的接近。

  • 用户 Query: “这破代码跑不起来咋整?” (短、口语、充满情绪)
  • 知识库 Doc: “Python 运行时异常处理与调试指南...” (长、专业、书面语)
    这两者在向量空间里距离可能很远,导致搜不到。

解决方案

HyDE (Hypothetical Document Embeddings) 的核心逻辑是:在搜索之前,先让 LLM 编一个答案。

  1. 生成 (Hallucinate): 当用户提问时,先不查库,而是让 LLM 生成一个假设性回复。哪怕这个回复事实是错的,但它会包含大量相关的专业术语行文习惯
  2. 编码 (Encode): 将这个“虚构答案”向量化。
  3. 检索 (Retrieve): 用虚构答案的向量去 Milvus 里搜。

因为“虚构答案”和“真实文档”在风格上高度相似,召回率通常会大幅提升。

适用场景

  • 跨语种检索: 用户用中文搜,库里全是英文。让 LLM 生成一段英文假答案去搜,效果奇好。
  • 专业领域盲区: 用户不知道专业名词(如不知“通货膨胀”,只说“钱不值钱”)。

魔法三:上下文压缩 (Context Compression)

当我们使用了 Small-to-Big 或扩大了检索范围(Top-K=50),Prompt 的长度会迅速膨胀。这带来了两个问题:

  1. 贵: Token 越多,API 成本越高。
  2. 迷失中间 (Lost in the Middle): 研究表明,当 Prompt 过长时,LLM 往往只关注开头和结尾,忽略中间的关键信息。

解决方案

Retriever (检索)LLM (生成) 之间,插入一个 Compressor (压缩器)

它就像一个严厉的编辑,负责给检索回来的内容“瘦身”。

常见的压缩策略:

  1. LLMLingua (基于困惑度的压缩):
    利用一个小模型(如 GPT-2 或 Llama-7B)计算 Prompt 中每个 Token 的困惑度(Perplexity)。如果去掉某个词(比如 "the", "a", "is", "非常"),句子的语义几乎不变,那就删掉它。这种方法能将 Prompt 压缩 5-10 倍而不丢失核心信息。
  2. Selective Context (基于语义的过滤):
    这其实就是我们在第三篇讲过的 Reranker 的变种。对于检索回来的 Top-20 文档,再次计算它们与 Query 的相关性,直接丢弃那些相关性低于阈值的段落。

LangChain 实现思路

from langchain.retrievers import ContextualCompressionRetriever
from langchain.retrievers.document_compressors import LLMChainExtractor
from langchain_openai import ChatOpenAI

# 定义基础检索器
base_retriever = vectorstore.as_retriever(search_kwargs={"k": 20})

# 定义压缩器 (这里使用 LLM 提取关键信息)
llm = ChatOpenAI(temperature=0)
compressor = LLMChainExtractor.from_llm(llm)

# 组装
compression_retriever = ContextualCompressionRetriever(
    base_compressor=compressor,
    base_retriever=base_retriever
)

# 现在的 result 只包含与问题高度相关的精简句子,而非整段废话
result = compression_retriever.invoke("项目A的截止日期是?")

总结:组合拳的力量

RAG 的优化从来不是单点突破,而是组合拳:

  1. 数据入库时: 使用 Small-to-Big,兼顾检索的锐度和阅读的广度。
  2. 接收提问时: 如果领域跨度大,使用 HyDE 将用户口语转化为专业向量。
  3. 最终生成前: 使用 上下文压缩Reranker,去粗取精,把最宝贵的 Token 留给 LLM。

通过这些应用层的技巧,你的 RAG 系统已经能战胜 80% 的竞争对手了。