RAG 入门实战:用 LangChain 从零搭文档问答(附可跑代码)

AI教程23小时前更新 程序员阿超
332 0 0

一、背景:大模型为什么需要 RAG

大模型有两个硬伤,靠换更大的模型也解决不了:

  1. 知识截止:模型只知道训练时见过的东西。上周发的论文、公司内部文档、昨天的销售数据,它一概不知,问就是编(幻觉)。
  2. 更新贵:为了一批新文档去微调模型,又贵又慢还容易灾难性遗忘。

检索增强生成(RAG)的思路朴素而有效:问问题时,先从你自己的知识库里检索出相关文档片段,把它们塞进提示词,再让模型”照着材料答”。答案有出处、可追溯,加新文档只需重新索引,不用动模型。这就是 2026 年企业 AI 落地最主流架构的原因:准确、可更新、可审计、便宜。

教程用 LangChain 生态从零搭一条完整文档问答管线:加载 → 切分 → 嵌入建库 → 检索 → 生成,并讲透每个环节的调参直觉,最后给出重排与 RAGAS 评测的生产化路线。

二、原理:RAG 五步到底在干什么

离线(建库):
PDF/网页/表格 → Load(文档对象) → Chunk(512+overlap) → Embed(向量)
                                                    → 存入向量库(Chroma)

在线(问答):
用户问题 → Embed(同一模型) → 向量库找 Top-k 最相似chunk
        → 拼成 context + 提示词模板 → LLM 生成 grounded 答案

2.1 为什么这样work

Embedding 模型把文本映射成向量,语义相近的文本在向量空间里距离近。检索就是”找离问题向量最近的 k 个文档向量”(余弦相似度)。LLM 拿到这些片段后做的是它最擅长的事:阅读理解 + 组织语言,而不是凭记忆编造。

2.2 每个环节的直觉

  • Load:LangChain 的 Loader 把各种格式统一成 Document(page_content, metadata),metadata(页码、来源)是答案引用的基础。
  • Chunk(最被低估的一环):块太大混入无关文字干扰检索,太小则语义破碎答不了。512 tokens + 50 overlap 是经验起点,递归 splitter 优先在段落/句 boundary 处切。
  • Embedtext-embedding-3-small 这类小 embedding 性价比最高;检索质量七成看 embedding 和切分,三成看 LLM。
  • Retrievek=4 是平衡点——context 太少答不全,太多(长 context)模型会”迷失在中间”且 token 烧钱。
  • Generate:提示词必须写死”只能依据 context 回答,不知道就说不知道”,否则 RAG 白搭,模型照样编。

三、环境准备

pip install langchain langchain-community langchain-openai chromadb pypdf

需要一个 OpenAI 兼容 API(OpenAI / DeepSeek / 通义等均可,代码用环境变量切换,embedding 与 chat 模型可分开配):

export LLM_API_KEY="你的key"
export LLM_BASE_URL="https://api.openai.com/v1"
export LLM_MODEL="gpt-4o-mini"
export EMB_MODEL="text-embedding-3-small"

准备一份测试 PDF(公司手册、论文、政策文件都行),命名为 doc.pdf 放当前目录。没有现成 PDF?用 Word 导出一页也行,但至少准备 20 页以上,否则检索太 trivial、体会不到调参差异。

四、分步实战

4.1 步骤一:加载与解析文档

# step1_load.py
from langchain_community.document_loaders import PyPDFLoader

loader = PyPDFLoader("doc.pdf")
documents = loader.load()
print(f"加载 {len(documents)} 页")
print("第一页前200字:", documents[0].page_content[:200])
print("元数据示例:", documents[0].metadata)
# metadata 里有 source 与 page,后面做"答案引用第几页"全靠它

Loader 一览(接口都是 .load(),换数据源只换类):

from langchain_community.document_loaders import (
    PyPDFLoader,      # PDF
    WebBaseLoader,    # 网页
    TextLoader,       # 纯文本
    CSVLoader,        # 表格
    UnstructuredWordDocumentLoader,  # Word
)

注意:PyPDFLoader 一页是一个 Document;扫描版 PDF 没有文本层,读出来是空串——这种情况先走 OCR(或直接看本系列的像素级 RAG 教程)。

4.2 步骤二:切分(chunk 策略讲透)

# step2_chunk.py
from langchain_community.document_loaders import PyPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter

documents = PyPDFLoader("doc.pdf").load()

splitter = RecursiveCharacterTextSplitter(
    chunk_size=512,
    chunk_overlap=50,
    separators=["\n\n", "\n", ". ", " ", ""],
)
chunks = splitter.split_documents(documents)
print(f"切出 {len(chunks)} 个块")
print("块0长度:", len(chunks[0].page_content))
print(chunks[0].page_content[:300])

递归 splitter 的工作方式:先试 \n\n(段落)切得下吗?切不下再试 \n、句号、空格,最后才硬切。chunk_overlap=50 让相邻块有 50 字符重叠,跨块的句子不断裂。

调参指南:

文档类型 chunk_size overlap 说明
通用问答起点 512 50 本教程默认
长篇报告/论文 800~1000 100~150 论述完整优先
FAQ/短条目 256~300 30 条目级精确命中
表格密集 按行/按表切 别用字符切,用结构切

验证切分质量的方法:随机抽 10 个块人工读一遍——块内是否语义完整、有没有半句话、有没有把表格拦腰斩断。切分是 RAG 里 ROI 最高的优化点。

4.3 步骤三:嵌入与建库(Chroma 持久化)

# step3_index.py
import os
from langchain_community.document_loaders import PyPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma

documents = PyPDFLoader("doc.pdf").load()
chunks = RecursiveCharacterTextSplitter(
    chunk_size=512, chunk_overlap=50).split_documents(documents)

embeddings = OpenAIEmbeddings(
    model=os.environ.get("EMB_MODEL", "text-embedding-3-small"),
    api_key=os.environ["LLM_API_KEY"],
    base_url=os.environ.get("LLM_BASE_URL"))

vectorstore = Chroma.from_documents(
    documents=chunks,
    embedding=embeddings,
    persist_directory="./chroma_db",
)
print(f"已索引 {len(chunks)} 个块到 ./chroma_db")

要点:embedding 模型一旦选定就不要轻易换——换模型=全库重算向量;persist_directory 落盘后下次直接 Chroma(persist_directory=..., embedding_function=...) 加载,不用重建;生产环境换 Pinecone/pgvector 只换这一行,上层检索代码不动,这就是 LangChain 抽象的价值。

4.4 步骤四:检索(Top-k 与调试)

# step4_retrieve.py
import os
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma

embeddings = OpenAIEmbeddings(model=os.environ.get("EMB_MODEL", "text-embedding-3-small"),
                              api_key=os.environ["LLM_API_KEY"],
                              base_url=os.environ.get("LLM_BASE_URL"))
vectorstore = Chroma(persist_directory="./chroma_db",
                     embedding_function=embeddings)

retriever = vectorstore.as_retriever(search_type="similarity",
                                     search_kwargs={"k": 4})

query = "这份文档的主要结论是什么?"
hits = retriever.invoke(query)
for i, h in enumerate(hits, 1):
    print(f"---命中{i}(页{h.metadata.get('page')})---")
    print(h.page_content[:250], "\n")

检索调试三板斧:

  1. 看命中的块人眼是否相关——不相关先调切分/k,再怀疑 embedding;
  2. MMR 检索search_type="mmr"):结果同质化严重时用,兼顾相关性与多样性;
  3. 带分数检索similarity_search_with_score):分数普遍偏低说明 query 与文档表述 gap 大,考虑 query 改写。

4.5 步骤五:生成 grounded 答案(提示词是安全带)

# step5_generate.py
import os
from langchain_openai import ChatOpenAI
from langchain.prompts import ChatPromptTemplate

llm = ChatOpenAI(model=os.environ.get("LLM_MODEL", "gpt-4o-mini"),
                 temperature=0,
                 api_key=os.environ["LLM_API_KEY"],
                 base_url=os.environ.get("LLM_BASE_URL"))

prompt_template = ChatPromptTemplate.from_template("""
你是严谨的文档问答助手,只能依据【资料】回答问题。
- 资料不足以回答时,直接说"我没有足够信息回答这个问题",不要编造。
- 回答后注明引用来源(第几页)。
【资料】:
{context}
【问题】:{question}
【回答】:
""")

def rag_query(question: str, retriever, k: int = 4) -> str:
    hits = retriever.invoke(question)
    context = "\n\n---\n\n".join(
        f"[第{h.metadata.get('page', '?')}页] {h.page_content}" for h in hits)
    chain = prompt_template | llm
    resp = chain.invoke({"context": context, "question": question})
    return resp.content

temperature=0 是问答场景的铁律:要的是稳定复述材料,不是发散创作。

4.6 步骤六:完整可跑主程序

# rag_app.py —— 五步合一,开箱即问
import os
from langchain_community.document_loaders import PyPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_community.vectorstores import Chroma
from langchain.prompts import ChatPromptTemplate

PDF, DB = "doc.pdf", "./chroma_db"
EMB = os.environ.get("EMB_MODEL", "text-embedding-3-small")
GEN = os.environ.get("LLM_MODEL", "gpt-4o-mini")

def build_index():
    docs = PyPDFLoader(PDF).load()
    chunks = RecursiveCharacterTextSplitter(
        chunk_size=512, chunk_overlap=50).split_documents(docs)
    emb = OpenAIEmbeddings(model=EMB, api_key=os.environ["LLM_API_KEY"],
                           base_url=os.environ.get("LLM_BASE_URL"))
    Chroma.from_documents(chunks, emb, persist_directory=DB)
    print(f"索引构建完成:{len(chunks)} chunks")

def get_chain():
    emb = OpenAIEmbeddings(model=EMB, api_key=os.environ["LLM_API_KEY"],
                           base_url=os.environ.get("LLM_BASE_URL"))
    vs = Chroma(persist_directory=DB, embedding_function=emb)
    retriever = vs.as_retriever(search_kwargs={"k": 4})
    llm = ChatOpenAI(model=GEN, temperature=0,
                     api_key=os.environ["LLM_API_KEY"],
                     base_url=os.environ.get("LLM_BASE_URL"))
    prompt = ChatPromptTemplate.from_template(
        "只能依据【资料】回答,不足时说不知道,并注明页码。\n"
        "【资料】:{context}\n【问题】:{question}\n【回答】:")
    def ask(q: str) -> str:
        hits = retriever.invoke(q)
        ctx = "\n\n---\n\n".join(
            f"[第{h.metadata.get('page', '?')}页] {h.page_content}"
            for h in hits)
        return (prompt | llm).invoke(
            {"context": ctx, "question": q}).content
    return ask

if __name__ == "__main__":
    import sys
    if "--build" in sys.argv or not os.path.exists(DB):
        build_index()
    ask = get_chain()
    print("文档问答已就绪,输入问题(quit退出):")
    while True:
        q = input("> ").strip()
        if q.lower() in ("quit", "exit", "q"):
            break
        print(ask(q), "\n")

用法:python rag_app.py --build 建库一次,之后直接 python rag_app.py 提问。

4.7 步骤七:生产化两件套——重排与评测

重排(re-rank):向量检索快但糙,cross-encoder 把 query 和每个候选块拼在一起精读打分,精度高一个档次。代价是慢,所以只对 Top-20 重排取 Top-2:

# rerank.py
from langchain.retrievers import ContextualCompressionRetriever
from langchain.retrievers.document_compressors import CrossEncoderReranker
from langchain_community.cross_encoders import HuggingFaceCrossEncoder

reranker = CrossEncoderReranker(
    model=HuggingFaceCrossEncoder(
        model_name="cross-encoder/ms-marco-MiniLM-L-6-v2"),
    top_n=2,
)
compression_retriever = ContextualCompressionRetriever(
    base_compressor=reranker,
    base_retriever=retriever,   # 先放宽到 k=20,再压缩到 2
)

评测(RAGAS):准备 30~50 个(问题, 参考答案)对,跑三个核心指标:

  • faithfulness:答案是否被 context 支持(抓幻觉);
  • answer_relevancy:答案是否真在回答问题;
  • context_precision:检索出的块是否真相关(定位检索 vs 生成的责任)。
pip install ragas datasets

评测驱动迭代:faithfulness 低→收紧提示词/降 temperature;context_precision 低→调切分、k、embedding 或加 rerank;relevancy 低→检查 query 改写。

五、常见坑

  1. 扫描版 PDF 直接喂:读出来是空串,库建了等于没建。先抽查 page_content 非空率,空就走 OCR。
  2. chunk_size 照抄不验证:FAQ 用 512 会把多条问答搅在一个块里,检索全是噪音。按文档类型选参数并抽查块质量。
  3. k 越大越好:k=10 的长 context 又贵又容易让模型抓不住重点。先 k=4,不够再加 rerank,而不是无脑加 k。
  4. 提示词没写”不知道就说不知道”:没有这句安全带,RAG 退化成”带着参考资料编”,幻觉照旧。
  5. temperature 没设 0:问答要确定性,默认 temperature=0.7 会让同一问题每次答案不同,评测也复现不了。
  6. embedding 模型中途换:向量空间变了,旧库全废。定下来就写进配置并版本化,换模型=重建索引+回归评测。
  7. 不做引用:答案不标页码/来源,业务方无法核验,RAG 的”可审计”优势荡然无存。metadata 页码从第一天就要透传。
  8. 跳过评测直接上线:没有 RAGAS 基线,改切分、换模型全凭感觉。30 条评测集半天写完,是后续一切优化的地基。

六、总结

  • RAG = 检索(找材料)+ 生成(照材料答),五步 Load→Chunk→Embed→Retrieve→Generate。
  • 切分与检索决定下限,提示词与 temperature 决定稳定性,重排与评测决定生产质量。
  • 今天就能跑:rag_app.py 建库提问,先让第一版转起来,再用评测集驱动迭代。
  • 下一步:HyDE/query 改写、混合检索(BM25+向量)、本系列的像素级 RAG(富版式文档)与 LLM 可观测性(线上质量监控),拼成完整生产拼图。

参考资料:SuperML RAG 入门教程与 LangChain 官方文档。 点击阅读原文

© 版权声明

相关文章

暂无评论

暂无评论...