AI 工具 | | 约 30 分钟 | 11,697 字

LangChain 实战:构建 RAG 应用

用 LangChain 构建完整的 RAG 管道:文档加载、切分、Embedding、检索、生成

什么是 RAG

RAG(Retrieval-Augmented Generation,检索增强生成)是目前最实用的 AI 应用模式之一。它的核心思想很简单:先从你的文档中检索相关内容,再把这些内容作为上下文交给 LLM 生成回答。

为什么需要 RAG?因为 LLM 有两个天然的局限:

  1. 知识截止日期——它不知道训练数据之后发生的事
  2. 没有你的私有数据——它不知道你公司的文档、代码库、知识库里有什么

RAG 解决了这两个问题。你可以把任何文档喂给 RAG 系统,然后基于这些文档进行问答。

RAG 的工作流程

文档 → 切分 → Embedding → 存入向量数据库

用户提问 → Embedding → 向量检索 → 获取相关文档片段

                    相关文档 + 用户问题 → LLM → 回答

整个流程分为两个阶段:

阶段步骤说明
索引阶段加载文档读取各种格式的文档
切分文档将长文档切成小块
生成 Embedding将文本转换为向量
存入向量库存储向量以供检索
查询阶段用户提问接收用户的问题
向量检索找到最相关的文档片段
生成回答LLM 基于检索结果回答

环境准备

安装依赖

pip install langchain langchain-openai langchain-community
pip install chromadb          # 向量数据库
pip install pypdf             # PDF 文档加载
pip install tiktoken          # Token 计数
pip install unstructured      # 通用文档加载

配置

import os
os.environ["OPENAI_API_KEY"] = "your-api-key"

第一步:文档加载

LangChain 提供了丰富的文档加载器(Document Loaders)。

加载 PDF

from langchain_community.document_loaders import PyPDFLoader

loader = PyPDFLoader("docs/technical-spec.pdf")
documents = loader.load()

print(f"加载了 {len(documents)} 页")
print(f"第一页内容预览: {documents[0].page_content[:200]}")
print(f"元数据: {documents[0].metadata}")
# {'source': 'docs/technical-spec.pdf', 'page': 0}

加载 Markdown

from langchain_community.document_loaders import UnstructuredMarkdownLoader

loader = UnstructuredMarkdownLoader("docs/README.md")
documents = loader.load()

加载网页

from langchain_community.document_loaders import WebBaseLoader

loader = WebBaseLoader("https://docs.python.org/3/tutorial/classes.html")
documents = loader.load()

加载整个目录

from langchain_community.document_loaders import DirectoryLoader

loader = DirectoryLoader(
    "docs/",
    glob="**/*.md",
    show_progress=True
)
documents = loader.load()
print(f"加载了 {len(documents)} 个文档")

常用加载器一览

加载器支持格式
PyPDFLoaderPDFpypdf
TextLoaderTXT内置
CSVLoaderCSV内置
UnstructuredMarkdownLoaderMarkdownunstructured
WebBaseLoader网页beautifulsoup4
GitLoaderGit 仓库gitpython
NotionDirectoryLoaderNotion 导出内置

第二步:文档切分

加载的文档通常很长,需要切分成小块。切分策略直接影响检索质量。

基本切分

from langchain_text_splitters import RecursiveCharacterTextSplitter

text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,       # 每块最大字符数
    chunk_overlap=200,     # 块之间的重叠字符数
    length_function=len,
    separators=["\n\n", "\n", "。", ".", " ", ""]
)

chunks = text_splitter.split_documents(documents)
print(f"切分为 {len(chunks)} 个块")
print(f"第一块: {chunks[0].page_content[:100]}...")

为什么需要 overlap

overlap(重叠)是为了避免重要信息被切断。比如一个段落刚好在切分点被分成两块,有了 overlap,两块都会包含这个段落的内容。

切分策略选择

策略适用场景说明
RecursiveCharacterTextSplitter通用按层级分隔符递归切分
MarkdownHeaderTextSplitterMarkdown按标题层级切分
TokenTextSplitter精确控制按 token 数切分
CodeTextSplitter代码按编程语言语法切分

代码文档的切分

如果你的文档包含代码,使用专门的代码切分器:

from langchain_text_splitters import Language, RecursiveCharacterTextSplitter

python_splitter = RecursiveCharacterTextSplitter.from_language(
    language=Language.PYTHON,
    chunk_size=1000,
    chunk_overlap=100
)

# 切分 Python 代码
code_chunks = python_splitter.split_documents(code_documents)

第三步:生成 Embedding

Embedding 是将文本转换为向量(一组数字),语义相似的文本会有相似的向量。

使用 OpenAI Embedding

from langchain_openai import OpenAIEmbeddings

embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

# 测试 Embedding
test_text = "Python 是一种编程语言"
vector = embeddings.embed_query(test_text)
print(f"向量维度: {len(vector)}")  # 1536
print(f"前 5 个值: {vector[:5]}")

Embedding 模型选择

模型维度价格质量
text-embedding-3-small1536$0.02/1M tokens
text-embedding-3-large3072$0.13/1M tokens更好
text-embedding-ada-0021536$0.10/1M tokens旧版

对于大多数场景,text-embedding-3-small 就够用了,性价比最高。

第四步:存入向量数据库

使用 Chroma

Chroma 是一个轻量级的向量数据库,适合开发和小规模应用:

from langchain_community.vectorstores import Chroma

# 创建向量数据库并存入文档
vectorstore = Chroma.from_documents(
    documents=chunks,
    embedding=embeddings,
    persist_directory="./chroma_db"  # 持久化到磁盘
)

print(f"存入 {vectorstore._collection.count()} 个文档块")

加载已有的向量数据库

# 下次使用时直接加载
vectorstore = Chroma(
    persist_directory="./chroma_db",
    embedding_function=embeddings
)

向量数据库选择

数据库类型适用场景
Chroma嵌入式开发、小规模
FAISS嵌入式大规模、高性能
Pinecone云服务生产环境、托管
Weaviate自托管/云企业级
Qdrant自托管/云高性能、过滤
pgvectorPostgreSQL 扩展已有 PG 的项目

第五步:检索

基本检索

# 创建检索器
retriever = vectorstore.as_retriever(
    search_type="similarity",
    search_kwargs={"k": 4}  # 返回最相关的 4 个文档块
)

# 检索
docs = retriever.invoke("如何配置数据库连接?")
for doc in docs:
    print(f"来源: {doc.metadata.get('source', 'unknown')}")
    print(f"内容: {doc.page_content[:100]}...")
    print("---")

检索策略

策略说明参数
similarity余弦相似度k: 返回数量
mmr最大边际相关性k, fetch_k, lambda_mult
similarity_score_threshold带阈值的相似度k, score_threshold

MMR(Maximal Marginal Relevance)会在相关性和多样性之间取平衡,避免返回内容重复的文档:

retriever = vectorstore.as_retriever(
    search_type="mmr",
    search_kwargs={
        "k": 4,
        "fetch_k": 20,       # 先获取 20 个候选
        "lambda_mult": 0.5   # 多样性权重
    }
)

第六步:生成回答

完整的 RAG Chain

from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

# RAG 提示词模板
prompt = ChatPromptTemplate.from_messages([
    ("system", """你是一个知识库问答助手。
    请根据以下检索到的文档内容回答用户的问题。
    如果文档中没有相关信息,请明确说明你无法从现有文档中找到答案。
    不要编造信息。

    检索到的文档:
    {context}"""),
    ("human", "{question}")
])

# 格式化文档
def format_docs(docs):
    return "\n\n---\n\n".join(
        f"来源: {doc.metadata.get('source', 'unknown')}\n{doc.page_content}"
        for doc in docs
    )

# 构建 RAG Chain
rag_chain = (
    {
        "context": retriever | format_docs,
        "question": RunnablePassthrough()
    }
    | prompt
    | llm
    | StrOutputParser()
)

# 使用
answer = rag_chain.invoke("如何配置数据库连接?")
print(answer)

带来源引用的 RAG

from langchain_core.runnables import RunnableParallel

# 同时返回答案和来源文档
rag_chain_with_sources = RunnableParallel(
    {"context": retriever, "question": RunnablePassthrough()}
).assign(
    answer=lambda x: (
        prompt.format_messages(
            context=format_docs(x["context"]),
            question=x["question"]
        )
        |> llm
        |> StrOutputParser()
    )
)

# 或者更简单的方式
def rag_with_sources(question: str):
    docs = retriever.invoke(question)
    context = format_docs(docs)

    answer = (prompt | llm | StrOutputParser()).invoke({
        "context": context,
        "question": question
    })

    sources = list(set(
        doc.metadata.get("source", "unknown")
        for doc in docs
    ))

    return {
        "answer": answer,
        "sources": sources
    }

result = rag_with_sources("如何配置数据库连接?")
print(f"回答: {result['answer']}")
print(f"来源: {result['sources']}")

完整代码示例

把所有步骤整合在一起:

"""
完整的 RAG 应用示例
从文档加载到问答的完整流程
"""

import os
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_community.document_loaders import DirectoryLoader, PyPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.vectorstores import Chroma
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough

# 配置
os.environ["OPENAI_API_KEY"] = "your-api-key"
DOCS_DIR = "./docs"
DB_DIR = "./chroma_db"

def build_index():
    """构建文档索引(只需运行一次)"""
    # 1. 加载文档
    loader = DirectoryLoader(DOCS_DIR, glob="**/*.pdf", loader_cls=PyPDFLoader)
    documents = loader.load()
    print(f"加载了 {len(documents)} 个文档")

    # 2. 切分
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=1000,
        chunk_overlap=200
    )
    chunks = splitter.split_documents(documents)
    print(f"切分为 {len(chunks)} 个块")

    # 3. 生成 Embedding 并存入向量库
    embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
    vectorstore = Chroma.from_documents(
        documents=chunks,
        embedding=embeddings,
        persist_directory=DB_DIR
    )
    print(f"索引构建完成,共 {vectorstore._collection.count()} 个块")
    return vectorstore

def create_rag_chain(vectorstore):
    """创建 RAG Chain"""
    llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
    retriever = vectorstore.as_retriever(search_kwargs={"k": 4})

    prompt = ChatPromptTemplate.from_messages([
        ("system", """根据以下文档内容回答问题。
        如果文档中没有相关信息,请说明。

        文档内容:
        {context}"""),
        ("human", "{question}")
    ])

    def format_docs(docs):
        return "\n\n".join(doc.page_content for doc in docs)

    chain = (
        {"context": retriever | format_docs, "question": RunnablePassthrough()}
        | prompt
        | llm
        | StrOutputParser()
    )
    return chain

# 使用
if __name__ == "__main__":
    # 首次运行:构建索引
    # vectorstore = build_index()

    # 后续运行:加载已有索引
    embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
    vectorstore = Chroma(
        persist_directory=DB_DIR,
        embedding_function=embeddings
    )

    chain = create_rag_chain(vectorstore)

    # 交互式问答
    while True:
        question = input("\n请输入问题(输入 q 退出): ")
        if question.lower() == 'q':
            break
        answer = chain.invoke(question)
        print(f"\n回答: {answer}")

优化技巧

1. 调整 chunk_size

chunk_size 太小会丢失上下文,太大会引入噪音。一般建议:

  • 技术文档:800-1200 字符
  • 对话记录:400-600 字符
  • 代码文档:按函数/类切分

2. 优化检索

# 使用 Ensemble Retriever 组合多种检索策略
from langchain.retrievers import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever

# 关键词检索
bm25_retriever = BM25Retriever.from_documents(chunks)
bm25_retriever.k = 4

# 向量检索
vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 4})

# 组合
ensemble_retriever = EnsembleRetriever(
    retrievers=[bm25_retriever, vector_retriever],
    weights=[0.4, 0.6]
)

3. 添加重排序

检索后对结果进行重排序,提高相关性:

from langchain.retrievers import ContextualCompressionRetriever
from langchain_community.document_compressors import CrossEncoderReranker
from langchain_community.cross_encoders import HuggingFaceCrossEncoder

model = HuggingFaceCrossEncoder(model_name="cross-encoder/ms-marco-MiniLM-L-6-v2")
compressor = CrossEncoderReranker(model=model, top_n=3)

compression_retriever = ContextualCompressionRetriever(
    base_compressor=compressor,
    base_retriever=retriever
)

4. 处理中文文档

中文文档切分需要注意分隔符的选择:

chinese_splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,
    chunk_overlap=100,
    separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""]
)

常见问题

Q: 检索结果不相关怎么办?

  1. 检查 chunk_size 是否合适
  2. 尝试不同的检索策略(MMR、Ensemble)
  3. 优化提示词,让 LLM 更好地利用上下文
  4. 添加重排序步骤

Q: 回答出现幻觉怎么办?

  1. 在提示词中强调”只根据文档内容回答”
  2. 降低 temperature(设为 0)
  3. 要求 LLM 引用来源
  4. 添加后处理验证步骤

Q: 文档量很大怎么办?

  1. 使用更高效的向量数据库(FAISS、Qdrant)
  2. 实现增量索引
  3. 添加元数据过滤,缩小检索范围
  4. 考虑分层检索(先粗筛再精排)

RAG 不是银弹,但它是目前让 LLM 理解你私有数据最实用的方案。从简单开始,根据实际效果逐步优化。

评论

加载中...

相关文章

分享:

评论

加载中...