Langchain入门到精通0x06:RAG

7 阅读8分钟

前情

我们知道 LangChain是一个用于构建由 LLM 提供支持的代理和应用程序的框架。而现实AI开发中往往又是LLM + RAG的模式。前面我们也学过原生的 RAG。当然Langchain对RAG流程也做了更便捷、更好用的封装。

Data Connection

Langchain中的 Data Connection模块正是对RAG流程的封装。

我们先简单回顾下RAG整个的流程:

image.png

对应这几个流程,我逐个对照看看Langchain的封装。

文档加载

LangChain有很强的数据加载能力,提供了很多常见的数据格式的支持,例如CSV、文件目录、HTML、JSON、Markdown及PDF等。

加载器Loader

  • TextLoader:TXT文档
  • PyPDFLoader:PDF文档
  • CSVLoaderCSV:文档
  • JSONLoader:JSON文档
  • UnstructuredHTMLLoader:HTML文档:
  • UnStructuredMarkdownLoader:MD文档
  • DirectoryLoader:文件目录

核心方法

  • load方法,用于从指定的数据源读取数据,并将其转换成一个或多个文档。
# 1.指定要加载的Word文档路径
loader = Docx2txtLoader("人事管理流程.docx")

# 加载文档、转换格式化成document
documents = loader.load()

切割器Splitter

# 文档切割 递归切割
# separators
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=500, #切块大小
    chunk_overlap=50,  # 切块重叠大小
    # separators=[".", '\n', '!', '?', ';']
)
# 通过分割器获取document :create_documents   split_documents  传入一个document对象,返回一个document对象列表
split_documents = text_splitter.split_documents(documents)

NativeRAG

splitter = RecursiveCharacterTextSplitter(
    chunk_size=20,  # 分割长度
    chunk_overlap=5,  # 重叠长度 /重叠窗口大小
    separators=["\n\n", "\n", "。", ",", ""],
)
chunks = splitter.split_text(text)

Document对象

image.png

可以看到以上代码基本相同,没什么差别。但是这里要注意:在Langchain中,操作的目标都是Document对象

Document对象是一个轻量级的容器,其核心职责是封装一段具有上下文语义的信息,并为这块信息附加可供机器处理的元数据。是后续一系列AI处理流程(如检索、切割、嵌入、推理)的统一、可操作的基本数据单元

  • page_content(字符串): 文档数据。这是后续LLM直接“阅读”和处理的原材料。
  • metadata(字典): 元数据。在检索后增强溯源、控制切割边界、进行精细化过滤时至关重要。

一个🌰:

Document(
    page_content="猫是柔软可爱的动物,但相对独立",
    metadata={"source": "常见动物宠物文档"},
     )

其他切割器

  • CharacterTextSplitter:基于字符切割
  • MarkdownHeaderTextSplitter:可以根据指定的一组标题来切割一个Markdown 文档
  • HTMLSectionSplitter:基于HTML的段切割
  • LatexTextSplitter:按照 LaTeX 格式的布局元素来拆分文本
  • ...更多详见 官方文档

向量化存储

llm_embeddings = get_ali_embeddings()
# 实例化向量空间,向量化+向量存储到向量数据库中
vector_store = Chroma.from_documents(documents=split_documents,embedding=llm_embeddings)

NativeRAG

# 向量化
def get_embeddings(self, texts, model=ALI_TONGYI_EMBEDDING_V4):
    '''封装 OpenAI 的 Embedding 模型接口'''
    data = self.client.embeddings.create(input=texts, model=model).data
    return [x.embedding for x in data]

# 添加文档与向量
def add_documents(self, instructions, outputs):
    '''向 collection 中添加文档与向量'''
    # 问题进行向量化,答案保持源文档
    embeddings = self.get_embeddings(instructions)

    self.collection.add(
        embeddings=embeddings,  # 每个文档的向量
        documents=outputs,  # 文档的原文
        ids=[f"id{i}" for i in range(len(outputs))]  # 每个文档的 id
    )
    print("self.collection.count():", self.collection.count())

对比

可见,Langchain封装之后代码更简洁了。我们可以更多地关注业务逻辑,而不是底层操作。

检索器retriever

# 1. # "similarity", 默认,向量相似度检索 默认top-k=4
retriever = vector_store.as_retriever()
# 2. 配置top_k
# retriever = vector_store.as_retriever(
#     search_type="similarity",  # 相似度检索
#     search_kwargs={"k": 3}  # 返回前4个最相关文档
# )

# 检索调用
# result = retriever.invoke("晋升")

NativeRAG

# 检索向量数据库
def search(self, query, n_results):
    ''' 检索向量数据库
       query是用户的查询,
       n_results:查出n个相似最高的记录
    '''
    results = self.collection.query(
        query_embeddings=self.get_embeddings([query]),
        n_results=n_results
    )
    return results

对比

同样,Langchain方式不仅更简洁,还提供了更丰富的封装接口以满足更广阔的需求。

相似度阈值检索

similarity_score_threshold:相似度阈值检索

  • 只返回相似度大于score_threshold的结果

我们修改检索器代码:

# # 3. "similarity_score_threshold", 向量相似度阈值检索
# retriever = vector_store.as_retriever(
#     search_type="similarity_score_threshold",
#     search_kwargs={
#         "score_threshold": 0.4,
#     }
# )

📢📢📢:我们这里是只是对比三种检索方式,所以执行的不是最终代码,只是中间测试代码。直接检索向量数据库。

# 中间测试
result = retriever.invoke("晋升")
print(result)
exit()
  • similarity,page_content = 4个 image.png

  • score_threshold = 0.4,page_content = 1个

image.png

  • score_threshold = 0.34,page_content = 2个 image.png

由此可见,score_threshold越高返回的相似块就会越少,也容易导致召回率过低。实际开发中阈值调优往往是最难也最重要的。

  • 阈值过高(如0.8):可能导致召回不足,许多相关文档因未达严苛标准而被遗漏,检索结果可能为空。

  • 阈值过低(如0.1):可能导致召回过多无关噪音,污染大模型的上下文窗口。

  • 建议:通过抽样观察,人工评估不同分数区间(如>0.5, 0.3~0.5, <0.3)下文档的相关性,找到一个在“查全率”和“查准率”之间的平衡点。

其他检索器

  • FAISS 检索器
retriever_faiss = FAISS.from_texts(texts, embeddings).as_retriever()
  • Chroma 检索器
retriever_chroma = Chroma.from_documents(docs, embeddings).as_retriever()
  • 关键词检索器
retriever_bm25 = BM25Retriever.from_texts(texts)
  • 混合检索器 (结合语义+关键词)
ensemble_retriever = EnsembleRetriever(
    retrievers=[retriever_faiss, retriever_bm25],
    weights=[0.7, 0.3]  # 权重分配

MMR

为什么需要?

除了以上两种相似度检索,Langchain还提供了另一种更高级的方式————mmr。那么为什么还会有这种检索方式呢?

试想有这样一个简单的🌰:

  • 用户提问:AI大模型高效运行的关键要素有哪些?
  • 相似度检索(可能结果):《什么是GPU》、《GPU并行计算原理》、《NVIDIA GPU架构详解》——

有没有发现,这三篇都在讲硬件,信息高度冗余。传统相似度检索可能导致检索结果单一、生成答案片面化这一问题。而mmr正是解决这一痛点的,其核心目标在于在确保结果“相关”的前提下,最大限度地引入“新信息”,从而让后续的大模型能基于一组“既相关又互补”的上下文,生成更全面、更具洞见的回答。

是什么?

MMR(Maximal Marginal Relevance),最大边际相关性,其核心是一个排序公式,用于在候选文档池中做迭代式选择:

MMR = argmax [λ * Sim(Q, Di) - (1-λ) * max Sim(Dj, Di)]

简单来说,它每次选择下一个文档时,会权衡两个因素:

  1. 与问题的相关性:新文档Di和你提的问题Q有多像。
  2. 与已选结果的差异性:新文档Di和当前已选出的结果Dj们有多不像。

其中的 λ参数是这个权衡的“调节旋钮”:

  • λ 接近 1:更看重相关性,结果趋近于普通相似性搜索。
  • λ 接近 0:更看重多样性,可能会为了引入新角度而牺牲一点点最相关的文档。
  • 调参经验:从0.5开始,向0.7(更相关)或0.3(更多样)微调

Codding

我们重新构造一个更合适的代码案例。

准备工作
1. 数据构造

# 1. 模拟一个包含多领域AI知识的小型文档库
documents = [
    Document(page_content="GPU,尤其是NVIDIA的系列产品,通过CUDA架构提供大规模并行计算能力,是训练大模型的基石。"),
    Document(page_content="TPU是谷歌专门为神经网络训练设计的张量处理单元,在特定模型上能效比极高。"),
    Document(page_content="注意力机制是Transformer模型的核心,它允许模型在处理序列时动态关注不同部分。"),
    Document(page_content="混合精度训练(FP16/BF16)可显著减少显存占用并提升计算吞吐,是加速训练的关键工程手段。"),
    Document(page_content="模型剪枝和量化是模型压缩的主要技术,用于减少模型大小和推理延迟,便于部署。"),
    Document(page_content="分布式训练框架,如PyTorch DDP和DeepSpeed,解决了单卡显存不足问题,实现了超大规模模型训练。"),
]
2. 数据库构造
# 2. 文本分割与向量化存储
text_splitter = RecursiveCharacterTextSplitter(chunk_size=100, chunk_overlap=20)
split_docs = text_splitter.split_documents(documents)

# 使用本地Chroma向量数据库
vector_store = Chroma.from_documents(
    documents=split_docs,
    embedding=get_ali_embeddings() # 或使用开源Embeddings模型
)

# 3. 定义检索器:重点对比普通检索 vs MMR检索
query = "训练大型AI模型有哪些技术挑战和解决方案?"
3. 相似度检索
# 方法A:普通相似性检索
print("=== 普通相似性检索结果 ==")
retriever_standard = vector_store.as_retriever(
    search_kwargs={"k": 3}
)
standard_docs = retriever_standard.invoke(query)
for i, doc in enumerate(standard_docs):
    print(f"[Doc {i+1}]: {doc.page_content}...") 

print("==" * 60)
mmr
  • k:top_k个,最终返回的文档数量
  • fetch_k:视野广度,fetch_k必须大于 k
    • 算法会先从库中召回fetch_k个(如10个)最相关的候选文档,然后在这10个里用MMR公式精选出k个(3个)最终结果。
  • lambda_mult:MMR公式中的λλ越小越偏重多样性,λ越大越偏重相似性
retriever_mmr = vector_store.as_retriever(
    search_type="mmr", # 关键参数
    search_kwargs={"k": 3, "fetch_k": 10, "lambda_mult": 0.5}
)
running

image.png

可见,mmr确实让检索答案变得更加丰富了。

大模型生成

message = """ 
仅使用提供的上下文回答下面的问题:
{question}
上下文:
{context}
"""
prompt_template = ChatPromptTemplate.from_messages([('human',message)])
# 定义这个链的时候,还不知道问题是什么,
# 用RunnablePassthrough允许我们将用户的具体问题在实际使用过程中进行动态传入
chain = {"question":RunnablePassthrough(),"context":retriever} | prompt_template | client

#用大模型生成答案
resp = chain.invoke("晋升")
print(resp.content)

大差不差,没有什么可对比的。

至此,使用Langchain框架构建RAG的基本流程就完成了。

源代码

github