☰
本地RAG系统实战:基于LangChain、bge-m3与Chroma搭建离线知识库问答
2026/10/17 7:29:08 网站建设 项目流程

做本地RAG这件事,我是被一个很朴素的问题逼上路的:团队里的产品文档、项目记录、技术方案散落在各个目录里,想找一个半年前的结论,翻聊天记录和共享盘翻到怀疑人生。数据传到在线问答平台又始终绕不开隐私这一关,于是决定在本地机器上搭一套完全离线运行的检索增强生成系统。这套系统用LangChain作为编排框架串联整个处理链路,bge-m3负责把中文文档和问题映射成高质量向量,Chroma作为轻量级向量库完成存储和检索,LLM选用本地模型保证问答生成不依赖外网,最后用Gradio快速搭出一个浏览器问答界面。整条链路数据不出本机,项目源码可以直接参考复现。如果你是刚开始接触RAG的开发者,或者正在为团队内部知识库发愁,这篇文章就是为你准备的实操记录。

1. 先拆解整体设计:这套系统到底在做什么

1.1 为什么选本地RAG而不是直接上在线平台

在开始写代码之前,先明确一个问题:为什么不直接把文档丢给大模型做长上下文问答?原因很简单,文档数量和长度决定了大模型上下文窗口不可能无限扩展,几份几十页的文档还能硬塞,一旦到了上百份文档,直接拼接的结果就是又慢又贵,而且准确率断崖式下降。RAG的核心思路是"先检索、后生成"——每次问答时只把和问题最相关的几个片段喂给大模型,既控制成本,又提升答案准确性。

本地部署的另一个决定性因素是隐私。团队内部文档往往包含未公开的功能规划、客户沟通细节,把这些内容传到外部服务就意味着数据合规风险不可控。全本地部署之后,文档和向量数据全部留在自己的机器上,模型推理也在本地完成,天然满足"数据不出内网"这个约束。再加上网络不稳定的时候,在线接口的延迟和限流会直接卡死体验,本地模型虽然效果略弱一些,但胜在稳定可控。

这个方案的适用场景非常明确:企业内网知识库、个人笔记库问答、离线环境下的文档检索、需要对提示词和检索流程做深度定制的场景。不适用的情况也有,比如你需要最强生成能力并且数据不敏感,那直接使用在线大模型API会省很多事。

1.2 五大组件的分工与协作

这套系统的五个组件,各管一段,职责非常清晰。

组件职责类比
LangChain编排整个处理流程,管理文档加载、分割、提示词、链路组装项目经理
bge-m3把自然语言文本转换成语义向量翻译官
Chroma向量存储与相似度检索档案库管理员
LLM基于检索到的上下文生成最终回答写手
Gradio提供浏览器交互界面前台接待

LangChain在这套架构里的作用容易被低估。很多人觉得RAG核心是模型和向量库,但实际上链路的稳定性、可维护性全靠编排层。文档加载、文本分割、向量化、检索、提示词组装、输出解析,任何一个环节脱节,整个系统就废了。LangChain的价值在于把这些环节抽象成统一接口,替换某个组件不需要重写其余代码。比如今天用Chroma,明天想换成别的向量库,只需要改初始化那几行。

bge-m3和Chroma是一对黄金搭档。bge-m3生成的向量质量直接决定检索结果准不准,Chroma则负责在收到查询向量后快速返回最相似的文档块。这里有一个容易被忽略的点:bge-m3输出的向量维度是1024维,而很多英文场景常用的模型是384或768维,选向量库的时候要确认它支持这个维度,Chroma没有这个限制,这也是我选它的重要原因。

LLM放在本地运行,意味着完全离线可用。虽然生成能力相比超大在线模型有差距,但配合检索到的精准上下文,回答质量在实际使用中完全够用。Gradio把整个过程封装成一个网页,输入问题、点击回车、得到带引用来源的回答,体验和在线产品没太大区别。

1.3 一次问答背后的完整链路

我来走一遍完整的问答流程。你输入"我们之前的发布流程规范是什么"这个问题之后,系统内部经历五个步骤。

第一步,问题理解与向量化。bge-m3把"我们之前的发布流程规范是什么"转换成一个1024维的向量。这个向量的语义空间里,它和"发布流程""规范文档""上线步骤"这些概念的距离都比较近。

第二步,向量检索。Chroma在向量库里搜索,找出与查询向量余弦相似度最高的K个文档块。这里K就是top_k参数,默认我设置的是4,意味着每次只取最相关的4个片段作为上下文依据。

第三步,上下文组装。LangChain把这4个文档块的原文内容按顺序拼接起来,再配合系统提示词和用户问题,生成完整的Prompt。

第四步,模型推理。本地LLM拿到Prompt之后,根据上下文片段生成回答。因为上下文是检索出来的真实文档,模型的生成被牢牢限制在文档内容范围内,幻觉明显减少。

第五步,结果输出。Gradio把答案渲染到聊天窗口,整个过程通常在几秒内完成,取决于机器性能。

这个流程看起来简单,但每一步都有不少坑。比如文本分割的大小怎么定、top_k设多少合适、向量模型需不需要归一化、提示词怎么写才能让模型"只看文档不瞎编"。这些细节会在后面的章节逐个展开。

2. 关键技术点:决定系统成败的细节都在这里

2.1 文档加载:不同格式资料如何统一处理

RAG的第一步是喂数据,但你手里的资料大概率不是统一格式。我的实践项目里就有Markdown文档、纯文本笔记、PDF产品手册三种格式,如果还有Word文档或者表格,情况更复杂。

LangChain的文档加载器把不同格式统一转换为Document对象。Document对象包含两个核心字段:page_content是文档正文,metadata里可以放来源路径、页码、标题等信息。我在项目里用的是DirectoryLoader按目录批量加载,再配合PyPDFLoader单独处理PDF。这里有一个关键设计:做多格式处理时,建议把metadata记录下来,后面检索到某个片段时,用户能看到这个片段来自哪个文件,这对结果的可信度提升很大。

我踩过的坑是编码问题。很多Windows环境生成的文本文件是gbk编码,默认UTF-8读取会直接报错,或者读出来是乱码。加载文本的时候务必要显式指定编码,或者用chardet这类工具先探测文件编码。另外,PDF加载对扫描件无能为力,除非额外接OCR链路,但那是另一个复杂度级别,本文暂时不涉及。

2.2 文本分割:chunk_size不是随便拍的

文档加载完后是一整块长文本,直接向量化的话,一个几万字的文档变成了一个向量,检索精度基本等于零。这里就引出一个关键工程问题:怎么把文档切成合适的片段。

我推荐的是RecursiveCharacterTextSplitter,它按照换行符、句号、分号、字符的优先级递归切分,尽量保证语义完整性。chunk_size决定每个片段的字符数,chunk_overlap决定相邻片段之间重叠多少个字符。我最初用的是业界常见的chunk_size=1000, chunk_overlap=200,但实测中文场景下效果一般。

中文信息密度比英文高,同样的向量维度下,1000个中文字符蕴含的信息量远超1000个英文字符,检索时容易引入无关内容。后来我把参数调整为chunk_size=512, chunk_overlap=64,检索精度明显提升。这里给一个经验值:中文文档使用512到768的chunk_size比较合适,如果文档逻辑段落很短,甚至可以调到256。overlap设置64到128足够,过大反而会让多个检索结果高度重复,浪费上下文空间。

实际测试中还有一个容易被忽略的点:如果文档里有代码块或者表格,文本分割很容易把它们拦腰截断,导致检索出来的片段语义残缺。解决办法是在分割之前做一步预处理,把代码块和表格单独提取出来,或者用自定义分隔符列表,把行内代码也纳入分割特征。

2.3 选型bge-m3的核心理由

嵌入模型的选择是整个系统里影响检索效果最关键的变量之一。实际用过几个主流模型之后,我把bge-m3确定为首选。

中文语义理解能力是第一个理由。bge-m3在中文语料上经过了充分的预训练,对中文的同义词、句式变化、领域术语的理解明显好于通用英文模型。我实测过同一个问题在不同模型下的检索结果,bge-m3返回的片段相关性最稳定。

第二个理由是bge-m3支持8192个token的长文本处理,这意味着即使某段业务文档比较长,也能整体编码而不丢失上下文信息。配合前面说的文本分割,即使分割后单个片段仍然较长,模型也能完整编码。

第三个理由是归一化习惯。使用bge-m3时必须要设置normalize_embeddings=True,让向量进行L2归一化。归一化之后,向量之间的内积等价于余弦相似度,Chroma的检索结果会更有区分度。很多人忽略这一步,导致检索分数全部堆在一个很小的区间里,排序不稳定。

还有一个细节:bge-m3的模型体积较大,首次下载需要几个GB的存储空间。如果你的机器磁盘紧张,可以考虑用轻量级替代方案,但效果会有明显差距。我的建议是只要磁盘和显存允许,优先选择bge-m3。

2.4 向量库持久化与LLM接入的衔接

向量库承担着"记忆"的职责——文档向量化之后存储在这里,下次系统启动时直接加载,不需要重新处理原始文档,这个机制依赖Chroma的持久化能力。

我在项目里使用persist_directory参数指定持久化目录,向量数据写入本地磁盘。系统重启后,只需要连接这个目录就能恢复向量库,完全跳过重复切分和向量化过程。实践中的一个建议是:索引构建脚本和问答脚本分开写。索引脚本负责加载文档、分割、向量化、写入向量库,按需执行;问答脚本启动时只加载已有向量库。这样可以避免每次启动都重建索引,节省大量时间。

LLM接入我采用的是本地模型方案。本地推理的工具选择很多,比较省心的是用Ollama拉起服务,再通过LangChain的Ollama包装器连接。我选的是一个7B参数量的中文模型量化版本,显存占用在6G到8G之间,普通消费级显卡就能跑。如果机器没有独立显卡,纯CPU推理也能用,只是单次回答会慢一些。这里提醒一下:首轮推理时模型需要加载到内存或显存,速度会明显变慢,后面就稳定了。

如果你手头有在线API的访问权限,也可以替换Ollama为客户端的ChatOpenAI,只需要改一处初始化代码和对应的base_url、api_key配置。LangChain统一抽象的好处就在这。

3. 实操记录:从零到一跑通整套系统

3.1 环境准备与依赖安装

本次实操基于Python 3.10环境,需要先准备这些依赖包:

langchain langchain-community langchain-chroma langchain-huggingface sentence-transformers chromadb gradio pypdf

安装命令很简单:

pip install langchain langchain-community langchain-chroma langchain-huggingface sentence-transformers chromadb gradio pypdf

这里的版本兼容问题值得说一句。LangChain迭代非常快,API变动频繁,我不建议全部无脑装最新版。实操时选择一套经过验证的版本组合,然后锁死在requirements.txt里。如果你用的版本比我新,A类API可能被移动到其他包中。

导入方式也要注意:

from langchain_huggingface import HuggingFaceEmbeddings from langchain_chroma import Chroma from langchain_community.document_loaders import DirectoryLoader

在旧版本中这些模块有的位于langchain.embeddings或langchain.vectorstores,新版已经拆分到独立包中,混用会直接报ImportError。如果你打开别人的老项目,第一件事就是检查导入路径。

3.2 项目目录结构规划

我建议按这样的结构组织项目:

local_rag/ ├── docs/ # 原始文档存放目录 │ ├── product_manual.md │ ├── api_doc.pdf │ └── meeting_notes.txt ├── chroma_db/ # 向量库持久化目录 ├── config.py # 全局配置 ├── build_index.py # 索引构建脚本 ├── rag_chain.py # RAG问答主流程 ├── app.py # Gradio界面入口 └── requirements.txt # 依赖锁定

为什么要单独拆build_index.py?因为索引构建和问答是两个生命周期完全不同的任务:索引可能每周重建一次,问答是持续进行的在线服务。分开写让整个流程边界清晰,调试也方便。实际使用中我发现,如果哪天你换了一版文档,只需要重新跑一遍build_index.py,问答侧什么都不用动。

3.3 索引构建脚本源码解析

先看配置文件:

# config.py from pathlib import Path # 原始文档目录 DOC_DIR = Path("./docs") # 向量库持久化目录 PERSIST_DIR = Path("./chroma_db") # 向量模型 EMBEDDING_MODEL = "BAAI/bge-m3" # 文本分割参数 CHUNK_SIZE = 512 CHUNK_OVERLAP = 64 # 检索参数 TOP_K = 4 SEARCH_SCORE_THRESHOLD = 0.5

这里推荐用配置文件集中管理所有参数,而不是散落在各个脚本里。调参的时候只改一处,系统其余部分自动生效。

接下来是索引构建的核心逻辑:

# build_index.py from pathlib import Path from config import DOC_DIR, PERSIST_DIR, EMBEDDING_MODEL, CHUNK_SIZE, CHUNK_OVERLAP from langchain_community.document_loaders import DirectoryLoader from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain_huggingface import HuggingFaceEmbeddings from langchain_chroma import Chroma def load_documents(): # 按目录加载常见的文本和Markdown文件 loader = DirectoryLoader( str(DOC_DIR), glob="**/*.md", show_progress=True, use_multithreading=True, loader_kwargs={"encoding": "utf-8"}, ) docs = loader.load() # 补充加载PDF文件 pdf_loader = DirectoryLoader( str(DOC_DIR), glob="**/*.pdf", show_progress=True, use_multithreading=True, ) pdf_docs = pdf_loader.load() return docs + pdf_docs def split_documents(docs): splitter = RecursiveCharacterTextSplitter( chunk_size=CHUNK_SIZE, chunk_overlap=CHUNK_OVERLAP, separators=["\n\n", "\n", "。", "!", "?", ";", ";", " ", ""], keep_separator=True, ) return splitter.split_documents(docs) def build_index(): print(">>> 加载文档") docs = load_documents() print(f" 共加载 {len(docs)} 个文档") print(">>> 分割文本") chunks = split_documents(docs) print(f" 共切分为 {len(chunks)} 个片段") print(">>> 初始化向量模型") embeddings = HuggingFaceEmbeddings( model_name=EMBEDDING_MODEL, encode_kwargs={"normalize_embeddings": True}, ) print(">>> 构建向量库并持久化") vector_store = Chroma.from_documents( documents=chunks, embedding=embeddings, persist_directory=str(PERSIST_DIR), ) print(f">>> 向量库已保存至 {PERSIST_DIR}") if __name__ == "__main__": build_index()

这里的几个细节值得关注。loader_kwargs={"encoding": "utf-8"}不是可选项,而是必填项,否则碰到gbk编码的txt文件直接抛异常。separators里我加入了中文标点符号,让分割器优先在句子边界切断,避免把一整段逻辑完整的内容劈成两半。normalize_embeddings=True被显式设置,这个参数对检索稳定性的影响前面已经说过,再次强调。

如果你有Word文档需要处理,可以额外引入Docx2txtLoader来加载,思路完全相同,在load_documents里再追加一个分支即可。

3.4 RAG问答主流程源码解析

问答脚本负责加载既有向量库、组装检索器和LLM链路:

# rag_chain.py from config import PERSIST_DIR, EMBEDDING_MODEL, TOP_K, OLLAMA_BASE_URL, OLLAMA_MODEL from langchain_huggingface import HuggingFaceEmbeddings from langchain_chroma import Chroma from langchain_community.llms import Ollama from langchain_core.prompts import ChatPromptTemplate from langchain_core.runnables import RunnablePassthrough from langchain_core.output_parsers import StrOutputParser def format_docs(docs): # 把检索到的Document列表拼成一整段上下文 return "\n\n".join(doc.page_content for doc in docs) def init_vector_store(): embeddings = HuggingFaceEmbeddings( model_name=EMBEDDING_MODEL, encode_kwargs={"normalize_embeddings": True}, ) vector_store = Chroma( persist_directory=str(PERSIST_DIR), embedding_function=embeddings, ) return vector_store def init_retriever(vector_store): return vector_store.as_retriever( search_type="similarity", search_kwargs={ "k": TOP_K, "score_threshold": 0.5, }, ) def build_rag_chain(): vector_store = init_vector_store() retriever = init_retriever(vector_store) llm = Ollama( base_url=OLLAMA_BASE_URL, model=OLLAMA_MODEL, temperature=0.1, num_ctx=4096, ) template = """你是一个严谨的文档问答助手。请仅根据以下资料片段回答问题,不要引入资料之外的信息。 如果资料内容不足以回答问题,请直接回答"资料中未找到相关内容"。 资料片段: {context} 问题:{question} 请给出准确、简洁的回答:""" prompt = ChatPromptTemplate.from_template(template) rag_chain = ( {"context": retriever | format_docs, "question": RunnablePassthrough()} | prompt | llm | StrOutputParser() ) return rag_chain

retriever | format_docs是LangChain的管道操作,检索器返回的Document列表经过format_docs函数转换成纯文本,再填入Prompt模板的{context}位置。num_ctx=4096设置模型的上下文长度,这个值要大于chunk_size乘以top_k,否则检索到的内容超出模型能处理的窗口,后边的片段会被截断。

temperature=0.1是我调好的值。问答场景追求的是忠实于文档,不是创意发挥,temperature设太高会导致模型自己胡编乱造。类似地,提示词里明确写了"不要引入资料之外的信息",这是降低幻觉的最后一层保险。

3.5 Gradio界面与启动

最后一步,把上面的链路包装成一个网页界面:

# app.py import gradio as gr from rag_chain import build_rag_chain # 初始化RAG链路 rag_chain = build_rag_chain() def ask(question, history): if not question.strip(): return "请输入问题" try: answer = rag_chain.invoke(question) return answer except Exception as e: return f"系统异常:{str(e)}" def main(): with gr.Blocks(title="本地知识库问答系统") as demo: gr.Markdown("# 本地知识库问答系统\n基于LangChain + bge-m3 + Chroma构建") chatbot = gr.Chatbot(height=500, label="对话区域") msg = gr.Textbox(label="输入你的问题", placeholder="例如:我们之前的发布流程规范是什么?", lines=2) clear = gr.ClearButton([msg, chatbot]) def respond(message, chat_history): history = chat_history or [] answer = ask(message, history) history.append((message, answer)) return "", history msg.submit(respond, [msg, chatbot], [msg, chatbot]) demo.launch(server_name="127.0.0.1", server_port=7860, inbrowser=True) if __name__ == "__main__": main()

这里我用了新版Gradio的Blocks组件,支持真正的多轮对话历史。如果你在别的地方看到的是旧版gr.Interface,那是单轮问答的写法,改造起来需要换一套逻辑。ClearButton用来一键清空对话记录。server_name="127.0.0.1"表示只在本机访问,如果你需要局域网内其他机器访问,改成"0.0.0.0"即可,但要注意访问控制,别暴露在不安全网络里。

启动方式就两步:先执行python build_index.py构建索引,再执行python app.py打开界面。

4. 运行效果、常见问题与调优心得

4.1 首次运行与后续启动

首次运行build_index.py时,有几个阶段需要耐心等待。第一步是下载bge-m3模型权重,几个GB的文件取决于网速。下载完成后模型的加载还需要一点时间。真正做向量化时,如果机器没有GPU,CPU处理几百个文本片段可能要几分钟到十几分钟,这个阶段会看到进度条滚动,不用着急。

问答阶段的速度主要取决于LLM推理。GPU机器上7B模型量化版本一次生成大约1到3秒,纯CPU机器可能要10秒以上。实际使用中我发现一个规律:问题的检索耗时很稳定,变化主要在生成阶段,token数越多越耗时。

4.2 高频问题排查速查表

问题现象原因排查解决方案
ImportError: cannot import name 'Chroma'版本更新导致导入路径变化确认使用langchain_chroma包,升级或固定依赖版本
加载文件乱码或报编码错误文件是gbk编码在所有loader的loader_kwargs里指定encoding="utf-8",必要时先转码
检索返回结果不相关chunk_size偏大或top_k不够调小chunk_size到512附近,适当调大top_k再观察
bge-m3下载极慢网络问题使用镜像源下载模型,不同网络的方案差异很大
回答总是说"资料中未找到"score_threshold设置过高把score_threshold降到0.4或者直接移除
回答引用无关上下文chunk_overlap过大导致语义重叠缩小overlap到64,并检查chunk_size是否过大
Chroma启动后数据重复多次插入相同文档但未去重构建索引前检查持久化目录,或使用ID稳定机制重建库
GPU显存不足模型太大或上下文太长换用小尺寸量化模型,或者调低num_ctx

表格里的每一条我都实际遇到过。最典型的是score_threshold这个参数,很多人不知道它会把低于阈值的检索结果全部过滤掉。如果阈值设成0.7而你的文档向量和问题向量本来就不太近,那结果就是每次检索都返回空。我的建议是第一次搭建时不要设这个参数,跑通之后再根据实际分数调整。你可以在检索器里打印出相似度分数,再做因地制宜的设定。

4.3 效果调优的几个小技巧

基础版本跑通之后,可以考虑以下几个方向做效果升级。

第一个是检索增强。单纯依靠向量检索在关键词匹配上偶尔会漏掉重要片段。可以引入BM25文本检索与向量检索做一个混合召回,用LangChain的EnsembleRetriever把两种检索器组合起来,分数加权融合。实测混合检索在带专有名词、产品名、编号的文档里效果提升明显,因为这类内容向量检索不一定识别得准。

第二个是重排序。Chroma返回的top_k文档不一定按相关性严格排序,可以接一个Reranker对检索结果二次打分。这会增加一些模型推理开销,但对于答案质量要求高的场景非常值得。如果你准备用重排序,top_k可以先设置在12到20,重排序后取前4个,这样效果最佳。

第三个是提示词与输出结构优化。让模型在回答末尾以引用列表形式输出信息来源,用户可以直接跳回原文检查。这个扩展的实现在Gradio界面上也不复杂,只需要给Prompt模板加一个输出格式约束,把格式说明写清楚,然后在respond函数里解析返回的带引用格式文本。

第四个是文档更新策略。如果知识库内容经常变化,每次全量重建索引成本较高。可以配合文件名哈希或文档ID机制,判断哪些文档变了、哪些是新增的,只对变动文档重新向量化。Chroma支持按ID更新和删除,这个能力在内容频繁更新的场景下非常有用。

最后说两句实在话

这套系统从搭建到稳定运行,我前后改了三版。最开始的版本把所有代码堆在一个脚本里,改一个参数要重新加载整个项目,后来拆成索引构建和问答两条链路,清爽多了。最大的感受是:RAG的门槛不在概念,而在工程细节。文本分割参数、向量归一化、检索阈值、prompt约束,每一个看似不起眼的点都直接决定体验。建议你跑通之后再回头调参,把每个参数的实际影响都亲手验证一遍,比看多少篇文章都管用。

如果后续想让系统更顺手,可以再加一个文档管理面板,直接在网页里上传文件和重建索引;也可以给Gradio界面做用户隔离和访问控制,多人协作时总得知道每个答案对应哪份知识库。按这个框架逐步扩展就好,核心链路不用动。

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

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

立即咨询