Python + Streamlit + DeepSeek API 实现一个本地文档问答助手

本文会从 0 到 1 实现一个可以运行的大模型文档问答小项目:上传 PDF 或 TXT 文档,输入问题后,程序会先从文档中检索相关片段,再调用大模型生成回答。

摘要

很多大模型应用并不是从零训练模型,而是把已有模型接入到具体业务流程中。本文以“本地文档问答助手”为例,使用 Python、Streamlit、DeepSeek API、pypdf 和 scikit-learn 实现一个入门版 RAG 应用。

项目完成后可以实现:

  • 上传 PDF / TXT 文档
  • 自动读取文档内容
  • 将长文本切分成多个片段
  • 根据用户问题检索相关内容
  • 调用 DeepSeek 大模型生成回答
  • 展示答案和参考片段

本文尽量不依赖复杂框架,先把完整流程跑通,适合用来理解大模型应用开发中的 RAG 基本思路。

目录

  • 一、项目效果
  • 二、技术选型
  • 三、项目原理
  • 四、环境准备
  • 五、项目目录
  • 六、完整代码
  • 七、运行项目
  • 八、核心代码解析
  • 九、常见问题
  • 十、后续优化方向
  • 十一、总结

一、项目效果

运行后会得到一个本地 Web 页面,页面中包含两个主要输入区域:

  • 文档上传区域:支持上传 PDF 或 TXT
  • 问题输入区域:输入想从文档中查询的问题

使用流程如下:

上传文档
↓
输入问题
↓
点击“生成回答”
↓
系统检索文档片段
↓
大模型基于检索内容生成回答
↓
页面展示回答和参考片段

例如上传一份技术文档后,可以提问:

这份文档主要讲了什么?

也可以提问:

文档中提到了哪些关键步骤?

相比普通聊天机器人,这个项目的重点在于:模型回答时会参考用户上传的文档内容,而不是完全依赖模型自身知识。

二、技术选型

本项目使用的技术如下:

技术 作用
Python 核心开发语言
Streamlit 快速搭建 Web 页面
DeepSeek API 调用大模型生成回答
OpenAI SDK 使用兼容 OpenAI 格式的接口调用 DeepSeek
pypdf 读取 PDF 文本
scikit-learn 使用 TF-IDF 和余弦相似度做文本检索

这里没有直接使用 LangChain、LlamaIndex 或向量数据库,主要是为了先用较少代码理解 RAG 的核心流程。后续可以在这个版本基础上继续升级。

三、项目原理

这个项目可以看作一个简化版 RAG,也就是检索增强生成。

普通大模型问答流程是:

用户问题 → 大模型 → 回答

本文实现的流程是:

用户问题 → 检索文档相关片段 → 大模型基于片段回答 → 展示答案

完整流程可以拆成 5 步:

1. 读取上传文档
2. 将文档切分成多个文本片段
3. 计算用户问题和文本片段的相似度
4. 取出最相关的几个片段
5. 将片段和问题一起交给大模型生成回答

这里的“检索”使用 TF-IDF + cosine similarity 实现。它不是最强的语义检索方案,但非常适合入门,因为代码简单、依赖少、方便理解。

四、环境准备

建议使用 Python 3.10 或以上版本。

1. 创建项目目录

mkdir document_qa_demo
cd document_qa_demo

2. 创建虚拟环境

python -m venv .venv

Windows PowerShell 激活虚拟环境:

.venv\Scripts\Activate.ps1

macOS / Linux 激活虚拟环境:

source .venv/bin/activate

3. 安装依赖

pip install streamlit openai scikit-learn pypdf

也可以新建 requirements.txt

streamlit
openai
scikit-learn
pypdf

然后执行:

pip install -r requirements.txt

4. 配置 DeepSeek API Key

DeepSeek API 兼容 OpenAI SDK,调用时需要配置 base_url 和 API Key。

Windows PowerShell 临时设置:

$env:DEEPSEEK_API_KEY="你的 API Key"

macOS / Linux 临时设置:

export DEEPSEEK_API_KEY="你的 API Key"

如果使用 Streamlit 的 secrets,也可以创建文件:

.streamlit/secrets.toml

写入:

DEEPSEEK_API_KEY = "你的 API Key"

注意:不要把自己的 API Key 上传到 GitHub,也不要直接写进公开文章的代码里。

五、项目目录

最终目录结构如下:

document_qa_demo
├── app.py
├── requirements.txt
└── .streamlit
    └── secrets.toml

其中:

  • app.py:项目主程序
  • requirements.txt:依赖列表
  • .streamlit/secrets.toml:本地密钥配置,可选

六、完整代码

新建 app.py,写入下面代码:

import os
from io import BytesIO

import streamlit as st
from openai import OpenAI
from pypdf import PdfReader
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity


MODEL_NAME = "deepseek-v4-flash"


def get_api_key():
    if "DEEPSEEK_API_KEY" in st.secrets:
        return st.secrets["DEEPSEEK_API_KEY"]
    return os.getenv("DEEPSEEK_API_KEY")


def read_pdf(uploaded_file):
    reader = PdfReader(BytesIO(uploaded_file.getvalue()))
    text_list = []

    for page in reader.pages:
        page_text = page.extract_text()
        if page_text:
            text_list.append(page_text)

    return "\n".join(text_list)


def read_txt(uploaded_file):
    return uploaded_file.getvalue().decode("utf-8", errors="ignore")


def split_text(text, chunk_size=700, overlap=120):
    chunks = []
    start = 0

    while start < len(text):
        end = start + chunk_size
        chunk = text[start:end].strip()

        if len(chunk) > 80:
            chunks.append(chunk)

        start = end - overlap

    return chunks


def retrieve_chunks(question, chunks, top_k=4):
    if not chunks:
        return []

    vectorizer = TfidfVectorizer(
        analyzer="char",
        ngram_range=(2, 4)
    )

    doc_vectors = vectorizer.fit_transform(chunks)
    question_vector = vectorizer.transform([question])

    scores = cosine_similarity(question_vector, doc_vectors)[0]
    ranked_indexes = scores.argsort()[::-1][:top_k]

    results = []
    for index in ranked_indexes:
        results.append({
            "content": chunks[index],
            "score": float(scores[index])
        })

    return results


def ask_llm(api_key, question, retrieved_chunks):
    context = "\n\n".join(
        [
            f"资料片段 {index + 1}:\n{item['content']}"
            for index, item in enumerate(retrieved_chunks)
        ]
    )

    client = OpenAI(
        api_key=api_key,
        base_url="https://api.deepseek.com"
    )

    response = client.chat.completions.create(
        model=MODEL_NAME,
        messages=[
            {
                "role": "system",
                "content": (
                    "你是一个严谨的文档问答助手。"
                    "请只根据用户提供的资料回答问题。"
                    "如果资料中没有相关信息,请明确说明无法从当前资料中确定。"
                )
            },
            {
                "role": "user",
                "content": f"""
请根据下面的资料回答用户问题。

【资料】
{context}

【用户问题】
{question}

【回答要求】
1. 先直接回答问题
2. 不要编造资料中没有的信息
3. 如果资料不足,请明确说明
4. 最后简单说明依据来自哪些资料片段
"""
            }
        ],
        stream=False
    )

    return response.choices[0].message.content


st.set_page_config(
    page_title="本地文档问答助手",
    layout="wide"
)

st.title("本地文档问答助手")
st.caption("上传 PDF 或 TXT 文档,输入问题后,系统会检索相关片段并调用大模型生成回答。")

api_key = get_api_key()

if not api_key:
    st.warning("请先设置 DEEPSEEK_API_KEY。可以使用环境变量,也可以使用 .streamlit/secrets.toml。")
    st.stop()

with st.sidebar:
    st.header("参数设置")
    chunk_size = st.slider("文本片段长度", min_value=300, max_value=1500, value=700, step=100)
    overlap = st.slider("片段重叠长度", min_value=0, max_value=300, value=120, step=20)
    top_k = st.slider("检索片段数量", min_value=1, max_value=8, value=4, step=1)

uploaded_file = st.file_uploader("上传文档", type=["pdf", "txt"])
question = st.text_input(
    "请输入你的问题",
    placeholder="例如:这份文档的核心内容是什么?"
)

if uploaded_file:
    st.info(f"当前文件:{uploaded_file.name}")

if uploaded_file and question:
    if st.button("生成回答", type="primary"):
        with st.spinner("正在读取文档..."):
            if uploaded_file.name.lower().endswith(".pdf"):
                text = read_pdf(uploaded_file)
            else:
                text = read_txt(uploaded_file)

        if not text.strip():
            st.error("没有读取到有效文本。可能是扫描版 PDF,或者文档内容为空。")
            st.stop()

        with st.spinner("正在切分文本并检索相关内容..."):
            chunks = split_text(text, chunk_size=chunk_size, overlap=overlap)
            retrieved_chunks = retrieve_chunks(question, chunks, top_k=top_k)

        if not retrieved_chunks:
            st.error("没有检索到可用文本片段。")
            st.stop()

        with st.spinner("正在调用大模型生成回答..."):
            answer = ask_llm(api_key, question, retrieved_chunks)

        st.subheader("回答")
        st.write(answer)

        st.subheader("参考片段")
        for index, item in enumerate(retrieved_chunks, start=1):
            with st.expander(f"参考片段 {index},相似度:{item['score']:.4f}"):
                st.write(item["content"])
else:
    st.write("请先上传文档并输入问题。")

七、运行项目

在项目目录下执行:

streamlit run app.py

如果命令不可用,可以使用:

python -m streamlit run app.py

正常情况下,浏览器会自动打开本地页面,地址通常是:

http://localhost:8501

如果页面没有自动打开,也可以手动复制终端里的地址到浏览器访问。

八、核心代码解析

1. 使用 Streamlit 上传文件

uploaded_file = st.file_uploader("上传文档", type=["pdf", "txt"])

这里限制上传类型为 PDF 和 TXT。Streamlit 会把上传的文件包装成一个类似文件对象的 UploadedFile,后续可以直接读取内容。

2. 读取 PDF 文本

reader = PdfReader(BytesIO(uploaded_file.getvalue()))

pypdf 可以读取普通 PDF 中的文本。如果 PDF 是扫描图片,可能提取不到文字,这种情况需要额外接入 OCR。

3. 文本切分

chunks = split_text(text, chunk_size=chunk_size, overlap=overlap)

长文档不能直接全部塞给大模型,所以需要切成多个片段。这里设置了两个参数:

  • chunk_size:每个片段的大致长度
  • overlap:相邻片段之间的重叠长度

保留重叠的原因是避免一句话或一个段落被切断后丢失上下文。

4. 检索相关片段

scores = cosine_similarity(question_vector, doc_vectors)[0]

这里使用 TF-IDF 将文本转换成特征向量,再用余弦相似度计算问题和文档片段的相关程度。相似度越高,说明该片段越可能和问题相关。

本文为了适配中文,使用了字符级 n-gram:

analyzer="char",
ngram_range=(2, 4)

这样即使没有分词工具,也能完成一个基础检索效果。

5. 调用 DeepSeek API

client = OpenAI(
    api_key=api_key,
    base_url="https://api.deepseek.com"
)

DeepSeek API 兼容 OpenAI SDK,所以可以通过 OpenAI 客户端调用。本文使用的模型是:

MODEL_NAME = "deepseek-v4-flash"

生成回答时,将检索到的资料片段和用户问题一起发送给模型:

response = client.chat.completions.create(
    model=MODEL_NAME,
    messages=[...],
    stream=False
)

这样模型就会优先根据上传文档中的内容进行回答。

九、常见问题

1. 为什么上传 PDF 后没有内容?

可能原因是 PDF 是扫描版,也就是每一页本质上是图片,而不是可复制的文字。pypdf 只能提取文本型 PDF。扫描版 PDF 需要使用 OCR 工具识别文字。

2. 为什么回答看起来不够准确?

可能有几个原因:

  • 文档切分太短,导致上下文不完整
  • 文档切分太长,导致检索不精确
  • TF-IDF 更偏关键词匹配,不是真正的语义向量检索
  • 问题表述和文档内容差异较大

可以尝试调整侧边栏中的 文本片段长度片段重叠长度检索片段数量

3. TF-IDF 和真正的向量检索有什么区别?

TF-IDF 更像关键词检索,适合入门和小规模 Demo。真正的 RAG 项目通常会使用 Embedding 模型,把文本转换成语义向量,然后存入 FAISS、Chroma、Milvus 或 pgvector 等向量数据库中。

简单理解:

TF-IDF:更关注字词是否相似
Embedding:更关注语义是否相似

例如“如何申请报销”和“费用报销流程是什么”字面上不完全一样,但语义接近。Embedding 检索通常更容易识别这种相似关系。

4. API Key 应该怎么保存?

不要直接写在代码里,建议使用:

  • 环境变量
  • .streamlit/secrets.toml
  • 部署平台提供的密钥管理功能

如果代码要上传 GitHub,记得把 .streamlit/secrets.toml 加入 .gitignore

十、后续优化方向

当前项目是入门版本,可以继续从以下方向优化:

  1. 使用 Embedding 模型替代 TF-IDF,提高语义检索效果。
  2. 使用 FAISS 或 Chroma 存储向量,支持更大的文档库。
  3. 支持多文件上传,实现个人知识库。
  4. 记录历史对话,让用户可以连续追问。
  5. 增加页码引用,让答案能追溯到 PDF 的具体页面。
  6. 增加 FastAPI 后端,将前端和后端分离。
  7. 增加 Dockerfile,方便部署和演示。
  8. 接入 OCR,支持扫描版 PDF。

如果继续升级,可以把项目路线设计成:

版本 1:TF-IDF + Streamlit 单文件 Demo
版本 2:Embedding + FAISS 语义检索
版本 3:多文档知识库 + 历史对话
版本 4:FastAPI 后端 + 前端页面
版本 5:Docker 部署 + 项目上线

这样既能逐步理解技术原理,也能把项目迭代过程记录下来。

十一、总结

本文实现了一个可以本地运行的大模型文档问答助手,核心流程包括:

文档上传
→ 文本读取
→ 文本切分
→ 相关片段检索
→ 大模型生成回答
→ 展示参考片段

这个项目虽然不复杂,但已经覆盖了大模型应用开发中的几个关键点:

  • Prompt 设计
  • API 调用
  • 文档处理
  • 文本检索
  • RAG 基本流程
  • Web 页面展示

对于入门大模型应用开发来说,先完成这样一个能运行、能演示、能继续扩展的小项目,比一开始直接堆复杂框架更容易理解核心逻辑。

参考资料

  • DeepSeek API 文档:https://api-docs.deepseek.com/
  • Streamlit 运行应用文档:https://docs.streamlit.io/develop/concepts/architecture/run-your-app
  • Streamlit 文件上传组件文档:https://docs.streamlit.io/develop/api-reference/widgets/st.file_uploader
  • scikit-learn TfidfVectorizer 文档:https://scikit-learn.org/stable/modules/generated/sklearn.feature_extraction.text.TfidfVectorizer.html
  • scikit-learn cosine_similarity 文档:https://scikit-learn.org/stable/modules/generated/sklearn.metrics.pairwise.cosine_similarity.html
  • pypdf 文档:https://pypdf.readthedocs.io/
Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐