1. 项目概述:从零构建本地知识库问答应用

最近和不少同行交流,发现大家对于如何利用开源工具快速搭建一个私有化、可本地运行的智能问答系统兴趣浓厚。这背后反映的,其实是两个刚需:一是对数据隐私和安全性的重视,不希望将内部文档、技术资料或私人笔记上传到云端服务;二是渴望拥有一个能深度理解特定领域知识、并能进行持续学习的“专属助手”。基于大语言模型(LLM)的检索增强生成(RAG)技术,恰好是解决这两个需求的利器。

这个项目,就是一次完整的实践记录。我们将使用 LangChain 作为应用编排框架, Ollama 来本地运行轻量级大语言模型,并用 Streamlit 快速构建一个直观的Web交互界面。最终,你将得到一个完全在你自己电脑上运行的智能应用:你可以上传自己的PDF、TXT或Word文档,它会自动学习其中的内容,然后你就能以自然对话的方式,向它提问并获得基于你文档内容的精准回答。整个过程无需联网调用任何外部API,所有数据、所有计算都在本地完成,彻底打消隐私顾虑。

无论你是开发者想为自己的项目添加一个智能文档助手,还是技术爱好者想体验本地AI的乐趣,亦或是某个领域的专业人士希望拥有一个永不疲倦的知识顾问,这套方案都值得一试。它技术栈清晰,模块化程度高,而且每一步都有成熟的开源工具支持,避免了重复造轮子。接下来,我们就从核心思路开始,一步步拆解如何实现它。

2. 核心架构与工具选型解析

2.1 为什么选择 RAG 架构?

在深入代码之前,我们必须先理解为什么选择RAG,而不是直接让大模型“背诵”你的文档。直接让模型学习海量、更新的专有知识,成本极高且不灵活。RAG的核心思想是“按需取用”:先将你的文档库转换成可快速检索的格式(向量化),当用户提问时,先从文档库中找出最相关的片段,然后将这些片段和问题一起交给大模型,让它基于这些“上下文”来生成答案。

这样做有几个压倒性优势:

  1. 知识更新成本低 :要增加新知识,只需将新文档向量化并存入数据库即可,无需重新训练昂贵的模型。
  2. 答案可溯源 :模型生成的答案基于检索到的文档片段,你可以轻松追溯到答案的来源,增强了可信度。
  3. 缓解模型“幻觉” :模型倾向于根据提供的上下文作答,减少了凭空捏造事实的可能。
  4. 突破模型上下文长度限制 :即使你的文档库有上百万字,RAG也能通过检索,只将最相关的少量文本送入模型,完美绕开了模型对单次输入长度的限制。

因此,对于构建个人或企业专属知识库,RAG是目前在效果、成本和可行性上最平衡的技术路径。

2.2 技术栈深度剖析:LangChain + Ollama + Streamlit

确定了RAG这条路,接下来就是挑选趁手的工具。 LangChain + Ollama + Streamlit 这个组合,可以说是为本地化、轻量级RAG应用量身定制的。

2.2.1 LangChain:应用编排的“粘合剂” LangChain不是一个具体的模型或数据库,而是一个框架。它把RAG流程中涉及到的各个环节——文档加载、文本分割、向量化、检索、提示词组装、模型调用——都抽象成了标准的“链”(Chain)或“组件”。你可以把它想象成一个乐高积木工具箱,它提供了各种形状的标准化接口(积木),我们只需要按顺序拼接它们,就能快速搭建起一个完整的AI应用管道,而无需关心每个积木内部复杂的实现细节。它极大地提升了开发效率,并且保持了高度的灵活性。

2.2.2 Ollama:本地大模型的“发动机” 模型本地运行是隐私和安全的关键。Ollama的出现,让在个人电脑上运行诸如Llama 2、Mistral、Gemma等开源大模型变得异常简单。它通过优化的量化技术,将原本需要数十GB显存的模型,压缩到只需4-8GB甚至更少内存就能流畅运行。你只需要一条简单的命令(如 ollama run llama2:7b )就能启动一个模型服务。更重要的是,Ollama提供了与OpenAI API兼容的本地接口,这意味着所有为ChatGPT设计的工具(包括LangChain),都能几乎无缝地切换到本地的Ollama模型上,技术迁移成本极低。

2.2.3 Streamlit:快速原型的“展示窗” 我们的应用需要一个界面。Streamlit是一个专门为机器学习和数据科学打造的超轻量级Web应用框架。它的核心理念是“脚本即应用”。你用Python写一个脚本,其中用Streamlit的函数定义输入框、按钮、显示区域,运行这个脚本,一个完整的Web应用就启动了。它省去了前后端联调、HTML/CSS/JavaScript编写的繁琐过程,让你能专注于核心逻辑。对于RAG这种交互式应用,Streamlit是快速构建演示界面或内部工具的不二之选。

2.2.4 向量数据库:知识的“记忆库” 除了上述三大件,还有一个隐藏的核心——向量数据库。我们拆解文档得到的“向量”(即文本的数学化表示),需要被存储和高效检索。这里我们选用 Chroma 。它是一个开源嵌入向量数据库,设计目标就是简单易用和轻量。它可以直接在内存中或本地目录里运行,无需复杂的服务器部署,完美契合本地开发的需求。LangChain也为其提供了原生支持,集成起来非常方便。

注意 :工具选型并非一成不变。例如,向量数据库可以换成FAISS(Facebook开源的相似性搜索库),界面框架可以换成Gradio。但当前组合在易用性、社区支持和功能完整性上达到了最佳平衡,特别适合入门和快速实现。

3. 环境准备与核心依赖安装

工欲善其事,必先利其器。在开始编码前,我们需要搭建好开发环境。我强烈建议使用 Python虚拟环境 来管理本项目依赖,以避免与系统或其他项目的Python包发生冲突。

3.1 创建并激活虚拟环境

打开你的终端(Linux/macOS)或命令提示符/PowerShell(Windows),执行以下命令:

# 1. 创建一个新的虚拟环境,命名为 `rag_env`(名字可自定)
python -m venv rag_env

# 2. 激活虚拟环境
# 在 Windows 上:
rag_env\Scripts\activate
# 在 Linux/macOS 上:
source rag_env/bin/activate

激活后,你的命令行提示符前通常会显示 (rag_env) ,表示你已进入该虚拟环境。

3.2 安装 Python 依赖包

我们将通过一个 requirements.txt 文件来一次性安装所有必要的Python库。首先,创建一个名为 requirements.txt 的文件,内容如下:

langchain==0.1.0
langchain-community==0.0.10
chromadb==0.4.22
streamlit==1.29.0
pypdf==3.17.4  # 用于读取PDF文件
python-docx==1.1.0  # 用于读取Word文件
sentence-transformers==2.2.2  # 用于生成文本向量(嵌入)
pydantic==2.5.0  # LangChain依赖

然后,在激活的虚拟环境中运行安装命令:

pip install -r requirements.txt

这里解释一下几个关键包:

  • langchain langchain-community :核心框架及社区贡献的组件。
  • chromadb :我们选用的向量数据库。
  • streamlit :Web应用框架。
  • pypdf python-docx :分别用于处理PDF和Word文档,这是LangChain文档加载器背后实际工作的库。
  • sentence-transformers :一个非常流行的库,用于将句子或段落转换为向量。我们将用它来为文本生成嵌入(Embeddings),这是实现语义检索的基础。

3.3 安装并配置 Ollama

Ollama需要单独安装。请访问 Ollama 的官方 GitHub 发布页面,根据你的操作系统(Windows、macOS、Linux)下载对应的安装包并安装。

安装完成后,打开一个新的终端窗口(保持虚拟环境的终端窗口开着),运行以下命令来拉取一个适合本地运行的模型。对于大多数消费级电脑(拥有8GB以上内存),7B参数的模型是性能和资源消耗的甜点区。我推荐从 Mistral 7B Llama 2 7B 开始:

# 拉取 Mistral 7B 模型(约4.1GB)
ollama pull mistral:7b
# 或者拉取 Llama 2 7B 模型
# ollama pull llama2:7b

拉取完成后,你可以运行 ollama run mistral:7b 在命令行中简单测试一下模型是否正常工作。测试完毕后,按 Ctrl+D 退出交互。

关键配置 :为了让LangChain能够调用本地的Ollama模型,我们需要知道Ollama服务的地址。默认情况下,Ollama会在 http://localhost:11434 启动一个API服务。我们后续的代码就会指向这个地址。

实操心得 :在拉取模型时,网络状况可能导致速度缓慢或失败。可以考虑使用镜像源,或者耐心等待。首次运行模型时,Ollama会进行一些优化加载,可能会多花一两分钟,后续启动就会快很多。另外,确保你的磁盘有足够空间(一个7B模型约需4-5GB)。

4. 核心模块一:文档处理与向量化存储

这是RAG的“知识注入”阶段。我们的目标是:将用户上传的各种格式的文档,转换成一段段有意义的文本,并为每一段文本生成一个数字向量(嵌入),然后把这些向量连同原始文本一起,存入向量数据库,以备检索。

4.1 文档加载与文本分割

不同的文档格式需要不同的加载器。LangChain提供了丰富的 DocumentLoader 。我们将主要支持PDF、TXT和Word。

# document_processor.py
from langchain_community.document_loaders import PyPDFLoader, TextLoader, Docx2txtLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.schema import Document
import os
from typing import List

def load_documents(file_path: str) -> List[Document]:
    """根据文件后缀名,使用对应的加载器加载文档"""
    if file_path.endswith('.pdf'):
        loader = PyPDFLoader(file_path)
    elif file_path.endswith('.txt'):
        loader = TextLoader(file_path, encoding='utf-8')
    elif file_path.endswith('.docx'):
        loader = Docx2txtLoader(file_path)
    else:
        raise ValueError(f"Unsupported file format: {file_path}")
    # loader.load() 返回一个Document对象列表,每个Document包含页面内容和元数据
    documents = loader.load()
    return documents

加载得到的文档可能很长(比如一本电子书),直接塞给模型是不行的。我们需要进行“文本分割”。这里的关键是平衡“片段”的长度和语义完整性。分割得太碎,会丢失上下文;分割得太长,则检索精度下降,且可能超出模型上下文窗口。

我们使用 RecursiveCharacterTextSplitter ,它会尝试按字符(如换行符、句号、空格)递归地分割文本,以尽可能保持段落或句子的完整。

def split_documents(documents: List[Document]) -> List[Document]:
    """将加载的文档分割成更小的片段"""
    text_splitter = RecursiveCharacterTextSplitter(
        chunk_size=500,  # 每个片段的理想字符数
        chunk_overlap=50,  # 相邻片段之间的重叠字符数,防止上下文断裂
        separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""] # 分割优先级
    )
    split_docs = text_splitter.split_documents(documents)
    print(f"原始文档分割为 {len(split_docs)} 个片段。")
    return split_docs

参数选择解析

  • chunk_size=500 :这是一个经验值。对于大多数问答任务,500-1000字符的片段能包含足够的信息(如一两个段落),又不会太长。你可以根据你的文档类型(技术手册段落长,对话记录段落短)进行调整。
  • chunk_overlap=50 :重叠部分是为了避免一个完整的句子或概念被硬生生切在两段之间,导致检索时信息不完整。50个字符通常能涵盖一个短句。

4.2 文本向量化与向量数据库存储

文本分割后,我们需要把它们变成计算机能高效比较的格式——向量。这里我们使用 sentence-transformers 库中的 all-MiniLM-L6-v2 模型。它是一个轻量级但效果很好的句子嵌入模型,能将文本转换为384维的向量。

# vector_store.py
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_community.vectorstores import Chroma
import os

def create_vector_store(documents, persist_directory="./chroma_db"):
    """
    为文档片段创建嵌入,并存储到Chroma向量数据库中。
    
    参数:
        documents: 经过分割的Document对象列表。
        persist_directory: Chroma数据库持久化存储的目录。
    """
    # 1. 初始化嵌入模型
    # `model_name` 指定使用的预训练模型,`all-MiniLM-L6-v2` 是一个很好的平衡速度和效果的选择。
    # `model_kwargs` 和 `encode_kwargs` 是传递给HuggingFace库的参数,这里设置使用CPU进行计算。
    embeddings = HuggingFaceEmbeddings(
        model_name="all-MiniLM-L6-v2",
        model_kwargs={'device': 'cpu'}, # 使用CPU,如果GPU内存足够可改为 'cuda'
        encode_kwargs={'normalize_embeddings': True} # 归一化向量,便于余弦相似度计算
    )
    
    # 2. 创建并持久化向量存储
    # Chroma.from_documents 会完成:将每个document内容通过embeddings模型转换为向量,然后存入数据库。
    # `persist_directory` 指定存储位置,下次可以直接加载,无需重新计算嵌入。
    vector_store = Chroma.from_documents(
        documents=documents,
        embedding=embeddings,
        persist_directory=persist_directory
    )
    
    # 3. 显式持久化(虽然from_documents可能已包含,但显式调用更安全)
    vector_store.persist()
    print(f"向量数据库已创建并保存至:{persist_directory}")
    return vector_store

def load_existing_vector_store(persist_directory="./chroma_db"):
    """加载已存在的向量数据库"""
    if not os.path.exists(persist_directory):
        raise FileNotFoundError(f"向量数据库目录不存在: {persist_directory}")
    embeddings = HuggingFaceEmbeddings(model_name="all-MiniLM-L6-v2")
    vector_store = Chroma(
        persist_directory=persist_directory,
        embedding_function=embeddings
    )
    print(f"已加载现有向量数据库,包含约 {vector_store._collection.count()} 个片段。")
    return vector_store

核心原理 all-MiniLM-L6-v2 模型将一段文本映射到一个384维的高维空间中的点。语义相似的文本,在这个空间中的点距离就近(余弦相似度高)。当我们提问时,问题也会被转换成同一个空间中的一个点,向量数据库的任务就是快速找到离这个“问题点”最近的几个“文档点”。

注意事项 :首次运行 create_vector_store 时,程序会从Hugging Face Hub下载 all-MiniLM-L6-v2 模型(约80MB),请确保网络通畅。生成向量(尤其是大量文档时)是计算密集型任务,可能需要一些时间。对于非常大的文档集,考虑分批处理。

5. 核心模块二:检索与生成链的构建

知识库准备好了,接下来就是设计“问答大脑”——即检索与生成(RAG)链。这个链负责接收用户问题,从向量库中检索相关文档,然后组织提示词,最后调用大模型生成答案。

5.1 连接本地 Ollama 模型

首先,我们需要让LangChain知道如何与本地运行的Ollama服务对话。

# llm_setup.py
from langchain_community.llms import Ollama

def get_local_llm():
    """
    初始化并返回本地Ollama LLM实例。
    确保在运行此代码前,已通过 `ollama run mistral:7b` 或其他命令启动了模型服务。
    """
    # Ollama类封装了与本地Ollama服务的交互。
    # `model` 参数指定使用哪个模型,必须与你在Ollama中拉取和运行的模型名称一致。
    # `base_url` 指向Ollama的API端点,默认是本地11434端口。
    # `temperature` 控制生成答案的随机性。0.0更确定、保守,1.0更富有创造性。对于知识问答,建议较低的值(如0.1)。
    llm = Ollama(
        model="mistral:7b",  # 或 "llama2:7b"
        base_url="http://localhost:11434",
        temperature=0.1
    )
    # 可选:进行一个简单的连通性测试
    try:
        response = llm.invoke("Hello")
        print("Ollama 模型连接测试成功。")
    except Exception as e:
        print(f"连接Ollama失败,请确保已运行 `ollama run mistral:7b`。错误: {e}")
        raise e
    return llm

5.2 构建检索器

检索器是向量数据库的接口,它定义了如何根据问题查找相关文档。我们使用“相似性搜索”,并设置一个相似度阈值,只返回相关性高的片段。

# retriever_setup.py
def get_retriever(vector_store, k=4, score_threshold=0.7):
    """
    从向量存储创建检索器。
    
    参数:
        vector_store: 已加载的Chroma向量存储对象。
        k: 返回的最相关文档片段数量。
        score_threshold: 相似度分数阈值,低于此值的片段将被过滤掉。
    """
    # 将向量存储转换为检索器对象,并配置搜索参数。
    # `search_type="similarity"` 表示使用余弦相似度进行搜索。
    # `search_kwargs` 中的 `k` 和 `score_threshold` 用于控制检索结果的数量和质量。
    retriever = vector_store.as_retriever(
        search_type="similarity",
        search_kwargs={"k": k, "score_threshold": score_threshold}
    )
    return retriever

参数调优建议

  • k :通常设置在3到5之间。太少可能信息不足,太多可能引入噪声并增加模型处理负担。
  • score_threshold :这是一个关键参数。它过滤掉低相关性的结果。阈值需要根据你的嵌入模型和文档类型进行实验调整。0.7是一个不错的起点,如果发现答案经常包含无关信息,可以调高(如0.75);如果经常检索不到任何内容,可以调低(如0.65)。

5.3 组装 RAG 链

现在,我们将检索器和大模型用LangChain的“链”组合起来。这里我们使用 RetrievalQA 链,它是一个高级抽象,封装了“检索-组装提示-生成”的全过程。

# rag_chain.py
from langchain.chains import RetrievalQA
from langchain.prompts import PromptTemplate

def create_rag_chain(llm, retriever):
    """
    创建并返回一个配置好的RAG问答链。
    """
    # 1. 定义提示词模板
    # 提示词是指导模型如何回答的“说明书”。一个好的提示词能显著提升答案质量。
    # `context` 是检索到的相关文档片段。
    # `question` 是用户的问题。
    # 我们明确要求模型只基于上下文回答,不知道就说不知道,并引用来源。
    prompt_template = """
    请严格根据以下提供的上下文信息来回答问题。如果上下文中的信息不足以回答问题,请直接说“根据提供的信息,我无法回答这个问题”,不要编造信息。

    上下文:
    {context}

    问题:{question}

    请给出准确、简洁的答案,并尽量引用上下文中的具体内容。
    答案:
    """
    
    PROMPT = PromptTemplate(
        template=prompt_template,
        input_variables=["context", "question"]
    )
    
    # 2. 创建链
    # `chain_type="stuff"` 是最简单的方式,它将所有检索到的上下文(不超过模型上下文长度)一次性塞进提示词。
    # 其他类型如 `map_reduce`、`refine` 适用于更长的文档,但更复杂。
    # `return_source_documents=True` 非常重要,它让我们能获取到答案所依据的源文档片段,实现可追溯性。
    qa_chain = RetrievalQA.from_chain_type(
        llm=llm,
        chain_type="stuff",
        retriever=retriever,
        chain_type_kwargs={"prompt": PROMPT},
        return_source_documents=True
    )
    return qa_chain

提示词工程心得 :提示词是控制模型行为的“方向盘”。上述模板强调了以下几点:

  1. 指令明确 :“严格根据以下提供的上下文信息”。
  2. 防止幻觉 :“如果信息不足...不要编造”。
  3. 格式化要求 :“给出准确、简洁的答案...引用上下文”。 在实际使用中,你可以根据模型的表现微调提示词。例如,如果模型总爱说“根据上下文...”,你可以去掉这个短语,让它直接回答。

6. 核心模块三:使用 Streamlit 构建交互界面

有了强大的后端RAG链,我们现在需要一个友好的前端界面。Streamlit让我们能用纯Python快速实现。

6.1 应用布局与状态管理

我们将创建一个多页面的应用:一个页面用于“知识库管理”(上传和处理文档),另一个页面用于“智能问答”。

# app.py
import streamlit as st
import os
import tempfile
from document_processor import load_documents, split_documents
from vector_store import create_vector_store, load_existing_vector_store
from llm_setup import get_local_llm
from retriever_setup import get_retriever
from rag_chain import create_rag_chain

# 设置页面标题和布局
st.set_page_config(
    page_title="我的本地知识库助手",
    page_icon="📚",
    layout="wide"
)

# 初始化关键的会话状态(Session State)
# Streamlit的Session State用于在页面重载间保持变量状态。
if 'vector_store' not in st.session_state:
    st.session_state.vector_store = None
if 'qa_chain' not in st.session_state:
    st.session_state.qa_chain = None
if 'processed' not in st.session_state:
    st.session_state.processed = False

# 在侧边栏创建导航
st.sidebar.title("导航")
page = st.sidebar.radio("选择页面", ["知识库管理", "智能问答"])

6.2 知识库管理页面实现

这个页面负责文档上传、处理,以及向量数据库的创建与加载。

# 接上面的 app.py
if page == "知识库管理":
    st.header("📁 知识库管理")
    st.markdown("在此上传您的文档(PDF/TXT/DOCX),系统将自动处理并构建可检索的知识库。")
    
    # 文件上传区域
    uploaded_files = st.file_uploader(
        "选择文件",
        type=['pdf', 'txt', 'docx'],
        accept_multiple_files=True,
        help="可同时上传多个文件"
    )
    
    # 向量数据库路径选择/输入
    db_path = st.text_input("向量数据库存储路径", value="./my_knowledge_db")
    
    col1, col2 = st.columns(2)
    
    with col1:
        # 按钮:处理上传的文档并创建新库
        if st.button("🚀 处理文档并创建知识库", use_container_width=True):
            if not uploaded_files:
                st.warning("请先上传文件。")
            else:
                with st.spinner("正在处理文档,这可能需要一些时间..."):
                    all_docs = []
                    for uploaded_file in uploaded_files:
                        # 1. 将上传的文件保存到临时位置
                        with tempfile.NamedTemporaryFile(delete=False, suffix=os.path.splitext(uploaded_file.name)[1]) as tmp_file:
                            tmp_file.write(uploaded_file.getbuffer())
                            tmp_path = tmp_file.name
                        
                        try:
                            # 2. 加载文档
                            docs = load_documents(tmp_path)
                            st.info(f"已加载文件: {uploaded_file.name},共 {len(docs)} 页。")
                            all_docs.extend(docs)
                        except Exception as e:
                            st.error(f"处理文件 {uploaded_file.name} 时出错: {e}")
                        finally:
                            # 3. 清理临时文件
                            os.unlink(tmp_path)
                    
                    if all_docs:
                        # 4. 分割文档
                        split_docs = split_documents(all_docs)
                        # 5. 创建向量存储
                        vector_store = create_vector_store(split_docs, persist_directory=db_path)
                        st.session_state.vector_store = vector_store
                        st.session_state.processed = True
                        st.success(f"知识库创建成功!共处理了 {len(split_docs)} 个文本片段。")
                    else:
                        st.error("未能加载任何有效文档内容。")
    
    with col2:
        # 按钮:加载已存在的知识库
        if st.button("📂 加载现有知识库", use_container_width=True):
            if os.path.exists(db_path):
                with st.spinner("正在加载知识库..."):
                    try:
                        vector_store = load_existing_vector_store(db_path)
                        st.session_state.vector_store = vector_store
                        st.session_state.processed = True
                        st.success(f"知识库加载成功!")
                    except Exception as e:
                        st.error(f"加载失败: {e}")
            else:
                st.warning(f"指定路径 '{db_path}' 下未找到已有的知识库。")
    
    # 显示当前状态
    st.divider()
    st.subheader("当前状态")
    if st.session_state.processed and st.session_state.vector_store:
        count = st.session_state.vector_store._collection.count()
        st.metric("知识库中的片段数量", count)
        st.success("✅ 知识库已就绪,可切换到「智能问答」页面进行提问。")
    else:
        st.info("ℹ️ 知识库未加载。请先上传文档处理或加载已有数据库。")

6.3 智能问答页面实现

这是用户与知识库交互的主要界面。

# 接上面的 app.py
elif page == "智能问答":
    st.header("💬 智能问答")
    
    # 检查知识库是否已准备就绪
    if not st.session_state.processed or st.session_state.vector_store is None:
        st.error("知识库未就绪!请先前往「知识库管理」页面创建或加载知识库。")
        st.stop() # 停止执行后续代码
    
    # 初始化LLM和RAG链(如果尚未初始化)
    if st.session_state.qa_chain is None:
        with st.spinner("正在初始化模型和问答链..."):
            try:
                llm = get_local_llm()
                retriever = get_retriever(st.session_state.vector_store, k=4, score_threshold=0.72)
                qa_chain = create_rag_chain(llm, retriever)
                st.session_state.qa_chain = qa_chain
                st.success("模型与问答链初始化完成!")
            except Exception as e:
                st.error(f"初始化失败,请确保Ollama服务正在运行。错误详情: {e}")
                st.stop()
    
    # 问答交互区域
    st.markdown("请在下方输入您的问题,助手将基于您已上传的文档进行回答。")
    
    # 问题输入框,并保留上一次的问题
    if 'last_question' not in st.session_state:
        st.session_state.last_question = ""
    
    question = st.text_input(
        "您的问题:",
        value=st.session_state.last_question,
        placeholder="例如:文档中提到的XXX是什么?"
    )
    
    # 高级选项(可折叠)
    with st.expander("高级选项"):
        col_a, col_b = st.columns(2)
        with col_a:
            k_slider = st.slider("检索片段数量 (k)", min_value=1, max_value=10, value=4, help="返回多少个相关文档片段给模型参考。")
        with col_b:
            threshold_slider = st.slider("相似度阈值", min_value=0.5, max_value=0.95, value=0.72, step=0.01, help="过滤掉低相关性的片段。")
        # 如果参数改变,则更新检索器(这里为了简化,每次提问都重新创建链。对于生产环境,可以考虑更优的状态管理)
        # 注意:频繁更改参数并重新创建链可能影响性能,此处仅为演示。
    
    # 提问按钮
    if st.button("发送", type="primary") and question:
        st.session_state.last_question = question # 保存问题
        with st.spinner("正在检索并生成答案..."):
            try:
                # 根据当前参数动态创建新的检索器和链(简单实现)
                current_retriever = get_retriever(st.session_state.vector_store, k=k_slider, score_threshold=threshold_slider)
                current_qa_chain = create_rag_chain(get_local_llm(), current_retriever)
                
                # 执行问答
                result = current_qa_chain.invoke({"query": question})
                
                # 显示答案
                st.subheader("答案:")
                st.write(result["result"])
                
                # 显示来源
                st.subheader("参考来源:")
                source_docs = result["source_documents"]
                for i, doc in enumerate(source_docs):
                    with st.expander(f"来源片段 {i+1} (相关性分数: {doc.metadata.get('score', 'N/A'):.3f})"):
                        # 显示元数据,如来源文件名和页码
                        source_info = f"**文件:** {doc.metadata.get('source', '未知')}"
                        if 'page' in doc.metadata:
                            source_info += f" | **页码:** {doc.metadata.get('page')+1}" # 页码通常从0开始,这里+1便于阅读
                        st.caption(source_info)
                        # 显示片段内容
                        st.text(doc.page_content[:500] + ("..." if len(doc.page_content) > 500 else "")) # 只显示前500字符
            except Exception as e:
                st.error(f"回答问题时出错: {e}")

7. 运行、测试与性能优化

7.1 如何启动整个应用

确保所有代码文件( app.py , document_processor.py , vector_store.py , llm_setup.py , retriever_setup.py , rag_chain.py )在同一个目录下,并且你已经完成了前面的环境准备步骤(安装了依赖、启动了Ollama模型)。

  1. 启动 Ollama 模型服务 :在一个终端窗口中,运行:

    ollama run mistral:7b
    

    保持这个窗口运行,它提供了模型服务。

  2. 启动 Streamlit 应用 :在另一个终端窗口(并确保在虚拟环境中),导航到项目目录,运行:

    streamlit run app.py
    

    Streamlit 会自动在浏览器中打开应用(通常是 http://localhost:8501 )。

  3. 使用流程

    • 在浏览器中,首先进入“知识库管理”页面,上传你的文档(例如一份产品说明书PDF),点击“处理文档并创建知识库”。
    • 处理完成后,切换到“智能问答”页面。
    • 在输入框中提问,例如“这款产品的主要特性是什么?”,点击发送。
    • 稍等片刻,你将看到基于文档生成的答案,以及答案所引用的原文片段。

7.2 常见问题与排查技巧实录

在实际搭建和运行过程中,你可能会遇到以下问题。这里是我踩过坑后总结的排查清单:

问题现象 可能原因 解决方案
启动Streamlit时提示缺少模块 虚拟环境未激活或依赖未安装完整。 1. 确认终端提示符前有 (rag_env)
2. 重新运行 pip install -r requirements.txt
Ollama连接失败 Ollama服务未启动,或模型未拉取。 1. 在新终端运行 ollama list 检查模型是否存在。
2. 运行 ollama run mistral:7b 启动服务并测试。
3. 检查 llm_setup.py 中的 base_url model 名称是否正确。
文档处理速度极慢 1. 文档太大或太多。
2. 生成嵌入(向量化)是CPU计算,较慢。
1. 对于超大文档,考虑先进行预处理或分批处理。
2. 如果有NVIDIA GPU且显存足够,可以将 HuggingFaceEmbeddings model_kwargs device 参数改为 'cuda'
问答时答案质量差,答非所问 1. 检索到的文档片段不相关。
2. 提示词不够明确。
3. 模型本身能力或温度参数问题。
1. 调整检索参数 :降低 k 值,提高 score_threshold
2. 优化文本分割 :调整 chunk_size chunk_overlap ,尝试按段落或标题分割。
3. 优化提示词 :在提示词中更强调“基于上下文”,并给出更具体的格式要求。
4. 调整模型参数 :降低 temperature 到 0.01 或 0,让答案更确定。
答案包含“根据上下文…”等多余内容 提示词模板中包含了引导性语句,模型在模仿。 修改提示词模板,去掉“请根据上下文…”这类引导句,直接要求“回答问题”。
Streamlit应用运行卡顿或内存不足 1. 向量数据库全部加载到内存。
2. 同时处理多个大文件。
1. Chroma在持久化模式下,查询时是动态加载的,压力不大。主要压力在模型。
2. 考虑使用更小的模型(如 phi 系列)。
3. 在“高级选项”中减少检索片段数量 k
无法读取某些PDF文件 PDF是扫描件(图片)或加密、特殊编码。 pypdf 只能处理文本型PDF。对于扫描件,需要先进行OCR(光学字符识别)。可以考虑使用 langchain UnstructuredPDFLoader 或先使用其他OCR工具处理。

7.3 性能优化与扩展思路

当你的知识库越来越大,或者希望应用更强大时,可以考虑以下方向:

  1. 嵌入模型升级 all-MiniLM-L6-v2 是速度和效果的平衡。如果需要更高精度,可以换用更大的模型,如 all-mpnet-base-v2 (768维),但计算和存储成本会上升。
  2. 检索策略优化 :除了相似性搜索,可以尝试 MMR (最大边际相关性) 搜索,它在保证相关性的同时,增加结果多样性,避免返回过于相似的片段。
  3. 引入对话历史 :当前的链是无状态的。你可以使用 ConversationalRetrievalChain ,它将记住之前的问答,实现多轮对话上下文理解。
  4. 元数据过滤 :在存储文档时,可以添加更多元数据(如文档类别、章节、日期)。检索时,可以结合元数据过滤,实现更精准的查询(如“在2023年的报告中找…”)。
  5. 前端美化与功能增强 :Streamlit支持丰富的组件。你可以添加聊天历史展示、答案评分反馈、文档预览、批量问答等功能。

构建这个本地RAG应用的过程,就像搭积木,每一步都有清晰的目标和成熟的工具。从文档处理到向量检索,再到模型生成和界面交互,我们打通了全链路。最让我有成就感的是,整个系统完全在本地运行,数据隐私掌握在自己手中,而且可以根据自己的需求灵活调整每一个环节。无论是用于个人学习笔记管理,还是作为团队内部知识库的雏形,这套框架都提供了一个坚实、可扩展的起点。在实际部署时,记得根据硬件条件(尤其是内存和显存)合理选择模型大小,并在提示词和检索参数上多做些测试微调,这往往能带来意想不到的效果提升。

Logo

免费领 200 小时云算力,进群参与显卡、AI PC 幸运抽奖

更多推荐