使用 Ollama + LangChain 构建本地文档问答系统

使用 Ollama + LangChain 构建本地文档问答系统

背景介绍

日常工作中有大量技术文档、论文和内部知识库需要处理。关键词搜索的痛点在于无法理解语义查询——搜"如何安装软件"和"安装步骤"本应返回相同结果,但传统搜索引擎办不到。

云端大语言模型能力很强,数据隐私是个问题。把内部文档上传到第三方 API 服务,多少有些顾虑。另外调用成本也是持续支出。

Ollama 出现后,本地运行 LLM 成为现实。配合 LangChain 的 RAG(检索增强生成)能力,可以在个人电脑上搭建完全本地化的文档问答系统。数据不出本机,语义检索替代关键词匹配。

这篇文章会详细演示:从零开始,用 Ollama 运行开源模型(Llama 3、Qwen 等),结合 LangChain 和 Chroma 向量数据库,搭建一个支持 PDF、Markdown、TXT 格式的本地文档问答系统。

问题描述

搭建这个系统之前,需要解决几个核心问题:

文档处理和语义检索:传统倒排索引只认关键词,不理解语义。需要把文档内容转成向量,用相似度搜索来实现语义匹配。

大模型选择:不同模型在推理能力、速度和硬件要求上差异很大。消费级 GPU 显存有限,7B 参数的模型是合理的起点。纯 CPU 运行的话,16GB 内存是底线。

文本预处理:文档需要分块、向量化、存储。分块策略直接影响检索效果——块太小丢失上下文,块太大引入噪音。

答案生成:检索到的内容如何组织成流畅的回答,需要设计合适的提示词模板,避免大模型 hallucinate(编造内容)。

下面用完整代码逐一解决这些问题。

详细步骤

环境准备

Ollama 支持 macOS、Linux 和 Windows。这里以 Ubuntu/Debian 为例:

# 安装 Ollama
curl -fsSL https://ollama.com/install.sh | sh

# 验证安装
ollama --version

模型选择方面,考虑到硬件限制,推荐 Qwen2:7B 或 Llama3:8B。纯 CPU 环境需要至少 16GB 内存才能流畅运行 7B 模型。

# 下载 Qwen2 7B(推荐,中文理解能力强)
ollama pull qwen2:7b

# 也可用 Llama3 8B
# ollama pull llama3:8b

# 查看已下载的模型
ollama list

安装 Python 依赖:

# 创建虚拟环境
python -m venv venv
source venv/bin/activate  # Linux/Mac
# venv\Scripts\activate  # Windows

# 安装 LangChain 和相关依赖
pip install langchain langchain-community langchain-ollama \
    chromadb langchain-text-splitters \
    pypdf pymupdf

文档处理与向量化

第一步是把各种格式的文档转成文本,然后分块。每个文本块转成向量,存入向量数据库。

创建 document_processor.py

"""文档处理模块:支持 PDF、Markdown、TXT 格式的文档加载和分块"""
import os
from pathlib import Path
from typing import List
from langchain_community.document_loaders import (
    PyPDFLoader,
    TextLoader,
    MarkdownLoader,
    UnstructuredMarkdownLoader
)
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.schema import Document

class DocumentProcessor:
    """文档处理器,支持多种格式的加载和分块"""

    def __init__(self, chunk_size: int = 500, chunk_overlap: int = 50):
        """
        初始化文档处理器

        Args:
            chunk_size: 每个文本块的最大字符数
            chunk_overlap: 相邻文本块之间的重叠字符数
        """
        self.chunk_size = chunk_size
        self.chunk_overlap = chunk_overlap

        # 根据不同文档类型配置加载器
        self.loaders = {
            ".pdf": PyPDFLoader,
            ".txt": TextLoader,
            ".md": UnstructuredMarkdownLoader,
            ".markdown": UnstructuredMarkdownLoader,
        }

        # 文本分割器配置
        self.text_splitter = RecursiveCharacterTextSplitter(
            chunk_size=chunk_size,
            chunk_overlap=chunk_overlap,
            length_function=len,
            separators=["\n\n", "\n", "。", ". ", " ", ""],
            keep_separator=True
        )

    def load_document(self, file_path: str) -> List[Document]:
        """加载单个文档"""
        path = Path(file_path)
        suffix = path.suffix.lower()

        if suffix not in self.loaders:
            raise ValueError(f"不支持的文档格式: {suffix}")

        loader_class = self.loaders[suffix]
        loader = loader_class(file_path)

        # 编码问题处理
        if suffix == ".txt":
            loader = TextLoader(file_path, encoding="utf-8")

        return loader.load()

    def load_directory(self, directory: str) -> List[Document]:
        """批量加载目录下的所有文档"""
        all_docs = []
        path = Path(directory)

        for file_path in path.rglob("*"):
            if file_path.is_file() and file_path.suffix.lower() in self.loaders:
                try:
                    docs = self.load_document(str(file_path))
                    # 为每个文档添加来源信息
                    for doc in docs:
                        doc.metadata["source"] = str(file_path)
                    all_docs.extend(docs)
                    print(f"已加载: {file_path.name}")
                except Exception as e:
                    print(f"加载失败 {file_path.name}: {e}")

        return all_docs

    def split_documents(self, documents: List[Document]) -> List[Document]:
        """将文档分割成小块"""
        return self.text_splitter.split_documents(documents)

# 测试代码
if __name__ == "__main__":
    # 示例用法
    processor = DocumentProcessor(chunk_size=500, chunk_overlap=50)

    # 加载单个 PDF
    # docs = processor.load_document("sample.pdf")

    # 加载整个目录
    # all_docs = processor.load_directory("./documents")
    # chunks = processor.split_documents(all_docs)

    print("文档处理器初始化完成")

向量存储与检索

创建 vector_store.py,用 Chroma 作为向量数据库,Ollama 提供嵌入模型:

"""向量存储模块:使用 Chroma 和 Ollama 嵌入模型构建向量数据库"""
import os
from typing import List, Optional
from langchain_ollama import OllamaEmbeddings
from langchain_chroma import Chroma
from langchain.schema import Document

class VectorStoreManager:
    """向量存储管理器"""

    def __init__(
        self,
        persist_directory: str = "./chroma_db",
        embedding_model: str = "nomic-embed-text",
        model_base_url: str = "http://localhost:11434"
    ):
        """
        初始化向量存储管理器

        Args:
            persist_directory: Chroma 数据库持久化目录
            embedding_model: 嵌入模型名称
            model_base_url: Ollama 服务地址
        """
        self.persist_directory = persist_directory
        self.embedding_model = embedding_model
        self.model_base_url = model_base_url

        # 初始化嵌入模型
        self.embeddings = OllamaEmbeddings(
            model=embedding_model,
            base_url=model_base_url
        )

        # 向量存储实例
        self.vector_store: Optional[Chroma] = None

    def create_vector_store(self, documents: List[Document]) -> Chroma:
        """从文档创建向量存储"""
        print(f"正在创建向量数据库,包含 {len(documents)} 个文本块...")

        self.vector_store = Chroma.from_documents(
            documents=documents,
            embedding=self.embeddings,
            persist_directory=self.persist_directory
        )

        print(f"向量数据库已保存至: {self.persist_directory}")
        return self.vector_store

    def load_vector_store(self) -> Chroma:
        """加载已存在的向量数据库"""
        if not os.path.exists(self.persist_directory):
            raise FileNotFoundError(f"向量数据库不存在: {self.persist_directory}")

        self.vector_store = Chroma(
            persist_directory=self.persist_directory,
            embedding_function=self.embeddings
        )

        print(f"已加载向量数据库,包含 {self.vector_store._collection.count()} 个向量")
        return self.vector_store

    def similarity_search(
        self,
        query: str,
        k: int = 5,
        filter: Optional[dict] = None
    ) -> List[Document]:
        """相似度搜索"""
        if self.vector_store is None:
            raise ValueError("向量数据库未初始化")

        return self.vector_store.similarity_search(
            query=query,
            k=k,
            filter=filter
        )

    def similarity_search_with_score(
        self,
        query: str,
        k: int = 5
    ) -> List[tuple]:
        """带分数的相似度搜索"""
        if self.vector_store is None:
            raise ValueError("向量数据库未初始化")

        return self.vector_store.similarity_search_with_score(
            query=query,
            k=k
        )

# 测试代码
if __name__ == "__main__":
    # 示例:创建新的向量数据库
    # from document_processor import DocumentProcessor
    # 
    # processor = DocumentProcessor()
    # docs = processor.load_directory("./documents")
    # chunks = processor.split_documents(docs)
    # 
    # manager = VectorStoreManager()
    # manager.create_vector_store(chunks)

    print("向量存储管理器初始化完成")

RAG 问答系统

核心部分:整合大模型和检索模块。创建 rag_qa_system.py

"""RAG 问答系统:整合检索和大模型生成答案"""
from typing import List, Optional, Dict
from langchain_ollama import ChatOllama
from langchain.chains import create_retrieval_chain
from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain.prompts import ChatPromptTemplate
from langchain.schema import Document
from vector_store import VectorStoreManager

class RAGQASystem:
    """基于 RAG 的问答系统"""

    def __init__(
        self,
        llm_model: str = "qwen2:7b",
        temperature: float = 0.3,
        vector_store_path: str = "./chroma_db",
        retrieval_k: int = 5
    ):
        """
        初始化 RAG 问答系统

        Args:
            llm_model: 大语言模型名称
            temperature: 生成温度,控制随机性
            vector_store_path: 向量数据库路径
            retrieval_k: 检索返回的文档数量
        """
        self.llm_model = llm_model
        self.retrieval_k = retrieval_k

        # 初始化大语言模型
        self.llm = ChatOllama(
            model=llm_model,
            temperature=temperature,
            base_url="http://localhost:11434"
        )

        # 加载向量数据库
        self.vector_manager = VectorStoreManager(
            persist_directory=vector_store_path
        )
        self.retriever = self.vector_manager.load_vector_store().as_retriever(
            search_kwargs={"k": retrieval_k}
        )

        # 构建提示词模板
        self._build_prompt()

        # 创建问答链
        self._build_chain()

    def _build_prompt(self):
        """构建提示词模板"""
        # 使用中文提示词,优化回答风格
        self.prompt = ChatPromptTemplate.from_template(
            """你是一个专业的技术文档问答助手。请根据以下参考文档回答用户的问题。

参考文档:
{context}

用户问题:{input}

要求:
1. 只根据提供的参考文档回答,不要编造信息
2. 如果参考文档中没有相关信息,请明确告知用户
3. 回答要简洁准确,使用中文
4. 在回答末尾注明参考来源

你的回答:"""
        )

    def _build_chain(self):
        """构建检索-回答链"""
        # 创建文档合并链
        self.combine_docs_chain = create_stuff_documents_chain(
            self.llm,
            self.prompt
        )

        # 创建完整的检索链
        self.retrieval_chain = create_retrieval_chain(
            self.retriever,
            self.combine_docs_chain
        )

    def ask(self, question: str) -> Dict:
        """
        提问并获取答案

        Args:
            question: 用户问题

        Returns:
            包含答案和参考来源的字典
        """
        # 执行问答
        result = self.retrieval_chain.invoke({"input": question})

        # 提取参考来源
        sources = []
        for doc in result.get("context", []):
            source_info = {
                "content": doc.page_content[:200] + "...",
                "source": doc.metadata.get("source", "未知")
            }
            sources.append(source_info)

        return {
            "answer": result["answer"],
            "sources": sources,
            "question": question
        }

    def ask_with_stream(self, question: str):
        """流式输出回答(如果模型支持)"""
        # 获取检索结果
        retrieved_docs = self.retriever.invoke(question)

        # 构建文档上下文
        context = "\n\n".join(doc.page_content for doc in retrieved_docs)

        # 构建完整提示
        full_prompt = f"""你是一个专业的技术文档问答助手。请根据以下参考文档回答用户的问题。

参考文档:
{context}

用户问题:{question}

要求:
1. 只根据提供的参考文档回答,不要编造信息
2. 如果参考文档中没有相关信息,请明确告知用户
3. 回答要简洁准确,使用中文

你的回答:"""

        # 流式调用
        for chunk in self.llm.stream(full_prompt):
            yield chunk.content

# 主程序入口
if __name__ == "__main__":
    # 初始化系统
    print("正在初始化 RAG 问答系统...")
    print("确保 Ollama 服务正在运行 (ollama serve)")

    qa_system = RAGQASystem(
        llm_model="qwen2:7b",
        temperature=0.3,
        vector_store_path="./chroma_db"
    )

    # 交互式问答循环
    print("\n=== RAG 问答系统已就绪 ===")
    print("输入问题进行咨询,输入 quit 或 exit 退出\n")

    while True:
        question = input("请输入问题: ")

        if question.lower() in ["quit", "exit", "q"]:
            print("再见!")
            break

        if not question.strip():
            continue

        print("\n正在思考...")

        try:
            result = qa_system.ask(question)

            print("\n" + "="*50)
            print("回答:")
            print(result["answer"])
            print("\n参考来源:")
            for i, source in enumerate(result["sources"], 1):
                print(f"  {i}. {source[\"source\"]}")
            print("="*50 + "\n")

        except Exception as e:
            print(f"Error: {e}")

构建索引脚本

最后创建 build_index.py,一键构建文档索引:

"""构建文档索引的脚本"""
import os
import argparse
from document_processor import DocumentProcessor
from vector_store import VectorStoreManager

def main():
    parser = argparse.ArgumentParser(description="构建文档向量索引")
    parser.add_argument(
        "--docs-dir",
        type=str,
        default="./documents",
        help="文档目录路径"
    )
    parser.add_argument(
        "--output-dir",
        type=str,
        default="./chroma_db",
        help="向量数据库输出路径"
    )
    parser.add_argument(
        "--chunk-size",
        type=int,
        default=500,
        help="文本块大小"
    )
    parser.add_argument(
        "--chunk-overlap",
        type=int,
        default=50,
        help="文本块重叠大小"
    )
    parser.add_argument(
        "--embedding-model",
        type=str,
        default="nomic-embed-text",
        help="嵌入模型名称"
    )

    args = parser.parse_args()

    # 检查文档目录
    if not os.path.exists(args.docs_dir):
        print(f"错误: 文档目录不存在: {args.docs_dir}")
        print("请创建目录并放入待索引的文档(PDF、Markdown、TXT)")
        return

    print(f"文档目录: {args.docs_dir}")
    print(f"输出路径: {args.output_dir}")
    print(f"文本块大小: {args.chunk_size}")
    print(f"嵌入模型: {args.embedding_model}")
    print("-" * 50)

    # 加载文档
    processor = DocumentProcessor(
        chunk_size=args.chunk_size,
        chunk_overlap=args.chunk_overlap
    )

    print("\n正在加载文档...")
    documents = processor.load_directory(args.docs_dir)
    print(f"共加载 {len(documents)} 个文档")

    # 分割文档
    print("\n正在分割文档...")
    chunks = processor.split_documents(documents)
    print(f"共生成 {len(chunks)} 个文本块")

    # 创建向量存储
    print("\n正在生成向量嵌入...")
    vector_manager = VectorStoreManager(
        persist_directory=args.output_dir,
        embedding_model=args.embedding_model
    )
    vector_manager.create_vector_store(chunks)

    print("\n" + "=" * 50)
    print("索引构建完成!")
    print(f"向量数据库位置: {args.output_dir}")
    print("=" * 50)

if __name__ == "__main__":
    main()

运行结果

索引构建

准备一个包含文档的目录,运行索引构建脚本:

# 创建文档目录并放入文档
mkdir -p documents
# 将 PDF、Markdown 或 TXT 文件放入 documents 目录

# 运行索引构建
python build_index.py --docs-dir ./documents --chunk-size 500

运行输出示例:

文档目录: ./documents
输出路径: ./chroma_db
文本块大小: 500
嵌入模型: nomic-embed-text
--------------------------------------------------

正在加载文档...
已加载: 技术文档.pdf
已加载: 常见问题.md
已加载: 使用指南.txt
共加载 3 个文档

正在分割文档...
共生成 156 个文本块

正在生成向量嵌入...
正在创建向量数据库,包含 156 个文本块...
向量数据库已保存至: ./chroma_db

==================================================
索引构建完成!
向量数据库位置: ./chroma_db
==================================================

问答测试

启动 Ollama 服务,运行问答系统:

# 确保 Ollama 服务正在运行
ollama serve

# 启动问答系统
python rag_qa_system.py

交互示例:

请输入问题: 如何安装这个软件?

正在思考...

==================================================
回答:
根据参考文档,安装步骤如下:

1. 下载安装包
2. 解压到指定目录
3. 运行安装脚本:
   ```bash
   ./install.sh
   ```
4. 配置环境变量

注意:安装前请确保系统满足最低配置要求(Python 3.8+,4GB 内存)。

参考来源:
  1. 使用指南.txt
  2. 技术文档.pdf
==================================================

另一个例子:

请输入问题: 这个工具支持哪些编程语言?

正在思考...

==================================================
回答:
根据已加载的文档,该工具支持以下编程语言:

- Python
- JavaScript/TypeScript
- Go
- Rust

详细的功能对比请参考技术文档中的语言支持章节。

参考来源:
  1. 技术文档.pdf
  2. 常见问题.md
==================================================

总结

这套系统的核心优势是数据完全本地化,不需要网络就能用。隐私敏感的场景下,这个方案比云端 API 安全得多。

技术链路其实很清晰:文档通过 LangChain 的加载器处理,用 RecursiveCharacterTextSplitter 分块,Ollama 的嵌入模型转成向量存进 Chroma。查询时先做向量检索,把最相关的文本块交给大模型生成答案。

几个可优化方向:模型层面可以换更强的 72B 模型;分块策略可以尝试按章节或段落而非固定字符数;加个对话历史缓存实现多轮对话;简单包装个 Web 界面给非技术用户使用。

个人开发者或小团队用这个方案,成本基本就是电费。不需要把内部文档交给第三方,自己掌控数据,部署一套专属的智能文档助手。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇