深入解析RAG流程:从向量检索到LLM上下文窗口的智慧选择
尊敬的各位开发者、技术爱好者们,大家好!
今天,我们将共同深入探讨一个在当前AI领域备受瞩目的技术——检索增强生成(Retrieval Augmented Generation, RAG)。大型语言模型(LLMs)以其惊人的生成能力和对自然语言的理解力,正在重塑我们与技术交互的方式。然而,LLMs也并非没有短板:它们可能“幻觉”(hallucinate),生成不准确或不符合事实的信息;它们的知识库仅限于训练时的数据,无法实时更新;它们也无法访问特定领域的私有数据。
RRAG正是为了解决这些核心痛点而生。它通过将LLM的生成能力与外部、可信的知识库检索能力相结合,显著提升了LLM的准确性、可靠性和可解释性。RAG的核心在于,当用户提出问题时,系统首先从一个或多个知识源中检索出最相关的片段,然后将这些片段作为额外的上下文信息,与用户查询一同输入给LLM,引导LLM生成更精准、更具信息量的回答。
本次讲座的重点将放在RAG流程中最关键、也最精妙的环节之一:内容是如何从庞大的向量数据库中被智慧地筛选、组织,并最终选入LLM有限的上下文窗口的? 这不仅仅是简单地“找到”相关信息,更是一门关于如何高效利用有限资源,最大化信息价值的艺术。
第一章:RAG架构概览——构建智能应答的蓝图
RAG系统通常可以被分解为两个主要阶段:索引阶段(Index Phase) 和 检索-生成阶段(Retrieval-Augmentation & Generation Phase)。理解这两个阶段及其内部组件,是掌握RAG工作原理的基础。
1.1 索引阶段 (Index Phase)
这个阶段是离线进行的,其目标是将所有可用的知识源预处理并存储在一个易于检索的格式中,特别是向量数据库。
- 文档加载器 (Document Loader): 负责从各种来源(如PDF文件、Markdown文档、网页、数据库记录等)加载原始数据。
- 文本分块器 (Text Splitter/Chunker): 将长文档切分成更小的、语义连贯的片段(chunks)。这是为了适应后续嵌入模型的输入限制和LLM的上下文窗口。
- 嵌入模型 (Embedding Model): 将每个文本块转换成一个高维向量(embedding)。这些向量捕获了文本的语义信息,使得语义相似的文本在向量空间中的距离更近。
- 向量数据库 (Vector Database): 存储这些高维向量,并提供高效的近似最近邻(Approximate Nearest Neighbor, ANN)搜索能力。
1.2 检索-生成阶段 (Retrieval-Augmentation & Generation Phase)
这个阶段是在线进行的,当用户提出查询时触发。
- 查询嵌入 (Query Embedding): 用户输入的查询(Question)同样通过嵌入模型转换为一个向量。
- 检索器 (Retriever): 使用查询向量在向量数据库中执行相似度搜索,找出与查询最相关的Top-K个文档块。
- 重排序器 (Re-ranker – 可选但推荐): 对初步检索到的Top-K文档块进行二次排序,以提升相关性并去除冗余。
- 上下文选择与组织 (Context Selection & Organization): 这是我们今天讲座的重中之重。它负责将重排序后的文档块,根据LLM的上下文窗口限制,进行进一步的筛选、压缩和格式化,形成最终的上下文提示。
- 大语言模型 (Large Language Model, LLM): 接收用户查询和精心准备的上下文,生成最终的答案。
下面的图表简要展示了RAG的主要流程:
| 阶段 | 组件 | 描述 |
|---|---|---|
| 索引阶段 | 文档加载器 | 从各种数据源(文件、数据库、API等)获取原始数据。 |
| (离线) | 文本分块器 | 将原始文档分割成更小、语义连贯的文本块。 |
| 嵌入模型 | 将文本块转换为高维向量表示。 | |
| 向量数据库 | 存储文本块的向量表示及其原始文本和元数据,并支持高效的相似度搜索。 | |
| 检索-生成阶段 | 查询嵌入 | 将用户输入的自然语言查询转换为向量。 |
| (在线) | 检索器 | 在向量数据库中查找与查询向量最相似的Top-K个文档块。 |
| 重排序器 (可选) | 对初步检索结果进行二次排序,提高相关性。 | |
| 上下文管理器 | 筛选、压缩、组织检索结果,使其适应LLM的上下文窗口,并与用户查询结合形成最终的提示。 | |
| 大语言模型 (LLM) | 接收完整的提示(查询+上下文),生成最终答案。 |
第二章:数据准备与向量化:构建知识基石 (索引阶段)
在RAG的索引阶段,我们将原始的、非结构化的知识转化为可供机器高效检索的结构化形式。这一步的质量直接决定了后续检索的效率和准确性。
2.1 文档加载与预处理
数据是RAG系统的生命线。我们需要能够从各种格式中提取文本内容。流行的框架如LangChain和LlamaIndex提供了丰富的文档加载器。
# 示例:使用LangChain加载不同类型的文档
from langchain.document_loaders import PyPDFLoader, WebBaseLoader, TextLoader
from langchain.schema import Document
# 加载PDF文件
pdf_loader = PyPDFLoader("example.pdf")
pdf_docs = pdf_loader.load()
print(f"Loaded {len(pdf_docs)} pages from PDF.")
# 加载网页内容
web_loader = WebBaseLoader("https://www.example.com/some-article")
web_docs = web_loader.load()
print(f"Loaded {len(web_docs)} documents from web.")
# 加载纯文本文件
text_loader = TextLoader("example.txt")
text_docs = text_loader.load()
print(f"Loaded {len(text_docs)} documents from text file.")
# 原始文档通常需要清洗:去除页眉页脚、广告、导航栏、不相关的代码块等。
# 这通常需要针对特定文档类型编写自定义的处理逻辑。
def clean_document(doc: Document) -> Document:
# 简单的文本清洗示例
cleaned_content = doc.page_content.replace("nn", "n").strip()
# 可以在这里添加更复杂的正则表达式或NLP清洗
doc.page_content = cleaned_content
return doc
cleaned_pdf_docs = [clean_document(doc) for doc in pdf_docs]
2.2 文本分块策略 (Chunking)
LLM的上下文窗口限制以及嵌入模型对输入长度的限制,使得我们无法将整个长文档直接送入模型。因此,将文档分割成大小适中、语义连贯的块(chunks)至关重要。
为何分块?
- 适应LLM上下文限制: 避免单个文档过大而无法放入LLM的输入。
- 提高检索粒度: 使得检索器能够精确地找到文档中最相关的部分,而不是整个文档。
- 优化嵌入质量: 嵌入模型在处理中等长度的文本时效果最佳,过长或过短的文本可能导致嵌入质量下降。
常见分块策略:
| 策略 | 描述 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 固定大小分块 | 将文本简单地切分成固定字符数或Token数的块。 | 实现简单、速度快 | 可能在语义中间切断,导致信息不完整。 | 文本结构不重要、快速原型开发。 |
| 带重叠的固定大小分块 | 在固定大小分块的基础上,引入块之间的重叠部分。 | 缓解语义切断问题,保留上下文连贯性。 | 增加存储冗余,可能引入不必要的重复信息。 | 大多数通用RAG场景,是常用基线方法。 |
| 基于分隔符分块 | 根据特定的分隔符(如nn表示段落、#表示标题、。表示句子)进行分块。 |
尊重文本自然结构,语义连贯性较好。 | 依赖于文本的结构化程度,分隔符选择不当可能效果差。 | 结构化文档(Markdown、代码)、段落或章节清晰的文本。 |
| 递归字符分块 | 尝试使用一系列分隔符进行分块,如果第一个分隔符导致块过大,则尝试第二个,以此类推。 | 灵活,能更好地适应不同结构。 | 复杂的文本结构可能仍难以完美处理。 | 通用文档,尤其是结构多样或复杂的文档。 |
| 语义分块 | 利用嵌入模型计算相邻句子的相似度,在相似度下降的“谷点”进行切分。 | 尽可能保持语义完整性。 | 计算成本高,需要额外的嵌入模型推理。 | 对语义完整性要求极高,且计算资源充足的场景。 |
| 上下文感知分块/父子分块 | 为每个小块存储其父文档的摘要或关键元数据,或将小块与更大的父块关联。 | 检索时提供更丰富的上下文,避免信息丢失。 | 复杂度高,需要更复杂的索引和检索逻辑。 | 对复杂概念理解、跨文档关联性要求高的场景(如问答、信息抽取)。 |
代码示例:使用LangChain的RecursiveCharacterTextSplitter
from langchain.text_splitter import RecursiveCharacterTextSplitter
# 假设我们有一个很长的文本
long_text = """
The quick brown fox jumps over the lazy dog. This is the first sentence of a paragraph.
It contains some important information.
This is the second paragraph. It also has crucial details about the topic at hand.
We should pay close attention to its content.
Chapter 1: Introduction
This chapter introduces the fundamental concepts.
Chapter 2: Advanced Topics
Here we delve into more complex ideas.
"""
# 创建一个递归字符文本分块器
# chunk_size: 每个块的最大字符数
# chunk_overlap: 块之间的重叠字符数
# separators: 尝试使用的分隔符列表,按优先级从高到低排列
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=500, # 理想的块大小通常在200-1000个Token,这里用字符数作为示例
chunk_overlap=50,
separators=["nn", "n", " ", ""], # 先尝试双换行符,再单换行符,再空格,最后单个字符
length_function=len, # 使用len()计算长度,也可以是计算Token的函数
)
chunks = text_splitter.split_text(long_text)
print(f"Original text length: {len(long_text)} characters")
print(f"Generated {len(chunks)} chunks.")
for i, chunk in enumerate(chunks):
print(f"n--- Chunk {i+1} (Length: {len(chunk)}) ---")
print(chunk)
# 对于Document对象列表,可以使用split_documents
# chunked_docs = text_splitter.split_documents(pdf_docs)
# print(f"Split {len(pdf_docs)} documents into {len(chunked_docs)} chunks.")
2.3 文本嵌入 (Embedding Generation)
嵌入是将文本转换为数值向量的过程,这个向量能够捕捉文本的语义含义。语义相似的文本会映射到向量空间中相近的点。
嵌入模型的选择:
- 开源模型: 如
Sentence-Transformers库提供的模型(all-MiniLM-L6-v2,BAAI/bge-small-en-v1.5,nomic-ai/nomic-embed-text等)。它们可以在本地运行,成本可控,但性能可能略逊于商业API。 - 商业API: 如OpenAI的
text-embedding-ada-002、Google的PaLM嵌入模型、Cohere的嵌入模型。它们通常性能强大、易于使用,但有API调用成本和数据隐私考虑。
选择合适的嵌入模型至关重要,它直接影响检索的质量。模型应与你的数据领域和语言相匹配。
# 示例:使用HuggingFaceEmbeddings (开源模型)
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_openai import OpenAIEmbeddings
import os
# 确保安装了相关库:pip install sentence-transformers langchain-community
# 如果使用OpenAI,还需要:pip install openai langchain-openai
# 1. 使用开源HuggingFace模型 (推荐用于本地部署和成本控制)
print("Using HuggingFace Embeddings...")
hf_embeddings = HuggingFaceEmbeddings(
model_name="BAAI/bge-small-en-v1.5", # 或 "all-MiniLM-L6-v2"
model_kwargs={'device': 'cpu'}, # 'cuda' if GPU is available
encode_kwargs={'normalize_embeddings': True} # 归一化嵌入向量,通常对余弦相似度有利
)
# 2. 使用OpenAI嵌入模型 (需要API Key)
# os.environ["OPENAI_API_KEY"] = "YOUR_OPENAI_API_KEY"
# print("Using OpenAI Embeddings...")
# openai_embeddings = OpenAIEmbeddings(model="text-embedding-ada-002")
# 假设我们有一些文本块
sample_chunks = [
"The cat sat on the mat.",
"A feline rested on the rug.",
"The dog chased the ball."
]
# 生成嵌入向量
chunk_embeddings_hf = hf_embeddings.embed_documents(sample_chunks)
print(f"Generated {len(chunk_embeddings_hf)} embeddings with dimension {len(chunk_embeddings_hf[0])}.")
# print(f"First embedding (HF): {chunk_embeddings_hf[0][:5]}...") # 打印前5个维度
# if os.getenv("OPENAI_API_KEY"):
# chunk_embeddings_openai = openai_embeddings.embed_documents(sample_chunks)
# print(f"Generated {len(chunk_embeddings_openai)} embeddings with dimension {len(chunk_embeddings_openai[0])}.")
# # print(f"First embedding (OpenAI): {chunk_embeddings_openai[0][:5]}...")
2.4 向量数据库 (Vector Database)
向量数据库是存储和管理这些高维嵌入向量的核心组件。它通过近似最近邻(ANN)算法,能够在海量向量中快速找到与给定查询向量最相似的K个向量。
核心技术:近似最近邻 (ANN) 算法
由于精确的最近邻搜索在大规模数据集上计算成本过高,向量数据库普遍采用ANN算法。常见的ANN算法包括:
- HNSW (Hierarchical Navigable Small World): 基于图的算法,构建多层图结构,在图中高效导航以找到近似最近邻。性能和召回率通常很高。
- IVF_FLAT (Inverted File Index): 基于聚类的算法,将向量空间划分为多个簇,查询时只在相关簇中进行搜索。
- Annoy (Approximate Nearest Neighbors Oh Yeah): 基于树的算法,构建多个随机超平面二叉树,通过在树中下降来寻找近似最近邻。
- Faiss (Facebook AI Similarity Search): 一个强大的C++库,提供了多种ANN算法的实现,可以与Python绑定使用。
主流向量数据库:
- 开源自托管:
Faiss(库而非完整DB)、Milvus、Weaviate、Qdrant、Chroma。它们提供了丰富的API和部署灵活性。 - 托管服务:
Pinecone、Zilliz Cloud(基于Milvus)、Weaviate Cloud、Qdrant Cloud。它们提供高可用性、可扩展性和便捷的运维。
关键特性:
- 可扩展性: 处理TB级甚至PB级数据。
- 性能: 低延迟的查询响应。
- 过滤能力: 除了向量相似度搜索,还能根据元数据(metadata)进行过滤,例如按日期、作者、标签等。
- 混合搜索 (Hybrid Search): 结合向量相似度与关键词搜索(如BM25),提供更全面的检索能力。
代码示例:将分块和嵌入存入Chroma向量数据库
from langchain_community.vectorstores import Chroma
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain.schema import Document
# 假设我们已经有了分块后的Document对象列表
# chunks_as_docs = [Document(page_content=chunk, metadata={"source": "example.txt", "chunk_id": i}) for i, chunk in enumerate(chunks)]
# 为了演示,我们直接创建一些Document
chunks_as_docs = [
Document(page_content="The cat sat on the mat.", metadata={"source": "story_1", "chapter": "1"}),
Document(page_content="A feline rested on the rug.", metadata={"source": "story_1", "chapter": "1"}),
Document(page_content="The dog chased the ball.", metadata={"source": "story_2", "chapter": "1"}),
Document(page_content="Dogs are known for their loyalty.", metadata={"source": "encyclopedia", "category": "animals"}),
Document(page_content="The sun rises in the east.", metadata={"source": "nature_facts", "category": "astronomy"}),
]
# 使用之前定义的HuggingFace Embeddings
embeddings_model = HuggingFaceEmbeddings(
model_name="BAAI/bge-small-en-v1.5",
model_kwargs={'device': 'cpu'},
encode_kwargs={'normalize_embeddings': True}
)
# 创建Chroma向量数据库实例,并添加文档
# persist_directory 参数用于持久化数据到磁盘
print("nCreating Chroma vector store and adding documents...")
vectorstore = Chroma.from_documents(
documents=chunks_as_docs,
embedding=embeddings_model,
persist_directory="./chroma_db"
)
vectorstore.persist() # 确保数据写入磁盘
print("Documents added to Chroma.")
# 加载持久化的数据库
# vectorstore = Chroma(persist_directory="./chroma_db", embedding_function=embeddings_model)
第三章:智能检索:从用户查询到相关文档 (检索阶段)
当用户提出一个问题时,RAG系统进入检索阶段。此阶段的目标是根据用户查询,从庞大的知识库中快速、准确地识别出最相关的文档块。
3.1 用户查询嵌入
与文档块的向量化类似,用户输入的自然语言查询也需要被转换为一个高维向量。关键在于,查询嵌入模型必须与文档嵌入模型保持一致,以确保它们在同一个向量空间中进行比较。
# 示例:查询嵌入
query = "Tell me about animals that are loyal."
query_embedding = embeddings_model.embed_query(query)
print(f"nQuery embedding generated with dimension {len(query_embedding)}.")
# print(f"Query embedding (first 5 dims): {query_embedding[:5]}...")
3.2 相似度搜索 (Similarity Search)
这是向量数据库的核心功能。它计算查询向量与数据库中所有文档块向量之间的相似度(或距离),并返回相似度最高的Top-K个结果。
常见相似度度量:
- 余弦相似度 (Cosine Similarity): 衡量两个向量方向上的一致性。值范围在-1到1之间,1表示完全相似,-1表示完全不相似,0表示无关。在文本嵌入中最为常用。
- 欧氏距离 (Euclidean Distance): 衡量向量空间中两点之间的直线距离。距离越小表示越相似。
- 内积 (Dot Product): 衡量两个向量的投影长度和方向。当向量归一化后,内积与余弦相似度等价。
向量数据库内部的ANN算法在此阶段发挥作用,它不是遍历所有向量进行精确计算,而是通过巧妙的数据结构(如HNSW图)快速定位到近似的最近邻。
# 示例:执行相似度搜索
# 确保Chroma实例已经加载或创建
# vectorstore = Chroma(persist_directory="./chroma_db", embedding_function=embeddings_model)
print(f"nPerforming similarity search for query: '{query}'")
# k=3 表示检索最相似的3个文档块
retrieved_docs_raw = vectorstore.similarity_search(query, k=3)
print(f"Retrieved {len(retrieved_docs_raw)} raw documents:")
for i, doc in enumerate(retrieved_docs_raw):
print(f"--- Document {i+1} ---")
print(f"Content: {doc.page_content[:100]}...") # 打印前100个字符
print(f"Metadata: {doc.metadata}")
3.3 检索策略优化
简单的相似度搜索可能不足以应对所有场景。优化检索策略可以显著提高RAG系统的性能。
-
关键词搜索与混合搜索 (Hybrid Search):
- 问题: 纯向量搜索有时在精确匹配特定关键词时表现不佳(例如,搜索“Python编程语言”与“蟒蛇”可能会混淆)。
- 解决方案: 结合传统的关键词搜索(如BM25、TF-IDF)与向量相似度搜索。
- 实现: 许多向量数据库(如Weaviate, Qdrant)原生支持混合搜索。也可以在应用层将两种搜索结果融合(例如,RAG-Fusion使用倒数排名融合RRF)。
-
元数据过滤 (Metadata Filtering):
- 问题: 即使向量相似,文档也可能不符合用户对时间、来源、作者等方面的要求。
- 解决方案: 利用文档的元数据对检索结果进行过滤。例如,只检索2023年之后发布的文档,或只检索来自特定部门的文档。
- 优势: 大幅提高检索的精确度和相关性,用户可以更精细地控制检索范围。
# 示例:带元数据过滤的检索
# 假设我们只想检索来自 "encyclopedia" 类别且包含 "dogs" 信息的文档
query_with_filter = "What are dogs known for?"
print(f"nPerforming similarity search with metadata filter for query: '{query_with_filter}'")
# Chroma的similarity_search支持where子句进行元数据过滤
retrieved_docs_filtered = vectorstore.similarity_search(
query_with_filter,
k=2,
where={"category": "animals"} # 过滤条件
)
print(f"Retrieved {len(retrieved_docs_filtered)} filtered documents:")
for i, doc in enumerate(retrieved_docs_filtered):
print(f"--- Document {i+1} ---")
print(f"Content: {doc.page_content[:100]}...")
print(f"Metadata: {doc.metadata}")
# 注意:如果元数据过滤条件过于严格,可能会导致检索不到任何结果。
第四章:智慧选择:将检索结果适配LLM上下文窗口 (关键环节)
这一章是本次讲座的核心。向量数据库返回的Top-K文档块可能数量过多、存在冗余,或者并非都具有同等的重要性。如何从这些“原始”检索结果中精挑细选,并将其高效地打包进LLM有限的上下文窗口,是RAG系统性能优劣的关键所在。
4.1 上下文窗口的限制与挑战
在深入策略之前,我们必须清晰地认识到LLM上下文窗口带来的固有挑战:
- Token限制: 几乎所有LLM都有一个固定的最大输入Token数(例如,GPT-3.5-turbo的4k/16k,GPT-4的8k/32k/128k)。超出这个限制,模型将无法处理。
- 成本与延迟: 更长的上下文意味着更多的Token处理,从而导致更高的API调用成本和更长的生成时间。
- “中间遗忘”问题 (Lost in the Middle): 研究表明,LLM在处理非常长的上下文时,往往更关注上下文的开头和结尾部分,而对中间部分的信息关注度下降,容易遗漏关键信息。
- 冗余与噪声: 简单地堆砌大量检索到的文档块,可能引入重复信息或与查询相关性不高的噪声,反而干扰LLM的判断。
- 信息密度: 如何在有限的Token内,提供最高信息密度、最相关、最无冗余的上下文,是RAG的关键目标。
4.2 初步筛选:Top-K选择
最直接、最基础的方法就是从向量数据库检索到的结果中,直接选取得分最高的K个文档块。
- 优点: 实现简单,计算开销小。
- 缺点: K的选择是经验性的,过大可能超出LLM上下文,过小可能遗漏重要信息。它只考虑了与查询的直接相似性,可能存在大量冗余信息,缺乏多样性,并且未能解决“中间遗忘”问题。
# 假设 retrieved_docs_raw 是我们通过 similarity_search 得到的初步结果
# retrieved_docs_raw = [...] # 假设这里有5-10个文档
# print(f"Initial retrieved documents: {len(retrieved_docs_raw)}")
# 如果LLM上下文限制只能容纳3个文档块,那么简单的Top-K就是选择前3个
llm_context_limit_chunks = 3
selected_for_context_topk = retrieved_docs_raw[:llm_context_limit_chunks]
print(f"nSelected {len(selected_for_context_topk)} documents using simple Top-K for context:")
for i, doc in enumerate(selected_for_context_topk):
print(f"--- Top-K Document {i+1} ---")
print(f"Content: {doc.page_content[:100]}...")
print(f"Metadata: {doc.metadata}")
4.3 增强相关性:重排序 (Re-ranking)
仅仅依靠向量数据库的相似度得分(通常是双向编码器计算的粗粒度相似度)往往不够精确。重排序器在初步检索的Top-K结果中进行二次精炼,进一步提升真正相关文档的排名。
- 目的: 提高召回率和精确率,确保送入LLM的上下文是最相关的。
- 方法:
- 交叉编码器 (Cross-Encoders): 这是一类更强大的Transformer模型,它们同时接收查询和文档块作为输入,并输出一个单一的相关性分数。由于能够“交叉注意力”处理查询和文档之间的关系,它们通常比用于嵌入生成的双向编码器(如
bge-small)更准确。缺点是计算成本较高,不适合大规模初次检索,但非常适合对少量Top-K结果进行精细排序。- 示例模型:
BAAI/bge-reranker-base,cross-encoder/ms-marco-MiniLM-L-6-v2。
- 示例模型:
- 单向编码器 (Mono-Encoders): 也可以用于重排序,但通常不如交叉编码器。
- 启发式规则/特征融合: 除了语义相关性,还可以结合其他特征进行重排序,例如:
- 关键词密度:文档中查询关键词出现的频率。
- 文档新鲜度:文档发布时间越近,权重越高。
- 来源权威性:来自权威源的文档权重更高。
- 用户反馈:根据历史用户对答案的满意度进行调整。
- 交叉编码器 (Cross-Encoders): 这是一类更强大的Transformer模型,它们同时接收查询和文档块作为输入,并输出一个单一的相关性分数。由于能够“交叉注意力”处理查询和文档之间的关系,它们通常比用于嵌入生成的双向编码器(如
# 示例:使用Sentence-Transformers的交叉编码器进行重排序
from sentence_transformers import CrossEncoder
import numpy as np
# 加载一个交叉编码器模型
# pip install sentence-transformers
reranker_model = CrossEncoder('BAAI/bge-reranker-base') # 或 'cross-encoder/ms-marco-MiniLM-L-6-v2'
# 假设 retrieved_docs_raw 是初步检索到的文档,我们想从其中选出最好的几个
# 为了演示,我们多构造一些文档
all_retrieved_docs = [
Document(page_content="The dog chased the ball with enthusiasm.", metadata={"source": "story_2", "chapter": "1"}), # 最相关
Document(page_content="Dogs are known for their loyalty and companionship.", metadata={"source": "encyclopedia", "category": "animals"}), # 相关
Document(page_content="A playful puppy was running in the park.", metadata={"source": "blog", "category": "pets"}), # 相关
Document(page_content="The cat sat on the mat, purring contentedly.", metadata={"source": "story_1", "chapter": "1"}), # 不相关
Document(page_content="Loyalty is a virtue highly valued in human relationships.", metadata={"source": "philosophy", "category": "ethics"}), # 关键词匹配,但语义不符
]
rerank_query = "What makes dogs good companions?"
# 准备重排序的输入对:(query, document_content)
rerank_pairs = [[rerank_query, doc.page_content] for doc in all_retrieved_docs]
# 获取重排序分数
rerank_scores = reranker_model.predict(rerank_pairs)
# 将分数与文档绑定,并按分数降序排序
reranked_docs_with_scores = sorted(
zip(all_retrieved_docs, rerank_scores),
key=lambda x: x[1],
reverse=True
)
print(f"nRetrieved documents after re-ranking for query '{rerank_query}':")
# 选取Top-3作为重排序后的最终结果
selected_for_context_reranked = [doc for doc, score in reranked_docs_with_scores[:3]]
for i, (doc, score) in enumerate(reranked_docs_with_scores):
print(f"--- Re-ranked Document {i+1} (Score: {score:.4f}) ---")
print(f"Content: {doc.page_content[:100]}...")
print(f"Metadata: {doc.metadata}")
# LangChain的VectorStoreRetriever也可以配置re-ranking
# from langchain.retrievers import ContextualCompressionRetriever
# from langchain.retrievers.document_compressors import CrossEncoderReranker
# compressor = CrossEncoderReranker(model="BAAI/bge-reranker-base", top_n=3)
# compression_retriever = ContextualCompressionRetriever(
# base_compressor=compressor, base_retriever=vectorstore.as_retriever(search_kwargs={"k": 5})
# )
# final_compressed_docs = compression_retriever.get_relevant_documents(rerank_query)
# print(f"nFinal compressed docs via LangChain retriever: {len(final_compressed_docs)}")
4.4 提升多样性与避免冗余:MMR与去重
即使经过重排序,选取的文档块之间仍可能高度相似,导致信息冗余,浪费宝贵的上下文窗口。此时,我们需要策略来提升多样性并消除重复。
- Maximal Marginal Relevance (MMR):
- 目标: 在保持高相关性的同时,最大化结果集的多样性。
- 原理: MMR在每次选择下一个文档块时,会权衡两个因素:它与查询的相似度,以及它与已选文档块集合的“不相似度”。这通过一个参数
lambda_mult来控制,lambda_mult接近1时更侧重相关性,接近0时更侧重多样性。 - 优势: 避免了检索结果中出现大量重复或高度相似的信息,使得上下文包含更丰富、更全面的视角。
# 示例:使用LangChain的MMR检索
# MMR通常是Retriever的一种搜索类型
# vectorstore = Chroma(persist_directory="./chroma_db", embedding_function=embeddings_model)
mmr_query = "Tell me about animals."
print(f"nPerforming MMR search for query: '{mmr_query}'")
# as_retriever() 方法可以配置search_type和search_kwargs
# search_type="mmr" 启用MMR检索
# k: 初步检索的文档数量 (用于多样性计算的池子大小)
# fetch_k: 最终返回的文档数量
# lambda_mult: 0.0 (多样性优先) to 1.0 (相关性优先)
mmr_retriever = vectorstore.as_retriever(
search_type="mmr",
search_kwargs={"k": 5, "fetch_k": 10, "lambda_mult": 0.7} # fetch_k通常大于k
)
mmr_docs = mmr_retriever.get_relevant_documents(mmr_query)
print(f"Retrieved {len(mmr_docs)} documents using MMR:")
for i, doc in enumerate(mmr_docs):
print(f"--- MMR Document {i+1} ---")
print(f"Content: {doc.page_content[:100]}...")
print(f"Metadata: {doc.metadata}")
- 去重 (Deduplication):
- 目标: 识别并移除高度相似或完全重复的文档块。这在处理多源数据或分块策略可能产生冗余时特别有用。
- 方法:
- 基于哈希: 对文档内容(或其规范化形式)计算哈希值,移除哈希值相同的块。
- 基于MinHash/LSH: 适用于大规模数据集,通过近似哈希快速识别相似项。
- 基于嵌入向量: 计算文档块嵌入向量之间的余弦相似度,如果相似度超过某个阈值,则认为是重复并移除。
# 示例:基于嵌入向量的简单去重
def deduplicate_documents(docs: list[Document], embeddings, threshold: float = 0.95) -> list[Document]:
if not docs:
return []
unique_docs = []
unique_embeddings = []
for doc in docs:
doc_embedding = embeddings.embed_query(doc.page_content)
is_duplicate = False
for existing_embedding in unique_embeddings:
# 计算余弦相似度
similarity = np.dot(doc_embedding, existing_embedding) / (np.linalg.norm(doc_embedding) * np.linalg.norm(existing_embedding))
if similarity > threshold:
is_duplicate = True
break
if not is_duplicate:
unique_docs.append(doc)
unique_embeddings.append(doc_embedding)
return unique_docs
# 假设 selected_for_context_reranked 包含一些可能重复的文档
# 为了演示,我们构造一个包含重复的列表
docs_with_potential_duplicates = [
Document(page_content="The cat sat on the mat.", metadata={"id": 1}),
Document(page_content="A feline rested on the rug.", metadata={"id": 2}), # 与1相似
Document(page_content="The dog chased the ball.", metadata={"id": 3}),
Document(page_content="The cat sat on the mat.", metadata={"id": 4}), # 与1完全重复
]
print("nDocuments before deduplication:")
for doc in docs_with_potential_duplicates:
print(f"- {doc.page_content[:50]}... (ID: {doc.metadata['id']})")
deduplicated_docs = deduplicate_documents(docs_with_potential_duplicates, embeddings_model, threshold=0.9)
print("nDocuments after deduplication:")
for doc in deduplicated_docs:
print(f"- {doc.page_content[:50]}... (ID: {doc.metadata['id']})")
4.5 智能压缩与精炼:适应上下文窗口
当即便经过重排序和去重,检索到的文档块仍然过多或过长,无法完全适应LLM的上下文窗口时,我们需要更高级的压缩和精炼技术。
- 上下文压缩 (Contextual Compression):
- 原理: 使用一个小型LLM(如
gpt-3.5-turbo或更小的开源模型)或专门设计的模型来总结或提取检索到的文档块中的关键信息。 - 目标: 在不损失关键语义信息的前提下,大幅减少Token数量,同时保留核心事实和论点。
- 实现: LangChain提供了
ContextualCompressionRetriever,可以结合LLMChainExtractor或LLMContextualCompressor作为压缩器。LLMChainExtractor会根据一个指令从每个文档中提取相关语句,而LLMContextualCompressor则会生成一个摘要。
- 原理: 使用一个小型LLM(如
# 示例:使用LLM进行上下文压缩 (概念性代码,需要OpenAI或HuggingFace LLM)
from langchain_openai import ChatOpenAI
from langchain.retrievers import ContextualCompressionRetriever
from langchain.retrievers.document_compressors import LLMChainExtractor, LLMContextualCompressor
# 假设我们已经有了一个LLM实例
# llm = ChatOpenAI(temperature=0, model="gpt-3.5-turbo", api_key=os.getenv("OPENAI_API_KEY"))
# 假设我们有一个“很长的”检索结果列表
long_retrieved_docs = [
Document(page_content="The history of artificial intelligence dates back to the 1950s when pioneers like Alan Turing explored the idea of machine intelligence. Early AI research focused on problem-solving and symbolic methods, leading to expert systems in the 1970s and 80s. However, limitations in processing power and data availability led to an 'AI winter'. The field saw a resurgence in the 21st century with the advent of machine learning, particularly deep learning, driven by larger datasets and more powerful computing. Today, AI encompasses areas like natural language processing, computer vision, and robotics, with applications ranging from self-driving cars to medical diagnosis. The ethical implications of AI are also a growing concern.", metadata={"source": "AI_history.txt"}),
Document(page_content="Natural Language Processing (NLP) is a subfield of AI that focuses on enabling computers to understand, interpret, and generate human language. Key techniques include tokenization, parsing, named entity recognition, and sentiment analysis. Recent breakthroughs in deep learning, especially with transformer models like BERT and GPT, have revolutionized NLP, leading to highly effective language translation, chatbots, and text summarization tools. Challenges remain in handling ambiguity, context, and cross-cultural nuances.", metadata={"source": "NLP_overview.txt"}),
# ... 更多长文档
]
# 为了演示,我们模拟一个压缩器,实际中会调用LLM
class MockLLMContextualCompressor:
def compress_documents(self, documents: list[Document], query: str) -> list[Document]:
compressed_docs = []
for doc in documents:
# 模拟LLM压缩,这里只是简单截断
summary = f"Summary related to '{query}': {doc.page_content[:150]}..."
compressed_docs.append(Document(page_content=summary, metadata=doc.metadata))
return compressed_docs
# compressor = LLMChainExtractor(llm=llm) # 实际使用LLM
compressor = MockLLMContextualCompressor() # 演示用Mock
compression_retriever = ContextualCompressionRetriever(
base_compressor=compressor,
base_retriever=vectorstore.as_retriever(search_kwargs={"k": 5}) # 先检索5个文档
)
compress_query = "What is the history of AI and its current applications?"
# compressed_docs = compression_retriever.get_relevant_documents(compress_query)
# 模拟直接调用Mock Compressor
print(f"nCompressing documents for query: '{compress_query}'")
# 假设我们从某个地方获取了原始的5个相关文档
initial_relevant_docs = long_retrieved_docs[:2] # 假设这是从向量DB检索到的
compressed_docs = compressor.compress_documents(initial_relevant_docs, compress_query)
print(f"Compressed {len(initial_relevant_docs)} documents into {len(compressed_docs)} summaries:")
for i, doc in enumerate(compressed_docs):
print(f"--- Compressed Document {i+1} ---")
print(f"Content: {doc.page_content[:150]}...")
print(f"Metadata: {doc.metadata}")
-
基于摘要的RAG (Summarization-based RAG):
- 原理: 对于每个检索到的文档块,不直接将其内容传递给LLM,而是先使用一个(通常较小的)LLM生成一个摘要。然后将这些摘要作为上下文传递给主LLM。
- 优势: 极大地减少了Token数量,特别适用于文档块非常长但只需核心要点的情况。
- 挑战: 摘要本身可能丢失细节或引入幻觉,需要高质量的摘要模型。
-
滑动窗口与窗口内检索 (Sliding Window & Window-in-Window Retrieval):
- 原理: 当一个检索到的“块”本身很大,但只有其中一小部分与查询高度相关时,可以围绕这个相关的小片段,额外获取其前后有限的上下文(一个“滑动窗口”),而不是整个大块。或者,先检索大的父块,再在这个父块内部进行更精细的检索,找到最相关的子片段。
- 优势: 在保持局部上下文完整性的同时,避免了传输整个大块的开销。
4.6 组织与格式化:构建最终的LLM提示
经过层层筛选、重排、压缩和去重后,我们得到了一组最相关、最精炼的文档块。最后一步是将其与用户查询结合,以LLM能够理解并有效利用的格式构建最终的提示(prompt)。
典型的提示结构:
[SYSTEM_PROMPT]
你是一个专业的问答助手,请严格根据提供的上下文信息来回答用户的问题。如果上下文中没有足够的信息,请告知用户。不要编造信息。
[RETRIEVED_CONTEXT]
<document source="AI_history.txt">
Summary related to 'What is the history of AI and its current applications?': The history of artificial intelligence dates back to the 1950s when pioneers like Alan Turing explored the idea of machine intelligence. Early AI research focused on problem-solving and symbolic methods, leading to expert systems in the 1970s and 80s... Today, AI encompasses areas like natural language processing, computer vision, and robotics, with applications ranging from self-driving cars to medical diagnosis.
</document>
<document source="NLP_overview.txt">
Summary related to 'What is the history of AI and its current applications?': Natural Language Processing (NLP) is a subfield of AI that focuses on enabling computers to understand, interpret, and generate human language. Key techniques include tokenization, parsing, named entity recognition, and sentiment analysis. Recent breakthroughs in deep learning, especially with transformer models like BERT and GPT, have revolutionized NLP...
</document>
[USER_QUERY]
用户问题: What is the history of AI and its current applications?
代码示例:构建最终的RAG提示
from langchain_core.prompts import ChatPromptTemplate
# 假设这是我们最终筛选、重排、压缩后的文档块列表
final_context_docs = compressed_docs # 或者 selected_for_context_reranked, mmr_docs, etc.
final_user_query = compress_query
# 1. 定义系统提示
system_template = """
你是一个专业的问答助手,请严格根据提供的上下文信息来回答用户的问题。
如果上下文中没有足够的信息,请告知用户。不要编造信息。
"""
# 2. 格式化检索到的上下文
# 遍历每个文档,将内容和元数据格式化成易于LLM理解的字符串
formatted_context = []
for i, doc in enumerate(final_context_docs):
# 可以选择性地包含元数据,例如文档来源
source_info = f" (Source: {doc.metadata.get('source', 'Unknown')})" if doc.metadata.get('source') else ""
formatted_context.append(f"<document id='{i+1}'{source_info}>n{doc.page_content}n</document>")
context_str = "nn".join(formatted_context)
# 3. 构建完整的提示
# 使用LangChain的ChatPromptTemplate方便地构建消息列表
prompt_template = ChatPromptTemplate.from_messages(
[
("system", system_template + "nn上下文信息:n{context}"),
("human", "用户问题: {question}"),
]
)
# 组合最终的提示
final_prompt = prompt_template.format_messages(
context=context_str,
question=final_user_query
)
print("n--- Final LLM Prompt ---")
for message in final_prompt:
print(f"[{message.type.upper()}]: {message.content[:500]}...") # 打印部分内容,避免过长
# 最终,这个 'final_prompt' 列表会被传递给LLM进行推理
# response = llm.invoke(final_prompt)
# print("n--- LLM Response ---")
# print(response.content)
4.7 动态上下文管理
在多轮对话或复杂任务中,上下文管理变得更加复杂。历史对话内容、新检索到的信息以及LLM的记忆能力都需要被考虑。
- 迭代RAG (Iterative RAG): 在生成答案的过程中,LLM可能会发现当前上下文不足,从而触发新一轮的检索。
- 多跳RAG (Multi-hop RAG): 对于需要多步推理的问题,系统可能需要进行多次检索,每次检索的结果作为下一次检索或推理的输入。
- “假设问答” (Hypothetical Questions/HyDE): LLM首先基于查询生成一个“假设性答案”,然后将这个假设性答案作为新的查询来检索文档。这种方法能更好地捕捉查询的深层语义,因为假设性答案通常更丰富、更接近真实文档的语言风格。
第五章:生成阶段:LLM的智能应答 (简述)
当LLM接收到包含用户查询和精心准备的上下文的提示后,它进入生成阶段。在这个阶段,LLM会基于其内部知识和提供的外部上下文进行推理、综合和生成答案。
- 推理与综合: LLM会分析查询与上下文之间的关系,从上下文中提取相关事实,并进行逻辑推理以构建答案。
- 避免幻觉: 严格的系统提示(如“请严格根据提供的上下文信息来回答”)有助于引导LLM优先使用外部上下文,从而减少幻觉。
- 答案格式化: LLM会以自然语言的形式生成答案,可以根据提示要求生成简洁的回答、详细的解释、列表或代码片段等。
RAG的成功很大程度上取决于LLM如何有效地利用所提供的上下文。一个高质量的上下文能够显著提升LLM生成答案的准确性、相关性和信息量。
第六章:高级RAG范式与挑战
RAG领域仍在快速发展,涌现出许多高级范式,旨在进一步提升系统的性能和鲁棒性。
6.1 高级RAG范式
- RAG-Fusion (Reciprocal Rank Fusion): 结合多种检索结果,例如关键词检索和向量检索的结果。通过Reciprocal Rank Fusion算法将不同检索器的排名结果融合,生成一个更鲁棒的最终排名,从而提高召回率和整体性能。
- Self-RAG: 这是一个更具自主性的RAG范式。LLM在生成过程中,会动态地决定何时需要检索、检索什么类型的信息,以及如何评估和利用检索结果。它通过引入特殊的Token(如
[Retrieve],[Relevant],[Irrelevant])来指导LLM的检索和生成行为。 - CRAG (Corrective RAG): 引入外部评估机制来纠正检索结果。当检索结果质量不佳时,CRAG可以触发更正机制,例如重新检索、调整查询,甚至直接生成一个“无法回答”的回复。
- Agentic RAG: 将LLM作为智能代理(Agent),赋予其规划能力和工具使用能力。LLM Agent可以根据任务需求,自主决定何时调用检索工具、何时调用其他外部API(如计算器、数据库查询工具),并整合这些工具的结果来完成更复杂的任务。
6.2 RAG面临的挑战
尽管RAG带来了显著进步,但它并非没有挑战:
- 召回率 (Recall) 与精确率 (Precision) 的平衡: 如何在找到所有相关信息(高召回)和只找到相关信息(高精确)之间取得最佳平衡是一个持续的难题。过高的召回可能引入噪声,过高的精确可能遗漏关键信息。
- 分块粒度优化: 找到最佳的分块策略是一个经验性过程,它高度依赖于数据类型、领域和具体任务。过大或过小的块都会影响检索效果。
- 嵌入模型选择与领域适应: 不同嵌入模型对不同领域、不同语言的性能差异巨大。为特定领域选择或微调最佳嵌入模型是关键。
- 幻觉与事实错误: 即使有RAG,LLM仍可能误解上下文,或者在生成过程中“编造”不存在的事实,这需要更精细的提示工程和后处理。
- 长上下文处理: 尽管RAG缓解了部分问题,但超长文档的检索和上下文组织仍然是一个挑战。LLM在处理极长上下文时效率和准确性依然有下降的风险。
- 实时性与成本: 大规模RAG系统的索引、检索和LLM推理都需要计算资源。如何优化系统架构以实现低延迟和高吞吐量,同时控制成本,是实际部署中的重要考量。
- 评估指标: 如何全面、客观地评估RAG系统的性能(例如,使用RAGAS框架评估Faithfulness, Answer Relevance, Context Recall, Context Precision等)是一个复杂的问题。
第七章:实践经验与优化策略
构建一个健壮高效的RAG系统,除了理解理论,更需要丰富的实践经验。
- 多源异构数据整合: 实际应用中的知识库往往来自多种格式和来源。需要统一的数据管道来处理这些数据,并为其分配合适的元数据。
- A/B测试与评估指标: 持续地对RAG系统的不同组件(如分块策略、嵌入模型、重排序器、提示模板)进行A/B测试。使用RAGAS等专业评估框架,结合人工标注,客观衡量系统的改进。
- 持续学习与更新: 知识库不是静态的。需要建立自动化的管道,定期更新索引,以确保RAG系统能够访问最新的信息。
- 用户反馈回路: 收集用户对RAG生成答案的反馈,用于迭代改进系统。这可以是隐式的(如用户是否点击了答案中的链接)或显式的(如“这个答案有用吗?”)。
RAG的精髓在于其对“智慧选择”的追求。从海量数据中精准筛选、精炼,并以最优方式呈现给LLM,这不仅是一系列工程挑战,更是对信息价值、上下文理解和AI协作能力深刻洞察的体现。随着LLM技术和检索技术的不断演进,RAG无疑将继续成为构建更可靠、更智能AI应用的核心驱动力。它的未来,在于更加智能的检索代理、更细粒度的上下文控制、以及更强大的自适应和自学习能力,最终为我们带来一个真正能够理解并利用全球知识的AI新时代。