在本教程中,我将向您展示如何使用LangChain v1、Ollama、Qwen和Python来构建一个专用于处理个人文档的、基于RAG技术的问答型AI助手。

该助手会阅读您的文档,并结合引用的资料来回答与这些文档相关的问题;所有这些操作都在您的个人机器上完成,从而有效保护您的隐私。

目录

背景知识

我们大多数人的电脑里都存有各种笔记、PDF文件以及多年来收集到的文档。如果不知道该查看哪些文件,就很难找到所需的信息;而且像“LangChain是用来做什么的”这样的语义查询也无法得到解答。

普通的AI助手也无法解决这些问题。ChatGPT和Claude并不知道您的文件夹里有什么内容,而将文档上传给第三方服务提供商也不符合隐私保护的要求。对于个人笔记、内部文件或敏感资料来说,使用云托管的服务显然不是一个可行的选择。

在本教程中,我将向您展示自己是如何构建这样一个本地问答AI助手的——它能够阅读您的个人文档,并结合引用的资料来回答问题;所有这些操作都在您的个人机器上完成,因此完全不涉及任何API费用,所以它是完全免费的。

要学习本教程,您需要在自己的电脑上安装Ollama。该教程适用于macOS、Windows和Linux系统。我使用的是一台配备32GB内存的MacBook Pro,但如果您使用的内存较少,也可以选择Ollama中内存要求较低的Qwen模型来运行这个程序。

什么是RAG和LangChain?

RAG(检索增强生成)是一种让大型语言模型能够回答与其训练数据无关的问题的技术。它通过以下三个步骤来实现这一目标:

  1. 检索:找出与问题最相关的文档内容片段。

  2. 增强:将这些内容片段作为上下文信息添加到模型的输入中。

  3. 生成:让模型根据这些上下文信息产生合理的答案。

如果没有RAG技术,模型就会仅依据自己训练时使用的数据来回答问题;而有了RAG之后,模型就能利用更多相关的背景信息来给出更准确的回答。

为了使检索功能能够正常工作,嵌入模型会将内容以及用户提出的问题都转换成能够捕捉其含义的向量。然后,向量数据库会存储这些向量,并迅速找到与问题最为相似的数据片段。在本次教程中,我们将使用一个名为ChromaDB的开源向量数据库。

LangChain是一个用于构建大语言模型应用程序的框架。它提供了各种基础组件,你可以将这些组件作为开发各类AI应用的起点。

过去,实现RAG功能通常会使用LangChain的RetrievalQA框架,但如今这个组件已经不再被推荐使用了。因此,在本次实现中,我将采用LangChain v1的新架构,即代理+中间件模式来构建RAG AI系统。

项目动机与架构设计

开展这个项目的初衷,就是将我已拥有的各种文档转化为真正可以实用的工具。无论是工程笔记、研究论文、会议总结还是参考资料,我都希望能够用简单的英语进行查询,从而得到准确的答案,而且这些数据根本不需要离开我的机器。

使用本地RAG系统意味着我无需支付API费用,甚至可以在没有网络连接的情况下离线使用该系统。

对于这个项目,我将使用Ollama来运行本地的Qwen聊天模型和嵌入模型,利用LangChain将所有组件连接起来,并以ChromaDB作为本地向量数据库。下图展示了整个系统的架构组成。

整个流程分为两个阶段:索引阶段和查询阶段。

整个流程分为两个阶段。在索引阶段,代理程序会从指定文件夹中加载文档,将这些文档分解成较小的片段,然后将每个片段转换成向量形式,并将所有数据存储到ChromaDB本地向量数据库中。这个过程只需进行一次。

在查询阶段,当我提出问题时,代理程序会将这个问题转换成向量形式,然后通过相似性搜索在ChromaDB数据库中找到与之最为相似的数据片段,接着将这些片段连同问题一起发送给本地的Qwen大语言模型。该模型会根据实际存在的文档生成答案,而代理程序则会同时输出答案以及对应的原始文档。

第一步:安装Ollama并下载模型

首先,请根据你的操作系统安装Ollama应用程序。

对于这个项目,我们需要从Ollama中下载两个模型:一个是用于将文本转换为向量的嵌入模型(我选择使用nomic-embed-text),另一个则是用于生成答案的Qwen大语言模型。Qwen是一个开放源代码模型,目前属于规模较小但性能优秀的模型之一。我在本次项目中使用了qwen3.5:4b版本;如果你的机器内存有限,也可以使用qwen3.5:0.8b版本代替。

ollama pull qwen3.5:4b
ollama pull nomic-embed-text

步骤2:安装Python依赖项

python3 -m venv venv
source venv/bin/activate
pip install ollama langchain langchain-core langchain-text-splitters langchain-chroma langchain-ollama pypdf

本教程要求langchain版本大于或等于1.0.0。您可以使用以下命令升级现有的安装环境:

pip install -U langchain

步骤3:准备文档

在项目目录中创建一个名为docs/的文件夹,并将一些文件放入其中。该智能体默认支持PDF、Markdown和纯文本格式,您也可以混合使用这些格式。

mkdir docs
# 将您的PDF文件、.md格式笔记以及.txt文件复制到docs/文件夹中

步骤4:问答智能体的Python代码

这段代码主要完成四项功能:顶部的配置代码用于指定文档文件夹的位置、持久化向量存储的路径、本地使用的Ollama模型,以及用于分块处理和信息检索的参数设置。

load_documents()函数会遍历文档文件夹,将PDF文件、Markdown格式的内容以及纯文本文件加载到LangChain的Document对象中,并为每个文件标注其来源路径。
get_vectorstore()函数在首次运行脚本时,会将所有文档分割成若干块,然后使用本地的Ollama嵌入模型对每一块进行处理,最终将处理结果保存到磁盘上,这样后续运行时就能快速获取所需数据。
RetrieveDocumentsMiddleware模块负责实现问答功能:每当用户提出问题时,该中间件会从向量存储中检索出最相关的信息片段,并在模型处理问题之前将这些片段作为上下文信息提供给模型。
main()函数将所有这些组件整合到一起,通过create_agent()创建智能体对象,然后运行一个交互式循环,既输出答案,也会显示引用到的文档文件路径。
请将这段代码保存为qa_agent.py文件。

from pathlib import Path
from typing import Any

from pypdf import PdfReader

from langchain.agents import create_agent
from langchain.agents.middleware import AgentMiddleware, AgentState
from langchain_coredocuments import Document
from langchain_core.messages import SystemMessage
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_ollama import ChatOllama, OllamaEmbeddings
from langchain_chroma import Chroma

DOCS_DIR = "./docs" # 文档文件夹路径
DB_DIR = "./db" # 持久化向量存储文件夹路径
CHAT_MODEL = "qwen3.5:4b" # Ollama聊天模型
EMBEDMODEL = "nomic-embed-text" # Ollama嵌入模型
RETRIEVAL_K = 5 # 每次查询时返回的文档片段数量。如果答案不够完整,可以增加这个数值
CHUNK_SIZE = 1000 # 每个文档片段的最大字符数。如果希望得到更简洁的答案,可以尝试使用500;如果需要更多的背景信息,可以使用2000
CHUNK_OVERLAP = 200 # 不同文档片段之间共享的字符数量。这个参数有助于避免关键信息被分割开
def load_documents():
    docs = []

    # 遍历DOC_DIR下的所有文件
    for path in Path(DOCS_DIR).rglob("*"):
        # 如果文件是Markdown或纯文本格式,就将其加载到Document对象中
        if path.suffix.lower() in [".md", ".txt"]:
            docs.append(Document(
                page_content=path.read_text(encoding="utf-8", errors="ignore"),
                metadata={"source": str(path)}
            ))

        # 如果文件是PDF格式,就提取其中的文本并加载到Document对象中
        elif path.suffix.lower() == ".pdf":
            text = "\n".join(page.extract_text() or "" for page in PdfReader(str(path)).pages)
            docs.append(Document(
                page_content=text,
                metadata={"source": str(path)}
            ))

    return docs


def get_vectorstore():
    # 创建用于索引和搜索的嵌入模型
    embeddings = OllamaEmbeddings(model=EMBED_MODEL)

    # 如果已经存在持久化向量存储数据库,就直接使用它
    # 如果添加或修改了文档,或者改变了CHUNK_SIZE、CHUNK_OVERLAP或EMBEDMODEL的值,就需要重新创建数据库
    if Path(DB_DIR).exists():
        print(f"正在使用现有的{DB_DIR}数据库...")
        return Chroma(persist_directory=DB_DIR, embedding_function=embeddings)

    # 首先加载文档
    docs = load_documents()
    print(f>已加载{len(docs)}份文档,现在开始将它们分割成片段...")

    # 将文档分割成若干块
    chunks = RecursiveCharacterTextSplitter(
        chunk_size=CHUNK_SIZE,
        chunk_overlap=CHUNK_OVERLAP,
    ).split_documents/docs)
    print(f>已创建{len(chunks)}个文档片段,现在正在构建持久化向量存储数据库...")

    # 创建并保存持久化向量存储数据库
    vs = Chroma.fromdocuments(
        documents=chunks,
        embedding=embeddings,
        persist_directory=DB_DIR,
    )
    print(f>使用{len(chunks)}个文档片段成功构建了持久化向量存储数据库...)
    return vs


# 智能体除了包含标准的消息字段外,还多了一个用于存储检索到的文档的上下文字段
# State = { "messages": [], "context": [] }
class State(AgentState):
    context: list[Document]


class RetrieveDocumentsMiddleware(AgentMiddleware[State]):
    state_schema = State

    def __init__(self, vector_store):
        self.vector_store = vector_store

    def before_model(self, state: State) -> dict[str, Any] | None:
        # 获取用户的最新输入信息
        msg = state["messages"][-1]
        # 提取查询内容
        query = str(msg.content)

        # 从向量存储中检索最相关的文档片段
        docs = self.vector_store.similarity_search(query, k=RETRIEVAL_K)
        print(f>找到了{lendocs)}个相关的文档片段,现在将它们添加到上下文信息中,并传递给模型处理...")

        # 将检索到的文档片段格式化为字符串
        context = "\n\n".join(
            f"来源文件: {doc.metadata.get('source', 'unknown')}\n{doc.page_content}"
            for doc in docs
        )

        # 在用户输入的信息前面添加系统提示信息
        system_message = SystemMessage(
            content=f"{SYSTEM_PROMPT}\n\n上下文信息:\n{context}
")
        return {
            "messages": [system_msg],
            "context": docs,
        } 


def build_agent(vector_store):
    model = ChatOllama(model=CHAT_MODEL, temperature=0)

    # 创建带有文档检索中间件的智能体
    return create_agent(
        model=model,
        tools [], # 目前还没有工具,因为文档检索功能是由中间件负责的
        middleware=[RetrieveDocumentsMiddleware(vector.store)],
        state_schema=State, # 使用这个状态结构体
    )


def main():
    # 创建文档检索后端和智能体
    vector_store = get_vectorstore()
    agent = build_agent(vector_store)

    print("\n准备就绪!请针对您的文档提出问题。\n")

    while True:
        # 读取用户的输入信息
        question = input("您: ").strip()
        if not question or question.lower() == "exit":
            break

        # 运行智能体
        result = agent.invoke({
            "messages": [{"role": "user", "content": question}],
            "context": [],
        })

        # 处理智能体的响应结果
        # 结果中包含用户的输入信息、系统提示信息以及智能体的回答
        # State = { "messages": [user msg, system_msg, ai answer], "context": [doc1, doc2, ...] }
        print(f"答案: {result['messages'][-1].content}\n")

        # 显示引用到的文档文件路径
        print("参考文献:")
        seen = set()
        for doc in result.get("context", []):
            source = doc.metadata.get("source", "unknown")
            if source not in seen:
                print "-", source)
                seen.add(source)
        print()


if __name__ == "__main__":
    main()

步骤5:运行代理程序

python qa_agent.py

首次运行需要几分钟时间,因为系统会加载你的文档,将它们分割成多个部分,嵌入相应的信息,然后将其全部保存到本地的`./db`文件夹中。后续的运行会更快,因为代理程序会重用之前生成的向量存储数据。

如果你后来添加了新的文档,请删除`./db`文件夹,这样代理程序就会重新进行索引生成。

示例输出结果

当代理程序准备就绪后,你可以用普通的英语向它提出问题。答案是由本地的Qwen模型根据从你的文档中提取的数据生成的,并且会连同所引用的原始文件一起显示出来。

在相信任何答案之前,请先浏览一下相关的参考资料,对其中的一些内容进行核实。本地模型相比那些托管在远程服务器上的模型规模要小得多,因此也更容易出现错误,所以进行核实可以帮助提高答案的准确性。

作为一次测试,我让代理程序处理了一个包含我自己用Markdown格式整理的人工智能和大语言模型学习笔记的文件夹。下面展示了一次交互过程的示例:

结论

通过本教程,你学会了如何构建一个基于RAG技术的问答AI助手。该助手能够读取你自己的文档,并结合引用的资料来回答相关问题。整个系统都在你的个人电脑上运行,没有任何数据会离开你的设备。你可以完全控制模型、提示语以及检索逻辑,而且无需支付任何API费用。

接下来,可以尝试提出一些新的问题,看看这个助手如何处理不同类型的话题。调整“数据块大小”或“检索数量”,观察这些设置对答案质量的影响。也可以更换不同的模型,比如Qwen3.6、Llama 3或Mistral。此外,你还可以扩展脚本功能,让系统能够处理Word文档、网页内容,甚至是你的个人代码。尽情尝试吧!

如果你喜欢这个教程,可以在我的博客中找到更多我的文章(最近的文章包括一系列系统设计论文);也可以在我的个人网站上查看我的工作成果;同时,你还可以在LinkedIn上关注我的最新动态。

Comments are closed.