LangChain RAG PDF 智能问答实战:用 Streamlit 构建本地知识库(完整代码)

文章目录


前言

在前面的学习中,我们已经掌握了 Prompt Template、Output Parser 和 Chain 的基本用法。但之前的案例都是基于模型自身知识进行问答,无法回答涉及私有文档的问题。如何让大模型根据我们自己上传的 PDF 文档回答问题? 这正是 RAG(检索增强生成)要解决的核心问题。

本文基于课堂项目,使用《卢浮宫》PDF 作为知识来源,通过 Streamlit 构建了一个完整的网页问答工具。运行后,用户可以:

  • 输入阿里云百炼 API Key
  • 上传一份 PDF 文件
  • 针对 PDF 内容提出问题
  • 查看模型根据文档生成的回答
  • 保留并查看多轮对话历史

本文适合已经了解 LangChain 基础组件(Prompt、Model、Parser、Chain)的读者,希望通过一个完整项目理解 RAG 的工作原理和实现方式。

最终效果:用户上传 PDF 后,可以像与 ChatGPT 对话一样提问,模型会基于 PDF 内容给出答案,并支持多轮连续对话。

项目效果展示

页面从上到下依次为:

  • PDF 文件上传区域(支持拖拽)
  • 问题输入框(上传 PDF 前禁用)
  • 答案展示区域

完整工程代码

以下为项目完整代码,包含两个核心文件。

1. scripts.py ------ RAG 核心处理函数

python 复制代码
import os

from langchain.chains import ConversationalRetrievalChain
from langchain_community.document_loaders import PyPDFLoader
from langchain_community.vectorstores import FAISS
from langchain_openai import ChatOpenAI
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.embeddings import DashScopeEmbeddings


def qa_agent(openai_api_key, memory, uploaded_file, question):
    # 实例化对话模型
    model = ChatOpenAI(
        model="qwen3.5-plus",
        openai_api_key=openai_api_key,
        openai_api_base="https://dashscope.aliyuncs.com/compatible-mode/v1"
    )

    # 实例化嵌入向量模型
    embeddings_model = DashScopeEmbeddings(
        model="text-embedding-v4",
        dashscope_api_key=openai_api_key
    )

    # 保存上传的PDF为临时文件
    file_content = uploaded_file.read()
    temp_file_path = "xxx.pdf"

    with open(temp_file_path, "wb") as temp_file:
        temp_file.write(file_content)

    # 加载PDF
    loader = PyPDFLoader(temp_file_path)
    docs = loader.load()

    # 切分文本
    text_splitter = RecursiveCharacterTextSplitter(
        chunk_size=500,
        chunk_overlap=50,
        separators=["\n", "。", "!", "?", ",", "、", ""]
    )

    texts = text_splitter.split_documents(docs)

    # 建立向量数据库
    db = FAISS.from_documents(texts, embeddings_model)
    retriever = db.as_retriever()

    # 创建检索问答链
    qa = ConversationalRetrievalChain.from_llm(
        llm=model,
        retriever=retriever,
        memory=memory
    )

    response = qa.invoke({"chat_history": memory, "question": question})
    return response

2. main.py ------ Streamlit 网页界面

python 复制代码
import streamlit as st
from langchain.memory import ConversationBufferMemory
from scripts import qa_agent


# 初始化对话记忆
memory = ConversationBufferMemory(
    return_messages=True,
    memory_key="chat_history",
    output_key="answer"
)

# 初始化会话状态
if "memory" not in st.session_state:
    st.session_state["memory"] = memory

if "chat_history" not in st.session_state:
    st.session_state["chat_history"] = []


# 页面配置
st.set_page_config(
    page_title="AI智能PDF问答工具",
    page_icon="📑",
    layout="centered"
)

st.title("📑 AI智能PDF问答工具")

# API密钥输入
st.subheader("🔑 API设置")
openai_api_key = st.text_input(
    "请输入OpenAI API密钥",
    type="password",
    placeholder="sk-..."
)

st.divider()

# 文件上传
st.subheader("📁 文件上传")
uploaded_file = st.file_uploader(
    "点击或拖拽PDF文件到此处",
    type="pdf",
    help="支持PDF格式文件"
)

# 问题输入
st.subheader("❓ 提问")
question = st.text_input(
    "请输入您的问题...",
    disabled=not uploaded_file,
    placeholder="例如:请总结PDF的主要内容..."
)

# 处理逻辑
if uploaded_file and question:
    if not openai_api_key:
        st.warning("⚠️ 请先输入OpenAI API密钥")
    else:
        with st.spinner("🤖 AI正在思考中,请稍等..."):
            try:
                response = qa_agent(
                    openai_api_key,
                    st.session_state["memory"],
                    uploaded_file,
                    question
                )

                st.subheader("📝 答案")
                st.write(response["answer"])

                st.session_state["chat_history"] = response["chat_history"]

            except Exception as e:
                st.error(f"❌ 处理过程中发生错误: {str(e)}")

# 历史对话记录
if st.session_state["chat_history"]:
    st.divider()
    with st.expander("🗣️ 历史对话记录", expanded=False):
        for i in range(0, len(st.session_state["chat_history"]), 2):
            if i + 1 < len(st.session_state["chat_history"]):
                human_msg = st.session_state["chat_history"][i]
                ai_msg = st.session_state["chat_history"][i + 1]

                st.markdown(f"**👤 你**: {human_msg.content}")
                st.markdown(f"**🤖 AI**: {ai_msg.content}")

                if i < len(st.session_state["chat_history"]) - 2:
                    st.write("---")

        if st.button("🔄 清空对话历史"):
            st.session_state["memory"] = memory
            st.session_state["chat_history"] = []
            st.success("✅ 对话历史已清空")
            st.experimental_rerun()

技术原理:什么是 RAG

1. 从直接问答到检索增强

传统大模型问答只能依赖模型训练时学习到的知识,无法回答私有文档中的问题。

RAG(Retrieval-Augmented Generation,检索增强生成) 的基本思想是:


#mermaid-svg-TFKz5VpR71mq7pBH{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-TFKz5VpR71mq7pBH .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-TFKz5VpR71mq7pBH .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-TFKz5VpR71mq7pBH .error-icon{fill:#552222;}#mermaid-svg-TFKz5VpR71mq7pBH .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-TFKz5VpR71mq7pBH .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-TFKz5VpR71mq7pBH .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-TFKz5VpR71mq7pBH .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-TFKz5VpR71mq7pBH .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-TFKz5VpR71mq7pBH .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-TFKz5VpR71mq7pBH .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-TFKz5VpR71mq7pBH .marker{fill:#333333;stroke:#333333;}#mermaid-svg-TFKz5VpR71mq7pBH .marker.cross{stroke:#333333;}#mermaid-svg-TFKz5VpR71mq7pBH svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-TFKz5VpR71mq7pBH p{margin:0;}#mermaid-svg-TFKz5VpR71mq7pBH .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-TFKz5VpR71mq7pBH .cluster-label text{fill:#333;}#mermaid-svg-TFKz5VpR71mq7pBH .cluster-label span{color:#333;}#mermaid-svg-TFKz5VpR71mq7pBH .cluster-label span p{background-color:transparent;}#mermaid-svg-TFKz5VpR71mq7pBH .label text,#mermaid-svg-TFKz5VpR71mq7pBH span{fill:#333;color:#333;}#mermaid-svg-TFKz5VpR71mq7pBH .node rect,#mermaid-svg-TFKz5VpR71mq7pBH .node circle,#mermaid-svg-TFKz5VpR71mq7pBH .node ellipse,#mermaid-svg-TFKz5VpR71mq7pBH .node polygon,#mermaid-svg-TFKz5VpR71mq7pBH .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-TFKz5VpR71mq7pBH .rough-node .label text,#mermaid-svg-TFKz5VpR71mq7pBH .node .label text,#mermaid-svg-TFKz5VpR71mq7pBH .image-shape .label,#mermaid-svg-TFKz5VpR71mq7pBH .icon-shape .label{text-anchor:middle;}#mermaid-svg-TFKz5VpR71mq7pBH .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-TFKz5VpR71mq7pBH .rough-node .label,#mermaid-svg-TFKz5VpR71mq7pBH .node .label,#mermaid-svg-TFKz5VpR71mq7pBH .image-shape .label,#mermaid-svg-TFKz5VpR71mq7pBH .icon-shape .label{text-align:center;}#mermaid-svg-TFKz5VpR71mq7pBH .node.clickable{cursor:pointer;}#mermaid-svg-TFKz5VpR71mq7pBH .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-TFKz5VpR71mq7pBH .arrowheadPath{fill:#333333;}#mermaid-svg-TFKz5VpR71mq7pBH .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-TFKz5VpR71mq7pBH .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-TFKz5VpR71mq7pBH .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-TFKz5VpR71mq7pBH .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-TFKz5VpR71mq7pBH .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-TFKz5VpR71mq7pBH .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-TFKz5VpR71mq7pBH .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-TFKz5VpR71mq7pBH .cluster text{fill:#333;}#mermaid-svg-TFKz5VpR71mq7pBH .cluster span{color:#333;}#mermaid-svg-TFKz5VpR71mq7pBH div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-TFKz5VpR71mq7pBH .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-TFKz5VpR71mq7pBH rect.text{fill:none;stroke-width:0;}#mermaid-svg-TFKz5VpR71mq7pBH .icon-shape,#mermaid-svg-TFKz5VpR71mq7pBH .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-TFKz5VpR71mq7pBH .icon-shape p,#mermaid-svg-TFKz5VpR71mq7pBH .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-TFKz5VpR71mq7pBH .icon-shape .label rect,#mermaid-svg-TFKz5VpR71mq7pBH .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-TFKz5VpR71mq7pBH .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-TFKz5VpR71mq7pBH .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-TFKz5VpR71mq7pBH :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 上传 PDF
保存为临时文件 xxx.pdf
PyPDFLoader 读取文档
RecursiveCharacterTextSplitter

切分文本(chunk_size=500, overlap=50)
DashScopeEmbeddings

生成文本向量
FAISS 建立向量数据库
Retriever 检索相关文本块
ConversationalRetrievalChain

组合上下文和问题
qwen3.5-plus 生成答案
Streamlit 显示结果和对话历史

2. RAG 中的两类模型

项目同时使用了两类模型:

模型类型 课堂使用 职责
聊天模型 ChatOpenAI(model="qwen3.5-plus") 理解问题、阅读检索到的上下文、生成自然语言答案
嵌入模型 DashScopeEmbeddings(model="text-embedding-v4") 将文本转换成向量,计算不同文本之间的语义相似度

简单理解:

text 复制代码
聊天模型:负责"说答案"
嵌入模型:负责"找资料"

代码逐模块详细解析

模块一:PDF 读取与临时文件保存

功能说明

Streamlit 的 file_uploader 返回的是上传文件对象,而 PyPDFLoader 需要文件路径。因此需要先将上传内容写入本地临时文件。

完整代码
python 复制代码
file_content = uploaded_file.read()
temp_file_path = "xxx.pdf"

with open(temp_file_path, "wb") as temp_file:
    temp_file.write(file_content)

loader = PyPDFLoader(temp_file_path)
docs = loader.load()
核心实现逻辑
  • uploaded_file.read() 读取的是二进制内容
  • 使用 "wb" 模式写入,因为 PDF 是二进制文件,不能按普通文本方式写入
  • docs 是一个文档列表,通常每一页会形成一个 Document 对象
  • 每个 Document 包含 page_content(文本内容)和 metadata(来源、页码等信息)
调试提示
python 复制代码
# 检查PDF加载是否成功
print(f"加载了 {len(docs)} 页")
print(f"第一页内容长度: {len(docs[0].page_content)} 字符")

模块二:文本切分

功能说明

将长文档切成较小的文本块,便于后续检索和模型处理。

完整代码
python 复制代码
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,
    chunk_overlap=50,
    separators=["\n", "。", "!", "?", ",", "、", ""]
)

texts = text_splitter.split_documents(docs)
参数说明
参数 课堂值 作用
chunk_size 500 每个文本块的目标最大长度(字符数)
chunk_overlap 50 相邻文本块保留约 50 个字符重叠
separators 中文标点列表 优先在自然边界处分割文本
核心实现逻辑

为什么不能直接使用整篇 PDF?

  • 内容可能超过模型上下文长度(如 128K 上下文也可能被长文档填满)
  • 每次请求携带大量无关文本,费用和延迟增加
  • 无关内容过多会干扰模型判断
  • 很难准确定位与问题相关的段落

为什么需要重叠区域?

假设一句重要内容刚好位于两个文本块的边界。如果完全不重叠,这句话可能被截断:

设置 chunk_overlap=50 后,相邻文本块会共享部分内容,保留上下文连续性。

参数调优建议
场景 推荐设置 原因
事实问答 chunk_size=300, overlap=50 更精确检索到关键信息
长段落总结 chunk_size=800, overlap=100 保留更多上下文供模型理解
表格/法律条文 针对文档结构调整分隔符 避免在关键位置截断

模块三:Embedding 与 FAISS 向量检索

功能说明

将文本转换成向量,建立索引,实现语义相似度检索。

完整代码
python 复制代码
embeddings_model = DashScopeEmbeddings(
    model="text-embedding-v4",
    dashscope_api_key=openai_api_key
)

db = FAISS.from_documents(texts, embeddings_model)
retriever = db.as_retriever()
核心实现逻辑

什么是文本向量?

计算机不能直接用"卢浮宫"和"博物馆"这样的文字计算语义相似度。嵌入模型会把文本转换成一组数字:

text 复制代码
"卢浮宫是法国著名博物馆"
              ↓ Embedding
[0.018, -0.127, 0.346, ..., 0.052]   # 通常为 1024 或 1536 维

语义接近的文本,其向量在高维空间中的距离通常也更接近。

FAISS 做了什么?

FAISS.from_documents(texts, embeddings_model) 完成:

  1. 遍历所有文本块
  2. 调用嵌入模型生成每个文本块的向量
  3. 保存文本块及其向量
  4. 建立可进行相似度搜索的 FAISS 索引

#mermaid-svg-dB0nsKUQviW0UvM1{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-dB0nsKUQviW0UvM1 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-dB0nsKUQviW0UvM1 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-dB0nsKUQviW0UvM1 .error-icon{fill:#552222;}#mermaid-svg-dB0nsKUQviW0UvM1 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-dB0nsKUQviW0UvM1 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-dB0nsKUQviW0UvM1 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-dB0nsKUQviW0UvM1 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-dB0nsKUQviW0UvM1 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-dB0nsKUQviW0UvM1 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-dB0nsKUQviW0UvM1 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-dB0nsKUQviW0UvM1 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-dB0nsKUQviW0UvM1 .marker.cross{stroke:#333333;}#mermaid-svg-dB0nsKUQviW0UvM1 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-dB0nsKUQviW0UvM1 p{margin:0;}#mermaid-svg-dB0nsKUQviW0UvM1 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-dB0nsKUQviW0UvM1 .cluster-label text{fill:#333;}#mermaid-svg-dB0nsKUQviW0UvM1 .cluster-label span{color:#333;}#mermaid-svg-dB0nsKUQviW0UvM1 .cluster-label span p{background-color:transparent;}#mermaid-svg-dB0nsKUQviW0UvM1 .label text,#mermaid-svg-dB0nsKUQviW0UvM1 span{fill:#333;color:#333;}#mermaid-svg-dB0nsKUQviW0UvM1 .node rect,#mermaid-svg-dB0nsKUQviW0UvM1 .node circle,#mermaid-svg-dB0nsKUQviW0UvM1 .node ellipse,#mermaid-svg-dB0nsKUQviW0UvM1 .node polygon,#mermaid-svg-dB0nsKUQviW0UvM1 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-dB0nsKUQviW0UvM1 .rough-node .label text,#mermaid-svg-dB0nsKUQviW0UvM1 .node .label text,#mermaid-svg-dB0nsKUQviW0UvM1 .image-shape .label,#mermaid-svg-dB0nsKUQviW0UvM1 .icon-shape .label{text-anchor:middle;}#mermaid-svg-dB0nsKUQviW0UvM1 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-dB0nsKUQviW0UvM1 .rough-node .label,#mermaid-svg-dB0nsKUQviW0UvM1 .node .label,#mermaid-svg-dB0nsKUQviW0UvM1 .image-shape .label,#mermaid-svg-dB0nsKUQviW0UvM1 .icon-shape .label{text-align:center;}#mermaid-svg-dB0nsKUQviW0UvM1 .node.clickable{cursor:pointer;}#mermaid-svg-dB0nsKUQviW0UvM1 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-dB0nsKUQviW0UvM1 .arrowheadPath{fill:#333333;}#mermaid-svg-dB0nsKUQviW0UvM1 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-dB0nsKUQviW0UvM1 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-dB0nsKUQviW0UvM1 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-dB0nsKUQviW0UvM1 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-dB0nsKUQviW0UvM1 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-dB0nsKUQviW0UvM1 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-dB0nsKUQviW0UvM1 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-dB0nsKUQviW0UvM1 .cluster text{fill:#333;}#mermaid-svg-dB0nsKUQviW0UvM1 .cluster span{color:#333;}#mermaid-svg-dB0nsKUQviW0UvM1 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-dB0nsKUQviW0UvM1 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-dB0nsKUQviW0UvM1 rect.text{fill:none;stroke-width:0;}#mermaid-svg-dB0nsKUQviW0UvM1 .icon-shape,#mermaid-svg-dB0nsKUQviW0UvM1 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-dB0nsKUQviW0UvM1 .icon-shape p,#mermaid-svg-dB0nsKUQviW0UvM1 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-dB0nsKUQviW0UvM1 .icon-shape .label rect,#mermaid-svg-dB0nsKUQviW0UvM1 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-dB0nsKUQviW0UvM1 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-dB0nsKUQviW0UvM1 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-dB0nsKUQviW0UvM1 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 在线阶段
离线阶段
文本块1

卢浮宫位于巴黎
Embedding
向量 0.12, -0.34, ...
文本块2

博物馆藏品丰富
Embedding
向量 0.56, 0.78, ...
文本块3

建筑历史悠久
Embedding
向量 -0.23, 0.45, ...
FAISS 索引
用户问题

卢浮宫在哪里
Embedding
问题向量
检索到最相似的

文本块1
模型根据文本块1

生成答案

模块四:ConversationalRetrievalChain 与对话记忆

功能说明

将检索器、聊天模型和对话记忆组合成一个完整的问答链。

完整代码
python 复制代码
# 创建对话记忆
memory = ConversationBufferMemory(
    return_messages=True,
    memory_key="chat_history",
    output_key="answer"
)

# 创建检索问答链
qa = ConversationalRetrievalChain.from_llm(
    llm=model,
    retriever=retriever,
    memory=memory
)

# 执行问答
response = qa.invoke({
    "chat_history": memory,
    "question": question
})
参数说明
参数 作用
return_messages=True 以消息对象列表保存历史记录
memory_key="chat_history" Chain 中读取历史消息的键名
output_key="answer" 把返回结果中的 answer 保存到记忆
核心实现逻辑

ConversationalRetrievalChain 把三个能力组合在一起:

text 复制代码
对话历史 + 当前问题
          ↓
理解本轮检索需求
          ↓
Retriever 查找 PDF 相关片段
          ↓
聊天模型根据片段生成答案
          ↓
更新对话记忆

有了对话记忆,用户可以继续追问:

text 复制代码
第一问:卢浮宫位于哪里?  → 回答:巴黎
第二问:它最初是做什么用的? → 模型理解"它"指卢浮宫

完整运行流程

1. 项目环境准备

项目结构:

text 复制代码
rag/
├── .venv/                  # 虚拟环境
├── .python-version         # Python 3.10.18
├── pyproject.toml          # 项目依赖
├── uv.lock                 # 锁定依赖版本
└── 代码2/
    ├── main.py             # Streamlit 主程序
    ├── scripts.py          # RAG 核心函数
    └── 卢浮宫.pdf          # 测试文档

2. 使用 uv 同步环境

进入项目根目录:

powershell 复制代码
Set-Location "D:\Jupyter_Projects\PythonProject\大模型学习\rag"

同步依赖:

powershell 复制代码
uv sync

验证 Python 版本:

powershell 复制代码
uv run python --version

预期输出:

text 复制代码
Python 3.10.18

3. 启动 Streamlit 服务

进入代码目录:

powershell 复制代码
cd 代码2

启动服务:

powershell 复制代码
uv run streamlit run main.py

启动成功后,终端显示:

text 复制代码
Local URL: http://localhost:8501
Network URL: http://192.168.x.x:8501

4. 网页操作顺序

  1. 点击上传区域,选择 卢浮宫.pdf
  2. 在问题输入框中输入问题(如"卢浮宫位于哪个城市?")
  3. 等待向量构建、检索和模型回答
  4. 查看回答与历史对话

第一次提问可能稍慢,因为程序需要读取 PDF、调用嵌入模型并建立 FAISS 索引。

5. 结束程序

回到正在运行 Streamlit 的终端,按 Ctrl + C 停止服务。

常见问题与解决方法

问题1:API Key 无效或读取不到

现象

text 复制代码
AuthenticationError: Invalid API key

原因: API Key 不正确,或未设置环境变量。

解决方法

  • 确认在网页中正确输入了阿里云百炼 API Key
  • 如果改为从环境变量读取,添加:
python 复制代码
import os
openai_api_key = os.getenv("DASHSCOPE_API_KEY")

问题2:PDF 加载后无内容

现象

text 复制代码
docs 为空或 page_content 为空字符串

原因PyPDFLoader 主要读取 PDF 中已有的文本层。如果 PDF 是扫描图片,加载后得不到有效文字。

解决方法

  • 使用 OCR 工具(如 PaddleOCR)先识别图片文字
  • 或使用带文本层的 PDF 进行测试

问题3:Streamlit 端口被占用

现象

text 复制代码
OSError: [Errno 98] Address already in use

原因:端口 8501 已被其他进程占用。

解决方法

powershell 复制代码
uv run streamlit run main.py --server.port 8502

问题4:中文显示乱码

现象:页面显示乱码或方框。

原因:控制台或页面编码问题。

解决方法

  • 检查页面编码为 UTF-8
  • 如果控制台乱码,在 PowerShell 中执行:
powershell 复制代码
chcp 65001

问题5:模型回答与 PDF 无关

现象:模型回答的内容不在 PDF 中。

原因

  • 检索到的片段不相关(切分参数或检索参数需调整)
  • 模型没有严格基于资料回答

解决方法

  • 检查 chunk_sizechunk_overlap 是否合适
  • 在系统提示中增加约束:"请基于提供的文档片段回答,如果文档中没有相关信息,请说明"
  • 验证检索结果:打印 retriever.get_relevant_documents(question) 查看返回的片段

问题6:每次提问都重新构建向量库

现象:连续提问速度慢,且每次都有 Embedding 调用。

原因 :课堂代码中 qa_agent() 每次执行都会重新加载 PDF、切分、调用 Embedding、建立 FAISS。

解决方法

  • 缓存向量库:只在首次上传时构建
  • 使用 st.session_state 保存 dbretriever 对象
python 复制代码
if "retriever" not in st.session_state:
    # 构建向量库
    st.session_state["retriever"] = retriever

性能优化建议

1. 向量库缓存

当前代码每次提问都重新构建向量库。优化方案:

python 复制代码
if "vector_db" not in st.session_state:
    st.session_state["vector_db"] = FAISS.from_documents(texts, embeddings_model)

retriever = st.session_state["vector_db"].as_retriever()

2. 选择合适的文本块大小

  • 事实问答:较小的 chunk_size(300-500)更精确
  • 总结类问题:较大的 chunk_size(800-1000)保留更多上下文
  • 建议通过实验确定最优参数

3. 使用更快的嵌入模型

  • text-embedding-v3v4 更快,但精度略低
  • 根据业务场景在速度和精度之间平衡

4. 异步处理

  • PDF 解析和 Embedding 可异步执行
  • Streamlit 本身不支持异步,可考虑使用后台任务

项目扩展方向

  1. 支持更多文档格式:添加 Word、TXT、Markdown、HTML 等格式
  2. 来源引用:在答案中标注内容来源页码
  3. 无答案提示:当文档没有相关信息时,明确告知用户
  4. 检索质量评估:显示检索到的相关片段,让用户判断可信度
  5. 多文档对话:支持同时上传多份 PDF
  6. 部署到云端:使用 Streamlit Cloud 或 Docker 部署

总结

本项目把一份 PDF 变成了可以对话的知识库,其核心不是"把 PDF 直接交给大模型",而是下面这条检索增强生成流程:

text 复制代码
PDF 文档
   ↓
加载并提取文本
   ↓
按 500 字符切分,保留 50 字符重叠
   ↓
text-embedding-v4 生成向量
   ↓
FAISS 建立向量索引
   ↓
根据问题检索相关片段
   ↓
qwen3.5-plus 结合片段生成答案
   ↓
ConversationBufferMemory 保存对话
   ↓
Streamlit 展示网页结果

通过这次实践,我们不仅完成了一个可运行的 PDF 问答网页,也串联了多个重要知识点:

  • uv 管理独立项目环境
  • Streamlit 构建交互网页
  • PyPDFLoader 读取 PDF
  • RecursiveCharacterTextSplitter 切分长文档
  • DashScope Embeddings 将文本转换成向量
  • FAISS 完成相似度检索
  • ConversationalRetrievalChain 组合检索、模型和记忆
  • st.session_state 保存网页会话数据

这就是一个完整的入门级 RAG 应用。下一步可以继续优化向量缓存、来源引用、临时文件管理和无答案处理,把课堂演示逐渐改造成更稳定的实际项目。

参考资料

相关推荐
不加辣椒1 小时前
第11章:性能优化与成本控制
人工智能
xcLeigh1 小时前
KingbaseES 的卢智能运维体架构深度拆解
运维·数据库·人工智能·ai·架构·ffmpeg·智能体
GuWenyue1 小时前
不用第三方SDK!Vue3原生Fetch实现DeepSeek流式输出,90%前端都会踩的分片解析坑一次性解决
前端·人工智能·llm
水如烟1 小时前
孤能子视角:EIS分析框架的演化(上)——元三力·五要点·六线探针:让关系场显影
人工智能
阿里云大数据AI技术1 小时前
数据集成 Agent 最佳实践(一):单表离线同步:一句话建好每日入仓任务
人工智能
恣逍信点2 小时前
《凌微经 · 理悖相涵》导论:“我思”事实——知识理论之根基
人工智能·科技·学习·生活·量子计算·交友·哲学
happyprince2 小时前
篇1:整体观 · bitsandbytes项目全貌解读
人工智能·算法
陈大鱼头2 小时前
一句话,Seed Evolving 给我做了一个 AI 小说创作 Agent
人工智能·gpt·ai
电化学仪器白超2 小时前
禹衡光栅长度计精度评测:基于国标0级氧化锆量块的校正与数据分析
python·单片机·嵌入式硬件·物联网·自动化