LangChain使用RAG 入门:让大模型读懂你的私有文档

29 阅读11分钟

LangChain使用RAG 入门:让大模型读懂你的私有文档

读完这篇文章,你将能用 80 行代码搭建一个简单的 "上传 Word 文档 → AI 基于文档回答" 的知识库问答系统。


先看效果

我有一份《阿里巴巴 Java 开发手册》Word 文档。什么都不告诉大模型,直接问:

"00000 和 A0001 分别是什么意思?"

不用 RAG 的效果(纯大模型瞎猜):

00000 通常是错误码...A0001 可能表示认证失败...

——胡说八道。

用 RAG 的效果:

根据提供的文本内容:
- 00000 表示"方法内部的执行结果""success",通常是操作成功的标志
- A0001 表示"用户端错误",通常是用户输入参数错误导致的异常

——精准,全部来自文档。

这就是 RAG 的核心价值:让大模型基于你的私有文档回答,不瞎编。


RAG 是什么

RAG = Retrieval-Augmented Generation(检索增强生成)。

一句话:给大模型外接一个知识库。 提问时先从知识库里检索相关内容,再让大模型基于检索结果回答。

┌─────────────────────────────────────────────┐
│                    用户提问                    │
│              "00000是什么意思?"               │
└──────────────────┬──────────────────────────┘
                   ▼
┌─────────────────────────────────────────────┐
│  ① 检索(Retrieval)                          │
│  问题转向量 → 向量数据库搜索 → 召回相关文本片段    │
│  找到: "00000表示方法内部执行结果..."            │
└──────────────────┬──────────────────────────┘
                   ▼
┌─────────────────────────────────────────────┐
│  ② 生成(Generation)                         │
│  Prompt = 问题 + 检索结果 → 大模型 → 答案       │
│  答案: "00000表示操作成功的标志..."              │
└─────────────────────────────────────────────┘

RAG 解决的三个痛点:

痛点不用 RAG用 RAG
知识截止日期"这个我不清楚"有文档就能答
私有数据完全不知道基于你的文档回答
幻觉(瞎编)可能编造有原文依据

为什么不能直接关键词搜索?

很多新手会问:"Ctrl+F 不就行了?为什么非要向量?"

看这个例子——你的知识库里有这三句话:

1. 我喜欢吃苹果          ← 苹果 = 水果
2. 苹果是我最喜欢吃的水果  ← 苹果 = 水果
3. 我喜欢用苹果手机       ← 苹果 = 手机品牌

关键词搜索搜"苹果":三条全返回,但第 3 条(手机品牌)和前两条(水果)语义完全不同。

向量搜索搜"水果":返回 1 和 2,第 3 条不会被召回——因为向量能捕获语义,不只是匹配字面。

向量 = 文本的"坐标"。语义越接近,坐标越靠近。这就是"以义搜义,而非以字搜字"。


完整代码:80 行跑通 RAG

import os
from pathlib import Path
from langchain_core.runnables import RunnablePassthrough
from langchain_core.prompts import ChatPromptTemplate
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_redis import RedisConfig, RedisVectorStore
from redisvl.index import SearchIndex
from langchain_community.document_loaders import Docx2txtLoader
from llm import llm, text_embedding

# ========== 配置 ==========
REDIS_URL = os.getenv("REDIS_URI", "redis://localhost:6379")
VECTOR_INDEX_NAME = "alibaba_java"
DOC_FILE_PATH = Path(__file__).parent / "assets" / "alibaba-java.docx"
CHUNK_SIZE = 1000
CHUNK_OVERLAP = 100
RETRIEVE_K = 2

# ========== Prompt 模板 ==========
system_prompt = """
请使用以下提供的文本内容来回答问题。仅使用提供的文本信息,
如果文本中没有相关信息,请回答"抱歉,提供的文本中没有这个信息"。

文本内容:
{context}
"""
prompt = ChatPromptTemplate.from_messages([
    ("system", system_prompt),
    ("human", "{question}")
])

# ========== 1. 加载文档 ==========
loader = Docx2txtLoader(file_path=str(DOC_FILE_PATH))
doc_list = loader.load()
print(f"加载文档:{len(doc_list)} 个片段")

# ========== 2. 文本分片 ==========
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=CHUNK_SIZE, chunk_overlap=CHUNK_OVERLAP, length_function=len
)
split_docs = text_splitter.split_documents(doc_list)
print(f"分片后:{len(split_docs)} 个文本块")

# ========== 3. 清理旧索引 ==========
try:
    SearchIndex.from_existing(name=VECTOR_INDEX_NAME, redis_url=REDIS_URL).delete(drop=True)
except Exception:
    pass

# ========== 4. 创建向量库并写入 ==========
redis_config = RedisConfig(index_name=VECTOR_INDEX_NAME, redis_url=REDIS_URL)
vector_store = RedisVectorStore(text_embedding, config=redis_config)  # 建索引
vector_store.add_documents(split_docs)                                 # 写入文档
# 后期新增文档只需:vector_store.add_documents(new_docs)

# ========== 5. 构建检索器 ==========
retriever = vector_store.as_retriever(search_kwargs={"k": RETRIEVE_K})
# retriever 内部自动将问题转向量 → Redis 相似度搜索 → 返回最相关文本块

# ========== 6. 组装 RAG 链路 ==========
rag_chain = (
    {"context": retriever, "question": RunnablePassthrough()}
    | prompt
    | llm
)

# ========== 7. 问答 ==========
if __name__ == "__main__":
    question = "00000和A0001分别是什么意思"
    result = rag_chain.invoke(question)
    print(f"\n【问题】{question}")
    print(f"【答案】{result.content}")

这就跑通了。 下面讲这 80 行代码每一步在干什么。


核心流程拆解

RAG 就 6 步,分两大阶段:

入库阶段(跑一次)

第 1 步:数据加载 — 把文件读进来

loader = Docx2txtLoader(file_path="文档.docx")
doc_list = loader.load()
# → [Document(page_content="..."), Document(page_content="..."), ...]

不管 PDF、Word、TXT,Loader 统一输出 List[Document]。Document 就两个字段:

字段含义
page_content文本正文
metadata来源、页码等标签

📖 Document Loaders 官方文档

第 2 步:文本切分 — 长文档切小块

text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=100)
split_docs = text_splitter.split_documents(doc_list)

两个关键参数:

  • chunk_size=1000:每块最多 1000 个字符
  • chunk_overlap=100:相邻块重叠 100 个字符,防止一句完整的话被切断

📖 Text Splitters 官方文档

第 3 步:向量化 — 文本变成数字

# 文本 → Embedding 模型 → 1024维浮点数向量
vectors = text_embedding.embed_documents(["苹果是水果"])
# → [[0.12, -0.34, 0.56, ...]]  共1024个数字

相似的文本,向量距离近;不相关的文本,向量距离远。这就是"语义搜索"的数学基础。

📖 Embeddings 官方文档

第 4 步:存入向量数据库

# 创建向量库实例(建索引)
vector_store = RedisVectorStore(text_embedding, config=redis_config)
# 写入文档(自动向量化 + 存入 Redis)
vector_store.add_documents(split_docs)
# 后期有新文档直接追加:vector_store.add_documents(new_docs)

拆成两步的好处:add_documents 可以反复调用增量追加,不用每次重建。

📖 Vector Stores 官方文档

检索生成阶段(每次提问)

第 5 步:检索 — 从库里搜相关内容

retriever = vector_store.as_retriever(search_kwargs={"k": 2})
# retriever 内部自动做了:问题转向量 → Redis 相似度搜索 → 返回最相关文本块
# 等价于:embed_query(question) → vector_store.similarity_search(向量, k=2)

📖 Retrievers 官方文档

第 6 步:组装 Prompt + LLM 生成

rag_chain = (
    {"context": retriever, "question": RunnablePassthrough()}
    | prompt
    | llm
)

最终发给大模型的 Prompt 长这样:

请使用以下提供的文本内容来回答问题...

文本内容:
[检索到的文本块1] 00000表示方法内部执行结果,通常是成功标志...
[检索到的文本块2] A0001表示用户端错误,通常是参数错误...

回答: 00000和A0001分别是什么意思?

LLM 基于"看到的资料"回答,不会瞎编。

LCEL 管线怎么读?

新手看到 |RunnablePassthrough 通常会懵。用数据流的方式理解:

rag_chain = (
    {"context": retriever, "question": RunnablePassthrough()}
    | prompt
    | llm
)

等价于:

用户输入: "00000是什么意思?"
         │
         ▼
{context: retriever, question: RunnablePassthrough()}
         │
         ├─ context   ← retriever.invoke("00000是什么意思?")  → 检索结果
         └─ question  ← "00000是什么意思?"(原样透传)
         │
         ▼
prompt   ← 把 context 和 question 填进模板,拼成完整 Prompt
         │
         ▼
llm      ← 大模型生成答案
         │
         ▼
答案: "00000表示操作成功的标志..."
  • | = 管道符,把上一步的输出传给下一步
  • RunnablePassthrough() = 原样透传,不做任何修改
  • {"context": ..., "question": ...} = 并行执行两个任务,结果拼成字典

用 10 个字记住 RAG

加载 → 切分 → 向量化 → 存库 → 检索 → 生成

前四步跑一次入库,后两步每次提问执行。


动手前的准备

1. 启动 Redis Stack:

docker run -d --name redis-stack -p 6379:6379 redis/redis-stack-server:latest

必须是 Redis Stack(带 RediSearch 模块),普通 Redis 不行。

2. 安装依赖:

pip install langchain-core langchain-text-splitters langchain-redis langchain-community redisvl python-docx

3. 配置大模型(llm.py):

# llm.py
from langchain.chat_models import init_chat_model
from langchain.embeddings import init_embeddings

llm = init_chat_model("openai:deepseek-ai/DeepSeek-V3")
text_embedding = init_embeddings(
    "openai:text-embedding-v4",
    dimensions=1024,
    api_key="你的阿里云百炼API_KEY",
    base_url="你的阿里云百炼BASE_URL",
)

常见踩坑

Q1:redis.exceptions.ConnectionError: Connection refused

# 检查 Redis 是否启动
docker ps | grep redis-stack

# 没启动就启动它
docker run -d --name redis-stack -p 6379:6379 redis/redis-stack-server:latest

Q2:跑一次后数据重复了

每次跑 add_documents 都是追加。测试阶段建议每次先清旧索引(代码第 3 步已处理)。

Q3:检索结果不相关

chunk_size:太小语义不完整,太大召回精度下降。从 500~1000 开始试,同时检查 chunk_overlap 不要为 0。

Q4:Embedding 报 InvalidParameter

Embedding 模型只接受 strList[str],别传 Document 对象。embed_documents 用文本,add_documents 用 Document。

Q5:换了文档格式怎么改?

只改第 1 步的 Loader,后面代码不动。速查 → 附录 A。


下一步:让你的 RAG 更好用

掌握基础后,可以进阶的方向:

方向一句话
多轮对话加上 chat history,支持追问
混合检索关键词 + 向量双路召回,精度更高
重排序 (Rerank)召回后二次精排,把最相关的排前面
多模态 RAG图片、表格也能检索
本地模型用 Ollama + BGE 做私有化部署,零 API 费用

附录 A:文档加载器选型速查

格式推荐 Loader一句话理由
.txtTextLoader直接读,最快
.docxDocx2txtLoader轻量够用
.mdTextLoadermd 就是纯文本
.pdfPyPDFLoader纯文本 PDF 首选
.csvCSVLoader按行入库
.jsonJSONLoader + jq按字段提取

复杂 PDF(扫描件、多栏、表格)推荐用 DoclingLoader。


附录 B:三种入库写入方式

方法场景
add_texts(texts, metadatas)手写文本,Demo 用
add_documents(docs)增量追加,99% 生产场景
from_documents(docs, config)一步建库 + 写入

文本转 Document(统一走 Document 链路):

from langchain_core.documents import Document

docs = [Document(page_content="文本内容", metadata={"source": "db"})]
vector_store.add_documents(docs)

附录 C:文本分割器详解

RecursiveCharacterTextSplitter 工作逻辑:

段落太长 → 切成句子 → 句子还长 → 切成词语 → 直到满足 chunk_size
splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,      # 每块最大字符数
    chunk_overlap=100,    # 相邻块重叠字符数
    separators=["\n\n", "\n", "。", ",", " ", ""]  # 分割优先级(默认)
)
场景推荐 chunk_size推荐 overlap
通用 RAG500~100050~100
短问答 FAQ200~5000~50
长文档摘要1000~2000100~200

附录 D:接入你的数据

把第 1 步的 Loader 替换成对应的就行:

# PDF
from langchain_community.document_loaders import PyPDFLoader
loader = PyPDFLoader("手册.pdf")

# Markdown
from langchain_community.document_loaders import TextLoader
loader = TextLoader("笔记.md")

# 网页
from langchain_community.document_loaders import WebBaseLoader
loader = WebBaseLoader("https://example.com/doc")

Loader 换了,后面代码一行不用改——这就是 Document 统一接口的好处。


附录 E:升级为 Agent 交互式问答

前面是"一行代码一个问答"。如果想像聊天机器人一样持续对话,把 RAG 检索包装成 Agent 工具:

import os
from pathlib import Path
from langchain.agents import create_agent
from langchain_core.tools import tool
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_redis import RedisConfig, RedisVectorStore
from redisvl.index import SearchIndex
from langchain_community.document_loaders import Docx2txtLoader
from llm import llm, text_embedding

# ========== 配置 ==========
REDIS_URL = os.getenv("REDIS_URI", "redis://localhost:6379")
VECTOR_INDEX_NAME = "alibaba_java"
DOC_FILE_PATH = Path(__file__).parent / "assets" / "alibaba-java.docx"
CHUNK_SIZE = 1000
CHUNK_OVERLAP = 100
RETRIEVE_K = 2

# ========== 1. 加载文档 ==========
print("=== 加载文档 ===")
loader = Docx2txtLoader(file_path=str(DOC_FILE_PATH))
doc_list = loader.load()
print(f"加载文档:{len(doc_list)} 个片段")

# ========== 2. 文本分片 ==========
print("=== 文本分片 ===")
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=CHUNK_SIZE, chunk_overlap=CHUNK_OVERLAP, length_function=len
)
split_docs = text_splitter.split_documents(doc_list)
print(f"分片后:{len(split_docs)} 个文本块")

# ========== 3. 清理旧索引 + 创建向量库 ==========
print("=== 创建向量库 ===")
try:
    SearchIndex.from_existing(name=VECTOR_INDEX_NAME, redis_url=REDIS_URL).delete(drop=True)
except Exception:
    pass

redis_config = RedisConfig(index_name=VECTOR_INDEX_NAME, redis_url=REDIS_URL)
vector_store = RedisVectorStore(text_embedding, config=redis_config)
vector_store.add_documents(split_docs)
print(f"已写入 {len(split_docs)} 条记录")

# ========== 4. 定义搜索工具 ==========
retriever = vector_store.as_retriever(search_kwargs={"k": RETRIEVE_K})


@tool
def search_document(query: str) -> str:
    """在《阿里巴巴 Java 开发手册》中搜索相关内容。当用户询问编码规范、命名规则、异常处理等 Java 开发相关问题时,调用此工具检索文档。
    :param query: 搜索查询,最好是完整的问题或关键词
    """
    docs = retriever.invoke(query)
    if not docs:
        return "未找到相关内容"
    return "\n\n".join(d.page_content for d in docs)


# ========== 5. 创建 Agent ==========
print("=== 启动 RAG Agent ===\n")

agent = create_agent(
    llm,
    tools=[search_document],
    system_prompt="你是 Java 开发助手。用户的问题如果涉及编码规范、命名、异常处理、日志等,使用 search_document 工具检索《阿里巴巴 Java 开发手册》,然后基于检索结果回答。如果文档中没有,如实告知。",
)

# ========== 6. 交互式问答 ==========
while True:
    try:
        question = input("你:").strip()
        if not question:
            continue
        if question.lower() in ("exit", "quit", "q"):
            print("再见!")
            break

        result = agent.invoke({"messages": [("user", question)]})
        answer = result["messages"][-1].content
        print(f"\n助手:{answer}\n")

    except KeyboardInterrupt:
        print("\n再见!")
        break

和基础版的关键区别:

基础版(33-rag-flow.py)Agent 版(34-rag-agent.py)
问答方式硬编码一个问题,跑完结束交互式循环,输 exit 退出
检索触发LCEL 管道里每次必调Agent 自主判断要不要检索
多轮对话✅ 支持追问,带上下文
闲聊兜底不调工具,直接闲聊回答

总结

  1. RAG = 外接知识库,解决大模型幻觉和私有数据问题
  2. 核心 6 步:加载 → 切分 → 向量化 → 存库 → 检索 → 生成
  3. 80 行代码就能跑通一个完整的知识库问答系统
  4. 换文件格式只换 Loader,下游代码不变
  5. 进一步用 Agent 包装,支持多轮对话和持续交互(见附录 E)