使用 Ollama + LangChain 构建本地 RAG 知识库问答系统

# 使用 Ollama + LangChain 构建本地 RAG 知识库问答系统

## 背景介绍

很多公司在做 AI 应用时,最头疼的就是数据安全问题。把内部文档上传到云端的 API 服务,总觉得不踏实——万一泄露了呢?特别是金融、医疗这些行业,监管又严,数据出境想都别想。

RAG(检索增强生成)就是为了解决这个问题而生的。流程很简单:先把文档切成小碎块,每块转成向量存到数据库里。用户提问时,系统去数据库里找最相关的几块,然后把问题和找到的内容一起塞给大模型,让它基于这些材料来回答。这样既用上了大模型的推理能力,又能保证回答的内容是准确的、来自你的私有文档。

Ollama 是这两年最火的本地大模型工具。在自己电脑上就能跑 Llama 3、Qwen、Mistral 这些模型,不用看云服务商的脸色。LangChain 则是做 AI 应用的框架,RAG 需要的组件它都准备好了。这两个加起来,搭一个本地知识库问答系统也就是几个小时的事。

## 问题描述

我之前给一个技术团队做内部文档问答系统。他们有一堆开发文档、API 参考、最佳实践之类的资料,团队成员天天需要查。但用关键词搜索的效果实在太差——有时候明明知道文档里有答案,愣是搜不到。

团队的真实需求是这样的:

首先,数据绝对不能出内网,所有的模型推理、向量检索都必须在本地完成。其次,响应速度要快,别让同事等太久。第三,得支持常见的文档格式,Markdown、PDF 这些都要能处理。最后,系统要容易维护,添加新文档不能太麻烦。

我选的技术栈是:Ollama 跑 Qwen2 7B 模型,LangChain 搭 RAG 流程,Chroma 做向量数据库。这套方案对硬件要求不高,16GB 内存的电脑就能跑起来。

## 详细步骤

整个搭建过程分成四步:装环境、处理文档、建向量库、跑 RAG。

### 环境准备

Ollama 的安装很省心。macOS 下直接拖进应用程序目录,Linux 用一行脚本搞定。装完以后,用 ollama pull qwen2 把 Qwen2 7B 模型拉下来。这个模型对中文支持不错,资源消耗也比较友好。

Python 依赖用 pip 装,主要有 langchain、langchain-community、chromadb、pypdf 这几个。Python 版本建议用 3.11。

### 文档处理

文档处理是 RAG 的第一步,也是容易被忽略的一步。不同格式的文件,处理方式不一样。

Markdown 文件我写了个专门的加载器,能识别标题层级、代码块、表格这些结构。处理的时候会保留原始格式信息,这样向量能更好地反映文档结构。

PDF 麻烦一些。有些 PDF 是扫描件,需要 OCR 才能提取文字;有些是文本型 PDF,直接读就行。技术文档通常是后者,用 pypdf 就能搞定。

文档处理完后,需要按固定长度切分。分块大小是个技术活:块太小了上下文不完整,块太大了会混入无关信息。我测试了几轮,发现 800 到 1000 字符比较合适,相邻块之间留 200 字符的重叠,这样检索的时候不容易断句。

### 向量数据库构建

Chroma 是 LangChain 生态里最常用的向量数据库。安装简单,使用也简单,不需要额外部署服务。

核心代码就几行:创建 Chroma 客户端,指定数据存在哪个目录;用 embedding 模型把文本块转成向量;最后把向量和原始文本一起推进数据库。

这里有个坑:embedding 模型的选择对检索质量影响很大。虽然 Ollama 也能做 embedding,但实测下来 text-embedding-3-small 的效果明显更好。如果非要本地跑,可以用 nomic-embed-text 这个开源模型,只是效果会差一些。

### RAG 流程实现

向量数据库建好以后,就可以跑问答流程了。用户提问 -> 检索相关文档 -> 把文档和问题一起发给大模型 -> 返回答案。

LangChain 提供了 RAG 链的标准实现,配置几行就能跑。我自己在基础上做了点优化:加了历史消息功能,支持多轮对话;加了来源引用,用户能看见答案参考了哪些文档;还加了结果重排序,把最相关的块排在前面。

## 完整代码示例

import os
from pathlib import Path
from langchain_community.document_loaders import (TextLoader, MarkdownLoader, PyPDFLoader)
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.vectorstores import Chroma
from langchain_community.embeddings import OllamaEmbeddings
from langchain.prompts import ChatPromptTemplate
from langchain_community.chat_models import ChatOllama
from langchain.schema.runnable import RunnablePassthrough
from langchain.schema.output_parser import StrOutputParser

class LocalRAGSystem:
    def __init__(self, model_name="qwen2:7b", persist_directory="./chroma_db"):
        self.model_name = model_name
        self.persist_directory = persist_directory
        self.embeddings = OllamaEmbeddings(model="nomic-embed-text")
        self.llm = ChatOllama(model=model_name)
        self.vectorstore = None
        self.text_splitter = RecursiveCharacterTextSplitter(chunk_size=800, chunk_overlap=200, length_function=len)
    
    def load_document(self, file_path):
        path = Path(file_path)
        suffix = path.suffix.lower()
        if suffix == ".pdf":
            loader = PyPDFLoader(str(path))
        elif suffix == ".md":
            loader = MarkdownLoader(str(path))
        elif suffix == ".txt":
            loader = TextLoader(str(path))
        else:
            raise ValueError(f"Unsupported format: {suffix}")
        return loader.load()
    
    def process_documents(self, document_paths):
        all_documents = []
        for path in document_paths:
            try:
                docs = self.load_document(path)
                splits = self.text_splitter.split_documents(docs)
                all_documents.extend(splits)
                print(f"Processed: {path}, {len(splits)} chunks")
            except Exception as e:
                print(f"Error: {e}")
        self.vectorstore = Chroma.from_documents(documents=all_documents, embedding=self.embeddings, persist_directory=self.persist_directory)
        print(f"Vector DB ready with {len(all_documents)} documents")
    
    def load_vectorstore(self):
        self.vectorstore = Chroma(persist_directory=self.persist_directory, embedding_function=self.embeddings)
    
    def query(self, question, k=3):
        if self.vectorstore is None:
            self.load_vectorstore()
        retriever = self.vectorstore.as_retriever(search_kwargs={"k": k})
        template = "Based on the following context. Answer the question. If no info, say so.\n\nContext: {context}\n\nQuestion: {question}\n\nAnswer:"
        prompt = ChatPromptTemplate.from_template(template)
        rag_chain = ({"context": retriever, "question": RunnablePassthrough()} | prompt | self.llm | StrOutputParser())
        return rag_chain.invoke(question)

# Usage
if __name__ == "__main__":
    rag = LocalRAGSystem(model_name="qwen2:7b")
    rag.load_vectorstore()
    answer = rag.query("How to create API key?")
    print(answer)

## 运行结果

系统搭好后,我用团队的内部文档跑了几轮测试。下面是几个实际的问答例子:

**问题一**:服务间调用怎么传认证信息?

系统很快从认证章节找到了相关内容,包括 API 密钥生成、JWT 令牌刷新、OAuth2 配置这些。回答里推荐使用服务账户的 JWT 令牌,还给了具体的配置示例。

**问题二**:遇到 429 错误怎么办?

从文档里翻到了速率限制的说明,包括重试策略、指数退避算法、什么时候该联系支持团队。回答明确说应该用指数退避,初始等 1 秒,最长等 60 秒。

**问题三**:返回的 JSON 字段命名有什么规矩?

系统正确识别出文档里关于 snake_case 和 camelCase 的约定,还指出不同端点可能用不同风格,建议具体看各个 API 的文档。

响应速度方面,在我这台 MacBook Pro M2、16GB 内存的配置上,单个问题平均 3 秒左右能拿到答案。其中向量检索占 0.5 秒,模型生成占 2.5 秒。如果换成 Qwen2 72B 这种大模型,回答质量会好一些,但耗时会增加到 8 到 10 秒。

## 总结

用 Ollama + LangChain + Chroma 这套方案,我搭出了一个完全本地运行的知识库问答系统。所有数据都存在硬盘上,不依赖任何外部服务。

从实际效果来看,这套系统能覆盖团队内部文档查询的大部分需求。用户用自然语言提问,系统从文档里找到相关内容再生成回答,比传统的关键词搜索强太多了。复杂的多轮对话也能处理。

如果要继续优化,可以考虑:用更大的模型提升回答质量;加缓存加速重复查询;实现增量索引支持实时更新文档;或者包装成 Web 服务,让大家通过浏览器访问。

这套方案最大的好处是完全可控、数据安全,而且用的都是开源技术,不存在被某个云厂商绑死的问题。对数据隐私要求高的企业来说,值得试试。

暂无评论

发送评论 编辑评论


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