# 使用 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 服务,让大家通过浏览器访问。
这套方案最大的好处是完全可控、数据安全,而且用的都是开源技术,不存在被某个云厂商绑死的问题。对数据隐私要求高的企业来说,值得试试。