公司内部积累了上百篇技术文档之后,最痛苦的事不是“没有知识”,而是“找不到、读不完、对不上”。我当时的想法很简单:能不能让大模型先把我这些文档全部读一遍,然后我用自然语言提问,它直接告诉我答案、还要告诉我出处?动手查了一圈之后,锁定了RAG这条路,最终花了一个周末搭出了一套完全跑在本地、数据不出网的私有知识库。这篇文章就是那次搭建过程的完整记录,包含选型理由、可复现的代码、实测效果和踩坑清单,适合刚接触大模型、没写过复杂AI项目的小白,也适合想快速评估RAG能力的团队参考。
先说结论:整个过程不需要显卡训练,不需要自己写模型,只需要一台能联网安装软件的电脑,CPU也能跑通。核心物料只有三样——一个本地大模型负责回答,一个向量模型负责把文字变成长短不一的“坐标”,一个向量数据库负责存这些坐标。下面我按实际情况,把从零到能用的每一步拆开讲清楚。
1. RAG到底是什么,为什么私有知识库首选它
1.1 开卷考试类比
RAG的全称是Retrieval-Augmented Generation,中文叫检索增强生成。我不喜欢直接背名词,更愿意把它理解成“开卷考试”。
传统的大模型对话相当于“闭卷考试”,模型脑子里记住了什么,就只能答什么。你问它今天你们公司报销流程是什么,它大概率一脸茫然,因为这段知识根本不在训练数据里,或者早就过期了。就算你强行把公司制度写进提示词,很快也会把上下文撑爆,因为模型输入窗口有上限。
RAG的思路是反过来:考试的时候允许你带参考资料。用户提问后,系统先去你的资料库里快速翻找出最相关的几段内容,再把这些内容和问题一起“喂”给大模型,让大模型看了材料再作答。模型不需要记住你公司的细节,它只需要具备阅读理解能力和表达能力。这也是为什么RAG能以很小的成本让模型“学会”私域知识——我们不是改它的记忆,而是给它递纸条。
1.2 一条数据从文档到答案要经过两段流水线
一套完整流程可以拆成两条单向管道。
入库阶段:
文档读取 -> 文本切片 -> 向量化(Embedding) -> 写入向量库查询阶段:
用户问题 -> 向量化(Embedding) -> 向量库相似度检索 top_k -> 拼接上下文 -> 大模型生成回答这两条管道有一个共同依赖:向量模型。它做的事情通俗讲就是把一段文字映射成一串浮点数,例如几百个维度的数组。语义越接近的文字,计算出的数组在空间里距离越近。于是“查资料”就变成了“算距离”。
向量数据库负责高效存储这些数组,并按相似度把最接近用户问题的几段文本捞出来。之后大模型只需要在一个小窗口内阅读这几段文本,而不必读整本手册。
1.3 为什么不用微调
很多人第一反应是:为什么不直接微调大模型?微调本质上是在修改模型权重,让模型把新知识“背进脑子里”。但它有三个很现实的问题:
第一,成本高。微调需要显存、需要准备高质量标注数据、需要反复训练验证,一套流程下来不是小白周末能搞定的。第二,更新难。今天上传一份新文档,就要重新训练一次模型,内部知识是天天变的,显然不现实。第三,可解释性差。模型记住了哪些知识、为什么这么回答,你很难控制。
RAG相比之下像“插拔式”知识库。新增文档只需要重建索引,不需要碰模型;回答明明可以追溯到引用的哪一份文档;某个资料写错了,删掉对应切片重新索引就行。
提示:如果你的目标不是让模型掌握某类“表达风格”或“推理能力”,而只是让它了解一批资料内容,RAG永远应该作为第一优先的尝试方案。
2. 本套搭建选型:门槛最低但能跑完闭环的组合
2.1 一张选型表
技术选型我始终抱着一个原则:小白阶段不要追求最强,要追求“能跑、能看懂、能改”。我在搭建过程中试过不少组合,最终确定的最省心配置如下。
| 环节 | 本方案选择 | 备选方案 | 选择理由 |
|---|---|---|---|
| 本地大模型 | qwen2.5:7b(通过Ollama运行) | qwen2.5:3b、chatglm系列、llama3 | 中文理解能力好,7B尺寸在CPU上也能跑,14B以上对内存要求高 |
| Embedding向量模型 | bge-m3(通过Ollama运行) | nomic-embed-text、all-MiniLM | 中文语义效果好,支持中文文档检索,Ollama里直接拉取 |
| 向量数据库 | Chroma | FAISS、Milvus | Chroma零部署,一个Python库直接落盘为本地目录,对新手最友好 |
| 文档加载与切分 | LangChain工具链 | LlamaIndex、自己写正则 | LangChain组件齐全,文档加载器覆盖md/pdf/word,切片逻辑直观 |
| 大模型调用方式 | Ollama兼容OpenAI接口 | 直接HTTP调用 | LangChain的ChatOllama封装完善,代码少,出问题好排查 |
这套组合的好处是:没有独立服务进程需要维护,除了Ollama之外,其他东西都是Python包级别的依赖。整个知识库最终就是本地磁盘上的一个目录,拷走即迁移,不依赖云服务。
2.2 模型的算力与内存经验值
在动手前先给自己做一个硬件摸底。纯CPU跑7B模型不是不行,只是速度偏慢,适合个人测试;如果团队使用,建议至少有一块6GB以上显存的GPU。
我的实际体感数据:
| 配置 | qwen2.5:7b | 说明 |
|---|---|---|
| 纯CPU,16GB内存 | 每秒约5-10个token | 可用但耐心,简单问答能接受 |
| 8GB显存GPU | 流畅对话 | 推荐最低配置 |
| 32GB内存,无GPU | 运行流畅但不快 | 模型全量加载到内存,注意留足余量 |
所以如果你只是学习验证,不用纠结硬件,先用CPU跑通流程,后续再把模型切到更大的版本即可。内存不足时启动Ollama会报错,解决方式就是换小模型,例如qwen2.5:3b。
2.3 完全私有与半私有的取舍
私有知识库最敏感的环节是:数据到底在哪里被处理。如果使用云端大模型API,虽然方便,但你的内部文档内容会通过网络发送到第三方服务器,很多时候这一条就违反了合规要求。
本教程选择的方案是完全私有——大模型和向量模型都跑在本地,向量库也在本地。整个链路零公网调用。如果你只是个人学习,也可以考虑把大模型换成云端API,等熟练之后再切换回完全私有,但数据隔离这件事最好一开始就养成习惯,不要养成把内部资料往外发的路径依赖。
3. 环境准备与模型拉取
3.1 安装Ollama并启动服务
模型本地运行我用的是Ollama,原因就一个:省事。它把模型下载、运行、API暴露这三件事全部封装好了,不需要手动配置Python环境去推理,也不需要理解GPU算子库这些底层细节。
安装步骤非常简单:
- 打开Ollama官网,根据系统下载安装包。
- 安装完成后,Windows系统会在托盘区看到Ollama图标;macOS会在顶部菜单栏出现图标。
- 打开终端,执行命令验证:
ollama list如果命令正常执行且输出了空列表或已有模型列表,说明服务已经跑起来了。如果提示连接不上,手动执行:
ollama serve这个命令会启动本地服务,默认监听http://127.0.0.1:11434。后面所有代码都是往这个端口发请求。
注意:Ollama在macOS和Windows上安装后通常会自启,但有时候系统更新后服务不会自动拉起,所以第二个习惯是:出问题先看Ollama图标还在不在,或者检查11434端口有没有监听。
3.2 拉取LLM与Embedding模型
确认服务在线后,拉取两个模型。第一个是问答用的大模型:
ollama pull qwen2.5:7b第二个是向量模型:
ollama pull bge-m3拉取过程中会显示下载进度。模型体积比较大,7B模型通常4GB以上,bge-m3大概1GB左右,需要耐心等待网络下载完成。
如果你机器配置较低,可以把7b换成3b:
ollama pull qwen2.5:3b拉取完成后,用ollama list确认两个模型都在列表中。也可以先做一次快速验证,直接用Ollama的交互模式问一句:
ollama run qwen2.5:7b "你好,请简单介绍一下你自己"能看到回复就说明大模型链路通了。至于向量模型,Ollama没有交互式对话入口,它的验证会放在后面的索引脚本里一起做。
3.3 初始化项目目录与Python依赖
在某个工作目录下新建项目文件夹,比如rag-quickstart,内部结构如下:
rag-quickstart/ ├── docs/ # 放你的知识文档 ├── index.py # 入库脚本 ├── qa.py # 问答脚本 ├── requirements.txt └── chroma_db/ # 由脚本自动生成,向量库存放目录先创建虚拟环境,这是Python项目的基本操作,避免依赖互相污染:
cd rag-quickstart python -m venv .venvWindows激活命令:
.venv\Scripts\activatemacOS/Linux激活命令:
source .venv/bin/activate然后安装依赖。我建议不要一次装太多,按需安装更稳。先装核心部分:
pip install langchain langchain-community langchain-ollama chromadb提示:LangChain版本迭代很快,部分类的导入路径在不同版本间发生过调整。老教程里的
langchain.embeddings现在已经迁移到了langchain_community.embeddings或langchain_ollama。文章后面的代码我会用当前新版本推荐的路径,如果你跑的时候报“ModuleNotFoundError”,多半就是路径变化,按提示改一下导入语句即可。
如果你打算后面处理PDF,再补两个包:
pip install pypdf PyMuPDF装好后,本项目最小的依赖环境就齐了。
4. 文档加载与切片:知识库的“入库”环节
4.1 文档放哪、怎么放
在docs目录下放入你要建库的资料。最省事的格式是Markdown或纯文本txt,因为结构干净、编码可控。我用的是UTF-8编码的md文件,LangChain读取时几乎不会出问题。
如果你的资料主要是PDF和Word,初期不要急着处理格式问题。我建议先把它们批量转成Markdown或纯文本,再丢进docs目录。外部工具转完记得人眼扫一遍,我之前转过一批PDF,页眉页脚变成了一堆乱码夹在正文中间,索引进去之后检索结果惨不忍睹。
文档命名也有讲究。切片之后,每个片段会保留来源信息,如果你把文件名起成01-网络故障排查.md这种包含业务语义的名字,后面看来源列表时一眼就能定位。
4.2 切片策略到底怎么定
切片是RAG质量的第一道分水岭。你想让向量模型比较“谁跟谁相似”,它比较的对象不能太长,也不能太短。
太长:一段内容包含多个主题,向量化后语义被稀释,查“报销流程”时可能把“出差制度”那几段也捞出来,精度下降。太短:一句话一个片段,检索是准了,但大模型只看到孤零零一句话,缺少上下文,回答容易断章取义。
经验上我推荐先用“固定长度+重叠”的方式起步。每个切片约250个字符,切片之间重叠40个字符。重叠的作用是让片段的头尾都能保留前后文的过渡信息,避免一个语义完整的句子恰好被拦腰切断。
我对一叠内部文档做过对比测试:chunk_size=500时,回答的通顺度略好,但召回的片段明显更杂;chunk_size=100时,回答经常出现“只见树木不见森林”的毛病,引用内容像拼图碎片。250是个人测试下来比较平衡的起点值。
4.3 写索引脚本index.py
新建index.py,脚本代码如下:
import os from langchain_community.document_loaders import DirectoryLoader, TextLoader from langchain.text_splitter import RecursiveCharacterTextSplitter from langchain_ollama import OllamaEmbeddings from langchain_community.vectorstores import Chroma DOC_DIR = "./docs" CHROMA_DIR = "./chroma_db" # 1. 加载 docs 目录下所有 .md 文件 loader = DirectoryLoader( DOC_DIR, glob="**/*.md", loader_cls=TextLoader, loader_kwargs={"encoding": "utf-8"}, ) docs = loader.load() if not docs: print("没有读到任何文档,请检查 docs 目录下是否有 .md 文件") exit(1) # 2. 切片 splitter = RecursiveCharacterTextSplitter( chunk_size=250, chunk_overlap=40, ) chunks = splitter.split_documents(docs) print(f"共加载 {len(docs)} 份文档,切分成 {len(chunks)} 个片段") # 3. 向量化并写入 Chroma embedding = OllamaEmbeddings(model="bge-m3") vectorstore = Chroma.from_documents( documents=chunks, embedding=embedding, persist_directory=CHROMA_DIR, ) print(f"索引完成,向量库存放在 {CHROMA_DIR}")这段脚本逻辑很直白:加载、切片、向量化、落盘。执行方式:
python index.py第一次运行时Ollama会把bge-m3模型加载进内存,切片数量多的话需要等一会儿。看到“索引完成”就说明文档已经变成了向量库里可检索的内容。
重要:如果之后你换了别的embedding模型,或者改了切片参数,一定要删掉旧的
chroma_db目录再重新索引。不同向量模型生成的向量维度不同,语义空间也不同,新旧数据混在一个库里会导致检索结果完全错乱。这个坑我踩过一次,排查了半天才发现库里有两批不同模型的向量。
5. 向量检索与问答:知识库的“出库”环节
5.1 检索不神秘,就是向量相似度
现在文档已经变成了向量库里的一个个点。用户提问时,同样把问题向量化,然后在向量库里找几个距离最近的片段,把它们按顺序拼成上下文。
很多人第一次接触会误以为向量检索像数据库的“模糊查询”一样按关键词匹配。其实不是。向量模型学到的是语义,所以“报销需要几步”和“费用审批流程是什么”虽然没有一个字相同,向量距离却很近,照样能被召回。
这种语义召回能力强,但也有代价:模型对专业名词、代码片段、精确数字的敏感度有时不如关键词匹配。所以生产环境里通常会再加一层关键词过滤或者重排序,我把这部分放在文末优化方向里说。
5.2 写问答脚本qa.py
新建qa.py,代码如下:
from langchain_ollama import OllamaEmbeddings, ChatOllama from langchain_community.vectorstores import Chroma CHROMA_DIR = "./chroma_db" EMBED_MODEL = "bge-m3" LLM_MODEL = "qwen2.5:7b" # 加载向量库 embedding = OllamaEmbeddings(model=EMBED_MODEL) vectorstore = Chroma( persist_directory=CHROMA_DIR, embedding_function=embedding, ) def ask(question: str, k: int = 4): # 1. 检索 top-k 相关片段 results = vectorstore.similarity_search_with_score(question, k=k) # 2. 构造带编号的上下文 context_parts = [] source_parts = [] for i, (doc, score) in enumerate(results, 1): context_parts.append(f"资料[{i}]:\n{doc.page_content}") source = doc.metadata.get("source", "未知来源") source_parts.append(f"[{i}] {source} (相似度分数: {score:.4f})") context = "\n\n".join(context_parts) source_text = "\n".join(source_parts) # 3. 构造提示词 prompt = f"""你是一个企业知识库助手。请严格依据下方提供的资料回答用户的问题。 回答时请尽量引用对应的资料编号,例如“根据资料[1]”。 如果资料中没有相关信息,请直接说“当前知识库中未查询到相关资料”,不要编造答案。 资料: {context} 用户问题:{question} 回答:""" # 4. 调用本地大模型 llm = ChatOllama( model=LLM_MODEL, temperature=0.1, ) answer = llm.invoke(prompt) final = f"{answer.content}\n\n---\n引用来源:\n{source_text}" return final if __name__ == "__main__": print("知识库已连接,输入问题开始提问,输入 exit 退出") while True: question = input("\n提问:").strip() if question.lower() in ("exit", "quit"): break if not question: continue print(ask(question))这段脚本有几个设计,值得解释一下。
第一,similarity_search_with_score返回两个东西:片段文档和分数。Chroma里的分数本质是向量距离,数值越小代表越相似,跟很多人直觉里的“百分比相似度”正好相反。所以打印出来时,你看到0.6会以为是60%相似,其实是距离,需要自行理解。我在最开始也犯过这个迷糊,后来统一在代码里把分数明确写成“相似度分数”并注释说明。
第二,提示词里写了“请引用资料编号”。这是为了让大模型的回答可溯源。没有这个要求,模型会自由发挥把资料里的内容打乱重组,虽然内容没错,但你很难知道它到底看了哪几段。要求编号后,输出就会带[1]这类角标,对应到来源列表,方便使用者回查原文。
第三,temperature=0.1。知识库问答属于事实型任务,我们不需要模型放飞创意。低温度会让回答更稳定、更贴资料。如果是闲聊场景可以调高,这里不要。
5.3 让回答有出处:返回来源与置信度
运行问答脚本:
python qa.py提问后,下方会附带一个“引用来源”区块,里面是命中的文件路径和相似度分数。这个区块是RAG项目能否从“玩具”变成“工具”的关键——它让每一个回答都有据可查。
6. 实测演示:从“能参考”到“能落地”
6.1 放一份演示文档:内部故障手册
为了让你看到完整效果,我在docs目录下放了三份模拟文档,内容都是常见的运维场景。
01-网络故障排查.md:
# 网络故障排查 ## 现象一:某个业务页面打开缓慢 首先检查服务器CPU和内存占用,排除资源不足。 然后检查网络延迟,使用 ping 命令测试目标地址的丢包率。 如果丢包率高于5%,需要排查交换机端口和链路质量。 ## 现象二:无法访问内部系统 确认客户端是否在办公网内,检查网关地址是否正常。 使用 traceroute 命令查看路由路径,定位断点位置。02-账号权限申请流程.md:
# 账号权限申请流程 ## 普通账号申请 员工入职后,由部门负责人提交账号申请单。 审批通过后,管理员在2个工作日内创建账号。 ## 权限变更 权限变更需要填写变更申请单,说明原因和所需权限等级。 高权限账号需要部门负责人和系统管理员双重审批。03-常用命令速查.md:
# 常用命令速查 ping:用于测试网络连通性,可以附带 -t 参数持续测试。 traceroute:用于查看数据包经过的路由路径,定位网络断点。 df -h:查看磁盘使用率,排查磁盘写满导致的服务异常。6.2 三类问题实测
我连续问了三个问题,结果如下。
第一问:“访问内部系统失败,怎么排查?”
模型回答:
根据资料[1],可以先用 ping 命令测试目标地址的丢包率,再用 traceroute 命令查看路由路径,定位断点位置。同时确认客户端是否在办公网内,检查网关地址是否正常。
第二问:“账号权限不够用了,要走什么流程?”
模型回答:
根据资料[2],权限变更需要填写变更申请单,说明原因和所需权限等级;高权限账号需要部门负责人和系统管理员双重审批。
第三问(文档里没有的):“今天北京天气怎么样?”
模型回答:
当前知识库中未查询到相关资料,我无法回答天气问题。
三个结果分别验证了:能查、能引、能拒绝。尤其是第三个,它没有因为“你是个AI就必须回答”而硬编,说明提示词里的“拒答”话术起到了作用。
6.3 离大谱的得分怎么理解
打印出来的来源区块里会有一行“相似度分数: 0.56”之类的数字。前面说过,Chroma返回的是向量L2距离,值越小越相似。我的经验区间是:bge-m3模型下,距离在0.8以内通常已经比较相关,1.2以上基本是弱相关,超过1.5基本可以忽略。
每家嵌入模型、每个文档切片的数值区间都会不同,所以不要把这个分数当成绝对标准。更可靠的做法是设定一个粗略阈值过滤掉明显不相关的片段,再配合重排序优化。
7. 坑与解决:我自己踩过的几条
7.1 Ollama服务没起来:一看端口二看模型
最常见的问题就是代码报连接错误,类似ConnectionError: HTTPConnectionPool(host='localhost', port=11434)。这就是Ollama服务没起来。
排查顺序:第一看系统托盘有没有Ollama图标;第二手动执行ollama serve看服务是否启动;第三执行ollama list看模型在不在。还有一个隐蔽问题:装了多个模型,代码里指定的模型名和ollama list里的名字不完全一致,比如写成qwen2.5而实际拉的是qwen2.5:7b,会提示模型不存在。模型名严格按ollama list输出填写。
7.2 向量库跟着embedding模型绑定
这是RAG项目最容易犯的隐藏错误。我最初先用了nomic-embed-text建了一版索引,后来觉得中文效果不好换成了bge-m3。结果没有删旧库,Chroma目录里同时存在两种模型生成的向量。检索时新的查询向量和旧片段向量根本不是同一个宇宙的坐标,结果乱七八糟。
换嵌入模型后,务必删除旧的chroma_db目录重新执行索引。
7.3 切换包导入路径与文档编码
LangChain改版很快,网上的教程经常过时。langchain.embeddings在老版本存在,新版本已经拆到了langchain_community和langchain_ollama。代码里我用的是新路径,如果你用的是旧版本,反过来改即可。遇到ModuleNotFoundError不要慌,错误信息里会明确告诉你是哪个模块找不到,上网一查就知道到底该从哪个包导入。
另外就是文档编码。TextLoader默认UTF-8,如果你的txt或md是GBK编码的,读取会报编码错误。Windows记事本保存的文档常会遇到这种情况,解决办法是在loader_kwargs里指定encoding="gbk",或者统一把文档另存为UTF-8。推荐后者,一劳永逸。
7.4 prompt里的“拒答”话术很重要
不加“没有资料就直说不知道”这句话,模型在资料里找不到答案时也会硬答,甚至用自己脑中的“常识”补全,这就是RAG最常见的幻觉来源。加了这句话之后,模型的拒答率会明显提升。它不是100%可靠,但这是成本最低的一道防线。
如果发现模型明明答错了还引用资料编号,检查两个地方:一是温度是否太高,二是资料里是否真的没有相关片段。检索质量不好,模型再会推理也白搭。
7.5 中文乱码与命令提示符
在Windows命令行里直接跑python qa.py,输入中文和输出中文偶尔会出现乱码。这不是代码问题,是命令行编码问题。我建议用VSCode这类IDE直接运行Python文件,或者先执行chcp 65001切换UTF-8代码页。macOS和Linux终端基本没这个问题。
8. 从跑通到好用:下一步优化方向
8.1 重排序器
向量检索召回的是“语义相近”的片段,但相近不代表精准。一个更成熟的做法是把召回的top 50先粗筛一遍,再用一个cross-encoder重排序模型逐对打分,重新排序后取top 5。cross-encoder会把问题和片段同时输入模型做深度交互,效果通常比单纯向量距离好一个档次。
重排模型可以用CrossEncoder类加载一个通用重排序模型。它比嵌入模型慢,但因为只对几十个候选片段打分,整体耗时增加不多,值得一试。如果你对回答精度有要求,这是性价比最高的升级项。
8.2 更细的切块
固定长度切片是最通用的起点,但不是最优方案。文档本身有结构,比如Markdown的标题、列表、代码块。用MarkdownHeaderTextSplitter按标题层级切,可以保证每个片段都是一个相对完整的章节,语义更凝聚。
另一种进阶思路是“语义切块”,利用嵌入模型的相似度判断句子之间是否该断开。效果更好,但计算开销更大,适合文档量已经稳定在几百篇之后再做精细化处理。
8.3 接入更多文档类型
项目跑顺之后,你会想把PDF、Word、Excel都塞进去。LangChain里对应的加载器分别是PyMuPDFLoader、Docx2txtLoader、CSVLoader。其中PDF是最需要小心的,扫描版PDF需要OCR才能提取文字,这也是一个完全独立的坑。
我的建议是:先把流程跑通,再逐步扩展文档格式,不要一开始就把所有格式混在一起处理。
8.4 Agent化与多轮会话
目前的问答脚本每次都是独立的:问题来了,检索,回答。如果想要多轮对话,可以把历史对话也拼进提示词,让模型知道前文聊过什么。更进一步,可以让大模型决定是否调用检索工具,这就是Agent的雏形。不过对知识库问答来说,先保证单轮检索质量足够好,再考虑Agent化也不迟。
说回我自己的体会。这套方案搭完以后,我又陆续往里塞了不少团队文档,逐渐把知识库从“能跑”打磨成“好用”。最大的感受是:RAG真正难的地方不在写代码,而在于文档质量、切片粒度和检索阈值这一连串细节。它们每一项都不难,但每一项都会影响最终体验。如果你第一次跑通后觉得回答效果一般,不要怀疑方案本身,从切块大小和提示词着手调,通常很快就能看到变化。希望这篇文章能帮你绕过我踩过的那些坑,用最少的时间跑出属于你自己的私有知识库。