☰
从零搭建本地RAG知识库:内网文档智能问答实战解析
2026/10/14 11:01:40 网站建设 项目流程

前前后后折腾了快三个月,终于把这套东西跑通了。起因其实很朴素:手头有一批内部资料——产品文档、售后手册、会议纪要,还有大量现场截图,散落在网盘和共享目录里,每次找点东西要翻半天。有人提议接在线大模型,把文档传上去直接问,但这边有明确要求,资料不能出内网,第三方平台一律不能碰。于是只能自己动手,用一台淘来的二手服务器,把全部资料做成一个本地RAG问答机器人,不联网、不上传,连截图里的文字都能搜到。这篇文章就记录这套系统的完整落地过程,从硬件选型、架构设计到具体代码,再到我踩过的那些坑,希望能给同样被内部资料检索折磨的人一点参考。

1. 项目缘起:为什么非要自己搭本地RAG

1.1 数据出不了内网,在线方案直接出局

真正让这个项目立项的,不是"想用AI"这个念头,而是一条硬性规定:核心资料不允许上传到任何第三方平台,内网之外一律不准访问。市面上那些开箱即用的智能问答工具,无论宣传得多安全,本质上都是把数据发到云端做处理和推理,这条红线一画,选项基本就剩两个:要么维持原始的文件检索方式,要么就在内网自己搭一套能和文档对话的系统。

我选了后者。原因很直白:文件量已经积累到几万份,靠网盘搜索和人工翻阅基本等于大海捞针。而RAG(检索增强生成)解决的核心问题,恰恰是"从海量私有文档中精准找到相关片段,再让模型基于这些片段作答"。它不需要微调模型,不需要重新训练,文档更新后重新过一遍索引就行,这套逻辑特别适合内部知识库这种动态更新的场景。

1.2 RAG流程不复杂,难在完全本地化

RAG的标准流程说起来挺简单:文档切块、向量化、存库;查询时把用户问题也向量化,召回最相关的片段,交给大模型组织答案。但在"完全不联网、不上传"的前提下落地,每一环都有额外的约束:

  • 向量化要跑本地embedding模型,不能调线上接口
  • 大模型必须本地部署,还要考虑内网机器能不能扛得住推理
  • 截图检索意味着要加一层OCR,识别质量直接决定召回效果
  • 纯向量检索对专有名词、编号、型号这类信息容易失灵,得结合关键词检索

这些约束叠加在一起,就把一个"看起来很简单"的架构,变成了一个需要反复调优的系统工程。

1.3 硬件选型:二手服务器够不够用

机器是某二手平台淘的旧企业服务器,双路E5,64GB内存,没上独立GPU,配了两块512GB的SATA SSD。这个配置谈不上豪华,但跑本地RAG是够的,原因是这套系统的算力大头其实不在GPU上——embedding模型很轻量,纯CPU就能跑;7B量级的量化大模型用CPU推理,配合量化格式和推理框架优化,单条问题的响应大概在5到15秒,对内部知识查询场景完全能接受。

注意:选二手服务器不要只盯着CPU主频。RAG对CPU性能不敏感,但对内存容量非常敏感,向量库和模型都吃内存,建议最低32GB起步,64GB比较舒服。另外如果你后续想上更大的模型或者加reranker重排,最好确认机器有预留PCIe插槽,以便加一块二手GPU。

硬盘我做了分区规划:系统、向量库和模型文件放一块SSD,原始文档和临时转换产物放另一块,避免索引写入时IO抢占。数据盘单独挂载还有一个好处:系统重装不会影响已构建的向量库和原文件,这一点在后来的维护中帮了大忙。

2. 架构设计与组件选型

2.1 整体管线:五个环节串起"文档→可问答"

整个系统我用一条流水线来理解,每个环节的产出都是下一个环节的输入:

  • 文档接入:支持PDF、Word、Excel、纯文本,还有图片和截图
  • 解析与OCR:文本类直接抽取内容,图片类先做文字识别
  • 切块与向量化:按语义和结构边界切分,逐块生成向量
  • 索引存储:向量与原文、元数据一并写入本地向量库
  • 查询问答:用户问题先向量化,再召回片段,最后交大模型生成答案

这套管线最核心的设计原则是"解耦"。每个环节独立运行、独立更新,比如只新增了一批截图,那就只跑OCR和向量化这两段,不需要重新处理全部文档。解耦也让排查问题变得简单,哪一步出问题,单独看哪一步的日志就行。

2.2 组件选型的取舍过程

组件选择这部分我花的时间最多,因为每个环节都有多个开源方案可用,但适配"完全离线+中文为主+单机资源有限"这三个条件的组合并不多。最终选型如下表:

环节我用的方案备选方案选择理由
Embedding模型BGE系列中文模型M3E、text2vec中文效果稳定,支持长文本,开源权重可直接下载
向量库ChromaQdrant、Milvus轻量、单机够用、无需单独起服务,自带持久化
大模型Qwen系列量化版(7B)ChatGLM、Llama系列中文能力强,量化后CPU推理可接受
推理框架Ollamallama.cpp、vLLM部署最简单,自带API,模型管理方便
OCR引擎PaddleOCRTesseract中文识别率明显更好,支持版面分析

2.3 为什么没选更重的方案

向量库我一开始想上Milvus,毕竟听起来更专业,还支持分布式。后来权衡了一下:单机、数据量在几十万条向量以内、不需要多副本,用Chroma足够,部署复杂度低,API简洁,数据落盘稳定。Qdrant也不错,但多一个服务进程就多一个排查点,本地项目越简单越好。

大模型的选择逻辑同样如此。7B量化模型在CPU上跑,配合Ollama的并发设置,能同时应付两三个人查询不卡死。如果使用人数增多,就把模型降到4B档位,或者加一张几百块的二手GPU,整体成本也比购置新服务器低得多。这里要说句实在话:单机场景下,别追求模型参数越大越好,能稳定输出、响应及时的模型才是好模型。

3. 核心实操:从零把本地RAG跑起来

3.1 环境准备:Python环境与Ollama部署

系统我用的Ubuntu Server 22.04 LTS,装好之后第一步是建Python虚拟环境,避免把系统Python搞乱:

sudo apt update && sudo apt install -y python3-venv python3-pip mkdir ~/rag-project && cd ~/rag-project python3 -m venv venv source venv/bin/activate pip install chromadb sentence-transformers pdfplumber python-docx openpyxl paddleocr paddlepaddle

然后安装Ollama,它是本地大模型推理最省事的方案,一条命令装完,后续拉模型、起服务都很方便:

curl -fsSL https://ollama.com/install.sh | sh ollama pull qwen2.5:7b

提示:拉取模型需要一次外网下载,下载完成后推理过程完全不依赖网络。如果内网环境连下载都不允许,可以用另一台能上网的机器下好模型文件,再通过U盘或内网传输拷入Ollama的模型目录。实际使用中,Ollama支持导入GGUF格式的模型权重,这条路也能走通。

装完以后,Ollama默认监听本地11434端口,直接HTTP调用即可。为了验证,可以先跑一条简单提问,确认模型能正常输出。这里我习惯设置一个环境变量来限制模型并发数,防止多个人同时查询时内存暴涨:

export OLLAMA_NUM_PARALLEL=1 export OLLAMA_MAX_LOADED_MODELS=1

3.2 文档解析与OCR识别:两种路径分别处理

文本类文档的处理相对直接。PDF用pdfplumber逐页抽取,Word用python-docx读取段落和表格,Excel用openpyxl遍历工作表。这里有个容易忽略的细节:很多PDF其实是扫描件,表面上看是PDF,本质上是图片,直接抽文本什么都抽不到。我的处理办法是先尝试抽文本,如果整页提取结果少于20个字符,就自动转成图片交给OCR流程处理。

截图和图片类的OCR,我用的PaddleOCR。它支持中英文识别,还带有版面分析能力,能识别出标题、正文、表格区域。核心调用代码:

from paddleocr import PaddleOCR ocr = PaddleOCR(use_angle_cls=True, lang='ch', show_log=False) def ocr_image(path): result = ocr.ocr(path, cls=True) lines = [] for block in result: for line in block: text = line[1][0] lines.append(text) return "\n".join(lines)

识别出来的文字我会连同图片路径、拍摄时间等信息一起存入元数据,方便后续检索时过滤。这里有一个非常关键的实操点:OCR识别结果要保留原始图片的引用关系。因为有时候用户搜到一段文字,还是想看原始截图的上下文,所以在向量库里我存的不只是文字块,还保留了一个字段指向原始图片文件路径,回答问题时可以把对应图片路径一并返回。

3.3 切块策略:既要语义完整,也要控制长度

切块是决定检索质量的核心环节。切得太碎,语义不完整,模型拿到的上下文缺胳膊少腿;切得太大,一个块里包含多个主题,向量会被稀释,召回精度下降。我最终用的是"结构化优先 + 按大小兜底"的组合策略:

from langchain_text_splitters import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=80, separators=["\n## ", "\n### ", "\n\n", "\n", "。", ";", ",", " ", ""] )

分隔符的顺序很讲究:优先按Markdown标题切,其次是段落换行,再是中文句号、分号、逗号,最后才按字符数硬切。这样切出来的块,绝大多数能保持一个相对完整的语义单元。chunk_size我试过256、500、800三档,500在检索准确率和上下文的完整度之间最平衡。

chunk_overlap设置为80字符,目的是避免一句话被拦腰截断后丢失信息。比如"本产品不支持在虚拟机环境下运行"这句话,如果正好被切在"不支持"后面,前半块和后半块的语义都会残缺,重叠区域能在一定程度上缓解这个问题。但重叠不是越大越好,太大会导致相邻块内容高度重复,检索时同一段信息被重复召回,浪费上下文窗口。

3.4 构建向量库与增量更新

embedding模型我用的是BGE的中文模型。第一次跑的时候,几万份文档过了一遍,向量化的耗时是最大的瓶颈,纯CPU上每分钟大概只能处理几十个文本块。优化办法是启用batch处理,把文本块攒到一定数量再一次性编码:

from sentence_transformers import SentenceTransformer model = SentenceTransformer('BAAI/bge-large-zh-v1.5') model.max_seq_length = 512 def embed_texts(texts): return model.encode(texts, batch_size=32, normalize_embeddings=True, show_progress_bar=True)

注意我加了normalize_embeddings=True,这个参数容易被忽略但非常重要。归一化之后,向量相似度计算等价于余弦相似度,能保证向量库检索时的距离度量和后续reranker的输入口径一致。

向量写入Chroma时,每条记录包含文本内容、来源文件路径、文档标题、页码、时间戳等元数据。文档全量构建完成之后,增量更新就很简单,新文件或修改过的文件,重跑解析、切块、embedding三件事,按文件路径去重后写入即可。我写了一个简单的增量脚本,每次扫描目录里mtime变化的文件,只处理变化的部分,几万份文档的维护成本因此降到了每次不到一支烟的功夫。

3.5 查询链路:召回、重排与生成

查询侧的逻辑是这样的:用户输入问题后,先用同样的embedding模型把问题向量化,去Chroma里做相似度检索,召回top_k个相关片段;为了提高精度,我接了一个reranker重排模型,对召回的候选重新打分排序,只取前3到5段送给大模型;最后在系统提示词里明确要求模型"只能依据给定材料回答,不得编造",再把片段拼接进上下文,走Ollama的API生成答案。

核心查询代码:

import requests def ask(question): q_vec = embed_texts([question])[0] results = collection.query( query_embeddings=[q_vec.tolist()], n_results=8, include=["documents", "metadatas", "distances"] ) docs = results["documents"][0] metas = results["metadatas"][0] # 重排:用reranker重新打分,取前5 pairs = [(question, doc) for doc in docs] scores = reranker.compute_score(pairs) ranked = sorted(zip(docs, metas, scores), key=lambda x: x[2], reverse=True)[:5] context = "\n\n---\n\n".join( f"【来源:{m['source']} 第{m.get('page', '?')}页】\n{d}" for d, m, s in ranked ) prompt = f"""你是内部知识库助手。请严格依据以下资料回答问题。 如果资料中没有相关信息,请直接说明未找到,不要编造。 资料: {context} 问题:{question} 回答:""" resp = requests.post("http://localhost:11434/api/generate", json={ "model": "qwen2.5:7b", "prompt": prompt, "stream": False, "options": {"temperature": 0.1, "num_ctx": 8192} }).json() return resp["response"]

temperature设到0.1,是为了让回答尽量稳定、忠于原文,而不是发挥创造力。内部知识问答场景,宁可回答保守一些,也不要满嘴跑火车。num_ctx设到8192,是给拼接的上下文留足空间,但也不要开太大,否则推理时间会明显变长。

4. 踩坑实录与排查技巧

4.1 OCR的"最后一公里"问题

原以为OCR是最不需要操心的环节,结果恰恰是它在前期坑了我最多次。典型的坑有三个:

第一,低分辨率截图识别率惨不忍睹。有些现场截图是从聊天记录里保存下来的,图片本身压缩严重,文字边缘模糊,PaddleOCR直接识别出来一堆乱码。解决办法是在识别前先做图像预处理:灰度化、二值化、放大两倍再送识别。实测下来,光加一个放大操作,识别准确率至少提升两成。

import cv2 def preprocess(path): img = cv2.imread(path) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) gray = cv2.resize(gray, None, fx=2, fy=2, interpolation=cv2.INTER_CUBIC) _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) return binary

第二,截图里的表格识别成文字后顺序混乱。PaddleOCR默认按检测框顺序输出文字,表格区域的阅读顺序经常是乱的,把表头和表内容切得七零八落。我的对策是遇到明显的表格区域,改用PaddleOCR的表格识别模型单独处理,把表格结构还原成Markdown格式后再入库,检索效果好了很多。

第三,竖排文字和倾斜文字识别率低。这个没有太好的办法,只能靠提高图片质量来缓解。我的处理原则是:OCR结果如果连续多行识别置信度低于阈值,就把该图片标记为"低质量待人工复核",而不是让错误文本进向量库。宁可少收一点,也不要收一堆错误的进去污染检索结果。

4.2 中文切块引发的召回率雪崩

最开始我图省事,直接用英文社区常用的固定大小切块,每512字符切一段。上线测试后发现问题很诡异:有些问题明明文档里有明确答案,却召不回相关片段。

排查后发现是切块把中文的语义单位切碎了。中英文不一样,英文靠空格天然分词,中文一句话经常二十个字讲完一层意思,机械按字符数切,很容易把一个完整的观点切成两半。比如"该接口仅支持HTTPS协议,不支持HTTP明文传输"这句,如果恰好从"不支持"中间切断,两半的语义都变了。

解决办法就是前面提到的结构化切分:优先按标题层级和段落边界切,实在没有边界才按字符数硬切。切完之后我还加了一个校验逻辑——每个chunk的最后一句如果以逗号结尾,就说明是拦腰切断,自动调整边界到最近的句号。这个细节对中文RAG的检索质量提升非常明显。

4.3 资源占用:64GB内存怎么分配才够用

CPU推理7B模型,内存占用比想象中高。实测下来,Ollama加载qwen2.5:7b的Q4量化版本,大约占6GB内存;embedding模型加载后占2GB;Chroma的持久化索引加上加载到内存的向量数据,文档量大的时候能占到4GB以上;再加上系统本身、OCR进程、Python进程,64GB内存表面上充裕,但多人同时查询时还是会出现抖动。

我的分配策略是给Ollama限制最大内存使用,同时给OCR处理单独设置一个并发上限,避免批量处理文档时把内存吃满:

export OLLAMA_KEEP_ALIVE=30m

OLLAMA_KEEP_ALIVE的意思是模型在空闲30分钟后自动卸载,既保证了常用时段查询不用反复加载模型,又能在长时间没人用时释放内存。另外,批量向量化时,千万不要一次性把所有文档读入内存,加载一批、处理一批、写库一批,循环执行,否则处理到一半机器就可能开始疯狂swap。

4.4 检索质量速查表

把调试期间遇到的问题和对应解法整理成一个速查表,方便大家直接对照:

现象可能原因排查与解法
相关问题召回不到切块切碎了语义单位改用结构化切分,按标题/段落边界切,强制句号收尾
召回的片段不相关top_k取值过大调低top_k,增加reranker重排
回答内容与原文不符上下文被无关片段干扰提高重排后送入模型的门槛,只保留前3-5段
专有名词搜不到纯向量检索对精确词不敏感叠加BM25关键词检索,合并召回结果
相同问题答案飘忽不定temperature过高调到0.1以下,必要时设为0
增量更新后老答案还在旧向量未清除按source字段先删后插
OCR文本乱码入库识别置信度低加置信度阈值过滤,低于阈值标记人工复核
CPU推理太慢上下文窗口开太大把num_ctx从8192降到4096,观察效果

BM25关键词检索是我后期加的一个模块,因为向量检索对"型号编号"这类精确词容易失灵,比如用户输入"XG-2000"这样的编号,向量相似度往往不如关键词精确匹配来得直接。实现方式很简单,用jieba分词后构建倒排索引,和向量召回结果做加权合并,实测对型号、编号、专有名词的检索提升非常显著。

4.5 一个容易被忽略的细节:文档版本覆盖

内部文档最大的特点就是改版频繁。同一个产品手册,v1.2、v2.0、v3.1可能同时存在,向量库里三个版本的内容全混在一起。用户问"当前最新的安装步骤",如果直接把三个版本都召回,模型很可能被旧版本带偏。

我的解决办法是在元数据里加一个"版本号"字段和"生效日期"字段,查询时优先过滤出最新版本,只有在最新版本里找不到答案时才回溯旧版。这个逻辑不复杂,但对回答的一致性影响非常大,属于内部知识库场景下必须处理的问题。

5. 实际使用体验与最后的一些建议

系统上线到现在跑了两个多月,几万份文档、上千张截图全部可检索。同事们的使用反馈中,提到最多的两个亮点:一是截图能搜了,以前找一张历史现场照片要靠记忆翻聊天记录,现在直接用文字描述画面内容就能定位;二是回答都带来源标注,每条答案后面跟着出处文件和页码,方便核对,这一点对内部知识场景尤为重要,大家对"AI生成"天然有戒心,能追溯到原文就不一样了。

我个人在实际操作中的体会是,本地RAG的难点从来不是技术本身,而是耐心。每个环节单看都不难的,但串起来之后,质量是用一个个细节堆出来的:切块是否合理、OCR是否干净、重排是否到位、元数据是否完整,任何一个环节的瑕疵都会在最终答案上放大。如果你也在计划做类似的事情,我的建议是先小范围试跑:挑一批有代表性的文档,跑通全流程,再逐步扩大语料范围。不要一上来就追求大而全,那只会让你迷失在批处理和调优的泥潭里。

最后再分享一个小技巧:记得定期备份Chroma的数据目录和原始文档目录。我一开始没做备份,有次系统盘故障差点丢掉整个索引。后来写了个简单的定时任务,每天凌晨把向量库目录打包传到另一块硬盘上,三分钟的事,省掉的却是重建索引的几天时间。这套系统真正的价值在于知识资产,保护好数据,比折腾什么新模型都重要。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询