在前几篇文章中,我们搭建起了 RAG 的骨架:选好了向量数据库(Milvus),配置了强大的语义模型(BGE),甚至解决了时间衰减的问题。
现在,你的 RAG 系统已经能跑通了。但在实际测试中,你可能会撞上一堵无形的墙:
- 场景 A: 你问“公司迟到怎么罚款?”,系统搜到了“罚款 50 元”的片段,但 LLM 答错了。因为那个片段太短,丢掉了前面关键的主语——“连续三次迟到者”。
- 场景 B: 用户问“怎么治那个一直在嗓子眼里的病?”,系统搜不到任何结果。因为数据库里的文档写的是专业的“慢性咽炎临床治疗方案”。
这标志着我们进入了 RAG 开发的深水区——检索质量优化(Retrieval Quality Optimization)。
不仅仅是“搜得快”,更要“搜得准”且“读得懂”。今天,我们将探讨三种能够显著提升 RAG 效果的高级魔法:Small-to-Big、HyDE 以及 上下文压缩。
魔法一:Small-to-Big (父文档检索)
这是解决 “搜索精度”与“上下文完整性”矛盾 的最佳实践。
痛点分析
在做 RAG 时,我们面临一个两难选择:
- 切片小 (Child Chunk): 语义聚焦,向量特征鲜明,容易被搜到。但信息破碎,缺乏前因后果。
- 切片大 (Parent Document): 信息完整,LLM 容易读懂。但噪音多,语义被稀释,很难被向量检索命中。
解决方案
Small-to-Big (从小到大) 策略的核心思想是:“索引时用小的,生成时给大的。”
- 索引阶段: 将文档切分成细粒度的子切片(比如 128 tokens),并生成向量存入 Milvus。
- 存储阶段: 同时在内存存储(如 Redis 或 InMemoryStore)中保存完整的父文档(Parent Document),并建立
Child_ID -> Parent_ID的映射。 - 检索阶段: 用 Query 搜索子切片。
- 生成阶段: 命中子切片后,抛弃它,通过映射找出它所属的“父文档”,将内容完整的父文档喂给 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 编一个答案。
- 生成 (Hallucinate): 当用户提问时,先不查库,而是让 LLM 生成一个假设性回复。哪怕这个回复事实是错的,但它会包含大量相关的专业术语和行文习惯。
- 编码 (Encode): 将这个“虚构答案”向量化。
- 检索 (Retrieve): 用虚构答案的向量去 Milvus 里搜。
因为“虚构答案”和“真实文档”在风格上高度相似,召回率通常会大幅提升。
适用场景
- 跨语种检索: 用户用中文搜,库里全是英文。让 LLM 生成一段英文假答案去搜,效果奇好。
- 专业领域盲区: 用户不知道专业名词(如不知“通货膨胀”,只说“钱不值钱”)。
魔法三:上下文压缩 (Context Compression)
当我们使用了 Small-to-Big 或扩大了检索范围(Top-K=50),Prompt 的长度会迅速膨胀。这带来了两个问题:
- 贵: Token 越多,API 成本越高。
- 迷失中间 (Lost in the Middle): 研究表明,当 Prompt 过长时,LLM 往往只关注开头和结尾,忽略中间的关键信息。
解决方案
在 Retriever (检索) 和 LLM (生成) 之间,插入一个 Compressor (压缩器)。
它就像一个严厉的编辑,负责给检索回来的内容“瘦身”。
常见的压缩策略:
- LLMLingua (基于困惑度的压缩):
利用一个小模型(如 GPT-2 或 Llama-7B)计算 Prompt 中每个 Token 的困惑度(Perplexity)。如果去掉某个词(比如 "the", "a", "is", "非常"),句子的语义几乎不变,那就删掉它。这种方法能将 Prompt 压缩 5-10 倍而不丢失核心信息。 - 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 的优化从来不是单点突破,而是组合拳:
- 数据入库时: 使用 Small-to-Big,兼顾检索的锐度和阅读的广度。
- 接收提问时: 如果领域跨度大,使用 HyDE 将用户口语转化为专业向量。
- 最终生成前: 使用 上下文压缩 或 Reranker,去粗取精,把最宝贵的 Token 留给 LLM。
通过这些应用层的技巧,你的 RAG 系统已经能战胜 80% 的竞争对手了。