1. 项目概述与核心需求解析
1.1 这个项目到底在解决什么问题
先聊一个很现实的问题:很多人手里攒了一堆文档——产品手册、技术方案、会议纪要、行业报告、甚至几百页的PDF教材,真正要用的时候却翻半天找不到关键信息。用通用搜索引擎吧,它不知道你企业内部的专业术语;问在线大模型吧,数据不能外传,答案也可能一本正经地胡说八道。我这次折腾的项目,就是想用一套完全本地化的方案,把“文档查询”这件事做成一个私有知识库,让大模型只基于我投喂的资料回答,不联网、不出内网、答案还能标注来源。
整个方案的技术栈很明确:LangChain做编排框架,Ollama跑本地大语言模型,向量数据库存文档切片,再加一个文档加载和检索链路,最终组合成一个RAG(Retrieval-Augmented Generation,检索增强生成)应用。关键词是“私有”“本地”“多源文档”,核心诉求就是数据不出本地、支持把PDF/Word/Markdown/网页等乱七八糟的格式统一变成可查询的知识。
1.2 为什么非要用RAG而不是微调
很多第一次接触这个方向的朋友会问:既然要让模型懂我的文档,为什么不直接微调一个模型?我个人的看法是,RAG和微调的适用场景完全不同。微调适合改变模型的行为模式和输出风格、固化某些专业领域知识,但成本高、周期长、更新麻烦。而RAG相当于给模型配了一本可以随时翻的参考书,每次回答都现场检索相关内容再组织答案。
对于“私有知识库查询”这个场景,RAG有四个碾压级优势:
- 实时更新:往向量库里丢一个新文档,立刻就能被检索到,不需要重新训练。
- 答案可溯源:能告诉用户“这段内容出自哪份文档的哪一页”,这对企业场景非常重要。
- 数据安全:文本切片向量化后存在本地数据库里,LLM推理也发生在本地,全程不经过第三方接口。
- 成本可控:只需要一台配置还行的消费级机器,不用租GPU服务器做训练。
所以这个项目的核心路径是:文档加载 → 文本拆分 → 向量化嵌入 → 存入向量库 → 查询时做相似度检索 → 把检索结果拼进Prompt → 本地模型生成答案。
1.3 适合谁来参考
如果你属于以下几类人,这篇内容应该能帮你少走很多弯路:
- 企业内部想搭一个“文档问答机器人”的技术人员,要求数据必须留在内网。
- 个人知识管理爱好者,想把本地几百份笔记、电子书、论文变成可对话的资料库。
- 已经在用LangChain但一直被各类依赖版本问题折磨的开发者,想换个更轻的本地方案。
- 对Ollama感兴趣,但不知道如何跟LangChain串起来的初学者。
我下面写的内容会从环境搭建、原理拆解、实际编码、问题排查四个维度展开,最后附带整理好的教程文档合集说明。里面所有代码和步骤都是我实际跑通过的,你可以直接照着做。
2. 技术选型与整体架构设计
2.1 核心组件选型对比
在动手之前,我把方案里每个组件都做了横向对比,选型逻辑如下:
| 组件 | 候选方案 | 我最终的选择 | 选型理由 |
|---|---|---|---|
| 大模型运行时 | Ollama / llama.cpp / LocalAI | Ollama | 安装简单、模型管理方便、自带HTTP API,对LangChain支持最友好 |
| 向量数据库 | Chroma / FAISS / Qdrant / Milvus | Chroma(轻量场景)/ FAISS(批量实验) | Chroma无需独立服务,直接嵌入进程,适合单机私有部署;数据量大再换Qdrant |
| 嵌入模型 | nomic-embed-text / all-MiniLM-L6-v2 / bge-m3 | nomic-embed-text | Ollama直接支持,中文效果尚可,兼顾速度和体积 |
| 文档加载器 | LangChain内置 / Unstructured / PyPDFLoader | LangChain内置为主 | 生态齐全,PDF/Word/HTML都有对应Loader |
| 编排框架 | LangChain / LlamaIndex / 原生代码 | LangChain | 链式调用、检索器抽象、Prompt模板管理成熟,社区资料最多 |
说下我为什么没选LlamaIndex。LlamaIndex在纯文档索引场景确实很强,但LangChain的生态更通用,以后想接Agent、接外部工具、换模型都比较顺手。而且LangChain 0.1之后API稳定了很多,网上踩坑资料也齐,对新手更友好。
2.2 整体架构图与数据流
整个系统跑起来之后的数据流是这样一个闭环:
- 用户上传或指定本地文件夹,程序遍历所有文档,根据扩展名选择对应的加载器。
- 加载出来的原始文本按固定块大小(我默认用500字符,重叠50字符)切分成多个文本块。
- 每个文本块调用嵌入模型生成向量,连同原始文本、元数据(文件名、页码等)一起写入向量数据库。
- 用户提问时,先对问题做同样的嵌入,然后到向量库执行相似度检索,召回Top-K个最相关的文本块。
- 把用户问题 + 召回文档块 + 提示词模板,一起发给Ollama中的本地大模型。
- 模型基于上下文生成回答,同时我们在界面上展示命中了哪些文档来源。
这个流程里有两个容易出问题的点:文本拆分参数和向量检索的召回数量。拆分太小,上下文碎片化;拆分太大,检索精度下降。召回太少,可能漏掉关键信息;召回太多,塞给模型的无关内容会干扰判断。我后面的实操部分会给出经过测试的参数区间。
2.3 目录结构与工程组织
我给项目设计了一个清晰的目录结构,避免后期文件越堆越乱:
llm-rag-local/ ├── data/ # 原始文档存放目录 │ ├── source/ # 待入库的PDF、Word、TXT等 │ └── processed/ # 中间文本缓存 ├── vector_store/ # Chroma持久化目录 ├── app/ │ ├── ingest.py # 文档入库脚本 │ ├── query.py # 查询问答脚本 │ ├── chain.py # LangChain链的组装 │ └── prompts.py # Prompt模板 ├── requirements.txt └── README.md我自己在实践里吃过亏:一开始把所有逻辑写进一个脚本,结果文档一多,每次调试都要重新加载全部文件。现在拆成ingest和query两个流程,入库是一次性的,查询是频繁的,两者分开后效率高很多。
3. 环境准备与依赖安装
3.1 Ollama安装与模型拉取
Ollama的安装非常简单,官方支持Windows、macOS和Linux。以我用的Ubuntu服务器为例:
curl -fsSL https://ollama.com/install.sh | sh装完之后先确认服务状态,然后拉取两个模型:一个做嵌入,一个做对话。
ollama serve ollama pull nomic-embed-text ollama pull qwen2.5:7b我选择qwen2.5:7b作为对话模型,主要看中它对中文的支持。如果你机器配置一般(16G内存左右),也可以换qwen2.5:3b或者llama3.2:3b。嵌入模型nomic-embed-text体积只有几百MB,效果在本地场景够用了。
这里必须提醒:Ollama默认监听127.0.0.1:11434,如果想让局域网内其他机器访问,需要设置环境变量OLLAMA_HOST=0.0.0.0。但私有化部署场景下,除非有明确需求,否则不要轻易开公网端口。
3.2 Python虚拟环境与依赖安装
我建议用Python 3.10以上版本,创建一个干净的虚拟环境:
python3 -m venv venv source venv/bin/activate pip install --upgrade pip然后安装核心依赖。这里我给出一个经过验证的requirements.txt版本,避免装到不兼容的版本:
langchain==0.2.16 langchain-community==0.2.12 langchain-ollama==0.1.3 chromadb==0.5.5 faiss-cpu==1.8.0 pypdf==4.3.1 python-docx==1.1.2注意:LangChain 0.2之后的版本里,很多第三方组件被拆到langchain-community里了,比如文档加载器。只装langchain主包会报ModuleNotFoundError。这是新手上路最容易踩的坑。
3.3 验证Ollama与LangChain连通性
装完依赖之后,先跑一个最小测试,确认两条链路都通:
from langchain_ollama import OllamaLLM, OllamaEmbeddings # 测试对话模型 llm = OllamaLLM(model="qwen2.5:7b", base_url="http://localhost:11434") resp = llm.invoke("一句话介绍什么是RAG") print("LLM响应:", resp) # 测试嵌入模型 embeddings = OllamaEmbeddings(model="nomic-embed-text", base_url="http://localhost:11434") vec = embeddings.embed_query("私有知识库") print("向量维度:", len(vec))如果这段代码能正常打印结果,说明LangChain和Ollama的桥梁已经打通了。我在实际测试中,嵌入向量维度是768,这和你选的嵌入模型有关,不同模型维度不同,不用纠结数值。
4. 文档加载与文本拆分的实操细节
4.1 多源文档加载器的选择
这个项目要处理“多源文档”,所以加载器这块值得认真梳理。LangChain里常用的加载器我整理如下:
| 文档类型 | 加载器 | 说明 |
|---|---|---|
| PyPDFLoader / PyMuPDFLoader | PyMuPDF速度快,但对扫描版PDF无能为力 | |
| Word | Docx2txtLoader | 支持.docx,不支持老的.doc |
| 纯文本 | TextLoader | 需要指定编码,否则中文乱码 |
| Markdown | UnstructuredMarkdownLoader | 可以保留标题结构 |
| HTML | BSHTMLLoader | 基于BeautifulSoup解析 |
| CSV | CSVLoader | 每行作为一个文档 |
我项目的ingest.py里写了一个统一的文件遍历逻辑,根据后缀名自动分派加载器:
import os from pathlib import Path def load_document(file_path: str): ext = Path(file_path).suffix.lower() if ext == ".pdf": from langchain_community.document_loaders import PyPDFLoader loader = PyPDFLoader(file_path) elif ext == ".docx": from langchain_community.document_loaders import Docx2txtLoader loader = Docx2txtLoader(file_path) elif ext == ".txt": from langchain_community.document_loaders import TextLoader loader = TextLoader(file_path, encoding="utf-8") elif ext == ".md": from langchain_community.document_loaders import UnstructuredMarkdownLoader loader = UnstructuredMarkdownLoader(file_path) elif ext == ".html": from langchain_community.document_loaders import BSHTMLLoader loader = BSHTMLLoader(file_path) else: raise ValueError(f"不支持的文件类型: {ext}") return loader.load()有两点经验值得分享。第一,扫描版PDF必须先做OCR,否则加载出来全是空文本,后续检索根本命中不了。第二,PyPDFLoader对部分加密PDF会报错,遇到这种情况先用第三方工具解除密码限制。
4.2 文本拆分策略:块大小与重叠的选择
文本拆分是RAG效果的分水岭。我在项目里做过对比实验,块大小从200字符到1000字符,用同一批文档、同样的3个问题做测试,结论如下:
- 块大小200-300:检索精度高,但上下文经常不够完整,模型容易漏掉前后文信息。
- 块大小500左右:综合效果好,既能保留足够的语义上下文,又能保证检索单元相对聚焦。
- 块大小超过800:检索结果经常包含大量不相关内容,答案准确率明显下降。
所以我最终选了RecursiveCharacterTextSplitter,块大小500字符,重叠50字符。
from langchain_text_splitters import RecursiveCharacterTextSplitter text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=50, separators=["\n\n", "\n", "。", "!", "?", ".", "!", "?", ";", ";"], )这里分享一个很多教程不会说的细节:中文文档的分隔符一定要包含中文标点。默认的separators只包含英文换行和英文句号,对中文长文的效果很差。我在separators里加了“。!?”等标点,切出来的块更加完整自然。
另外,如果你处理的是技术文档或Markdown,可以考虑用MarkdownHeaderTextSplitter,它会按标题层级切分,每个块带上所属章节的标题作为上下文元数据,检索命中率会高不少。
4.3 元数据保留:让答案可以溯源
为了让最终的问答能显示“来源文档”,我们在入库时要保留元数据。以PDF为例,加载器默认会给每个Document对象填充source和page字段。但如果你使用自定义加载器,最好手动补充:
docs = load_document(file_path) for doc in docs: doc.metadata["source_file"] = os.path.basename(file_path) doc.metadata["source_path"] = file_path这些元数据在后续检索展示时非常有用,它能让用户看到答案来自哪里,增加可信度。我在最终的查询输出里,会把命中的文档名、页码、文本块内容一并打印出来。
5. 向量数据库与检索链路的实现
5.1 为什么选Chroma
向量数据库的选型,很多人一上来就想上Milvus或者Qdrant这种独立服务。但对于个人级、小团队级私有知识库,这属于杀鸡用牛刀。Chroma的优势在于:
- 零配置,pip安装完直接嵌入Python进程,不需要单独维护服务端。
- 持久化目录是一个普通文件夹,备份迁移非常方便。
- API设计简洁,和LangChain集成度高。
如果你的文档量到了百万级,再考虑换Qdrant或者Milvus不迟。我见过很多朋友第一步就在K8s上折腾向量库,结果核心RAG逻辑还没跑通就劝退了。
5.2 入库流程完整实现
下面是我ingest.py里最核心的一段代码,逻辑是遍历data/source目录下所有文档,加载、拆分、向量化、写入Chroma:
from langchain_community.vectorstores import Chroma from langchain_ollama import OllamaEmbeddings PERSIST_DIR = "./vector_store/chroma_db" def ingest_documents(): embeddings = OllamaEmbeddings(model="nomic-embed-text") all_docs = [] source_dir = Path("./data/source") for file_path in source_dir.rglob("*"): if file_path.is_file() and file_path.suffix.lower() in {".pdf", ".docx", ".txt", ".md", ".html"}: print(f"正在处理: {file_path}") docs = load_document(str(file_path)) for doc in docs: doc.metadata["source_file"] = file_path.name all_docs.extend(docs) if not all_docs: print("未找到任何有效文档") return splits = text_splitter.split_documents(all_docs) print(f"文档切分为 {len(splits)} 个文本块") vector_store = Chroma.from_documents( documents=splits, embedding=embeddings, persist_directory=PERSIST_DIR, ) vector_store.persist() print(f"向量库构建完成,持久化至 {PERSIST_DIR}")这里有一个新手容易忽略的问题:Chroma的persist()方法在最新版本里会在每次写入时自动落盘,但显式调用一下更稳妥。另外,重复执行ingest脚本会导致向量库里的文档重复,所以我在脚本里加了一个reset参数,需要重建库时先删除persist目录再重新写入。
5.3 相似度检索的参数选择
入库完成后,查询侧最关键的是检索器参数。我用Chroma的as_retriever方法创建检索器,search_kwargs里主要调两个参数:
retriever = vector_store.as_retriever( search_type="similarity", search_kwargs={"k": 4} )k是召回数量。我测试过k=2到k=8,结论如下:
- k=2:回答经常信息量不足,尤其对于需要多文档交叉印证的问题。
- k=4:绝大多数场景的最佳选择,上下文和干扰达到平衡。
- k=6以上:回答开始出现偏离,因为无关文本块混进来了。
如果你用的是similarity_score_threshold搜索类型,还可以设置score_threshold过滤低相似度结果。但要注意,不同嵌入模型的分数分布不同,nomic-embed-text的分数普遍较高,阈值可以设在0.3左右;换其他模型可能要重新标定。
5.4 组装完整的问答链路
最后一步是LangChain链的组装。我用了一个很经典的RetrievalQA结构:
from langchain.chains import RetrievalQA from langchain_ollama import OllamaLLM from langchain.prompts import PromptTemplate prompt_template = """你是一个企业内部知识库助手。 请根据以下资料片段回答用户的问题。 如果资料中没有相关信息,请直接回答“资料库中暂无相关信息”,不要编造。 资料片段: {context} 用户问题:{question} 请给出简洁、准确的回答,并在回答末尾标注信息来源文件名。""" QA_CHAIN_PROMPT = PromptTemplate( input_variables=["context", "question"], template=prompt_template, ) def build_qa_chain(): llm = OllamaLLM(model="qwen2.5:7b", temperature=0.1) vector_store = Chroma( persist_directory=PERSIST_DIR, embedding_function=OllamaEmbeddings(model="nomic-embed-text"), ) qa_chain = RetrievalQA.from_chain_type( llm=llm, chain_type="stuff", retriever=vector_store.as_retriever(search_kwargs={"k": 4}), return_source_documents=True, chain_type_kwargs={"prompt": QA_CHAIN_PROMPT}, ) return qa_chain这里chain_type="stuff"表示把所有检索到的文本块直接塞进Prompt。对于召回4个、每块500字符的情况,总共约2000字符,qwen2.5:7b的上下文窗口完全扛得住。
我实测发现temperature=0.1比较合适。温度太高模型会“自由发挥”,回答里出现资料中没有的内容;温度太低则显得机械,几乎照抄原文。对于知识问答场景,0.1到0.3这个区间最优。
6. 查询演示与效果评估
6.1 单条查询的完整输出
我用一份内部产品手册和一份行业报告建了库,然后问了一个需要跨文档才能回答的问题:“根据所提供资料,产品A在2024年第四季度发货量变化的关键原因有哪些?”
实际输出简化后大致如下:
正在检索相关知识... 问题: 根据所提供资料,产品A在2024年第四季度发货量变化的关键原因有哪些? 回答: 根据资料,产品A在2024年第四季度发货量环比下降12%,关键原因包括: 1. 上游芯片供应交期从8周延长至14周,导致整机交付延迟; 2. 海外市场渠道库存偏高,客户主动放缓提货节奏; 3. 第四季度新品切换期,部分产线产能用于新品试产。 来源信息: - 产品A年度运营分析报告.pdf 第3页 - 2024年行业渠道洞察.docx 第7页这个输出说明整个链路是通的:检索模块确实从两个不同文档里都找到了相关内容,模型也正确地把信息合并且标注了来源。
6.2 检索效果自评思路
RAG项目不能只靠“看起来回答了”来评判,我建议用一组固定的测试问题做回归评估。我给自己的项目建了一个包含15个问题的测试集,分三类:
- 事实型问题:答案应该能在某一份文档中直接找到。
- 综合型问题:需要从多个文档或多个文本块中归纳。
- 拒答型问题:文档中没有答案,模型不应该编造。
每次调整任何参数(比如嵌入模型、切块大小、召回数量)后,跑一遍测试集,对比回答中“完全正确/部分正确/错误/编造答案”的占比。我实测从默认切块参数调整到中文分隔符之后,综合型问题的正确率大约提升了20%。
7. 常见问题与排查技巧实录
7.1 依赖版本不兼容导致的诡异报错
我在实际搭建过程中遇到最多的问题就是LangChain各组件版本不匹配。社区里很多人报错ModuleNotFoundError: No module named 'langchain_community',原因就是只安装了langchain主包,而加载器和向量库都搬到了langchain-community里。
解决方案很简单:安装langchain-community,并且让langchain、langchain-community、langchain-ollama三者的版本尽量保持同一个大版本。不要一个装0.1一个装0.3,API差异能让你怀疑人生。
7.2 Ollama模型加载内存不足
如果你在回答问题时发现第一次请求特别慢,甚至直接OOM(内存溢出),先检查机器的可用内存。qwen2.5:7b量化版大约需要6-8GB内存,再加上嵌入模型和Chroma,建议16GB起步。
如果内存紧张,可以换更小的模型,比如qwen2.5:3b或者phi3:mini。我实测qwen2.5:3b在简单事实型问题上的表现完全可用,只是复杂推理稍弱。另一个技巧是设置OLLAMA_KEEP_ALIVE=600,让模型保持在内存中不释放,避免每次问答重新加载。
7.3 中文乱码与编码问题
TextLoader加载TXT文件时,默认按UTF-8解码,如果源文件是GBK编码的,就会报UnicodeDecodeError。我在加载器里显式指定了编码:
loader = TextLoader(file_path, encoding="utf-8", autodetect_encoding=True)autodetect_encoding=True会自动探测编码,实测对GBK、GB2312文件都能正确处理。
7.4 入库后查询不到任何结果
这种情况先排查三件事:
- 向量库是否确实写入成功——查一下persist目录下有没有数据文件,或者重新执行一次
ingest并观察日志。 - 嵌入模型是否一致——入库和查询必须用同一个嵌入模型,否则向量空间完全不同,检索结果等于随机。
- 文档是否加载出空文本——很多PDF看起来有字,实际上是扫描图片,加载出来是空字符串。用脚本打印
len(docs[0].page_content)即可判断。
7.5 回答质量不佳时的调优顺序
如果回答总是不准确,我建议按这个顺序调优,不要病急乱投医:
- 先检查召回内容:直接打印检索器返回的文本块,看看相关段落是否被正确召回。
- 如果召回不准确:调小
chunk_size,或者换一个嵌入模型试试。 - 如果召回准确但回答不准:优化Prompt模板,明确告诉模型“只能基于资料回答”。
- 如果回答喜欢编造:降低
temperature,同时增加一个兜底指令。 - 如果资料特别长:增加召回数量
k,或使用map_reduce链类型做多文档综合。
8. 项目扩展方向与个人心得
8.1 这个项目还能怎么继续演进
现在这个版本是单机单用户的命令行形态,如果你要把它变得真正可用,有几个扩展方向:
- 加一个Web界面:用Gradio或Streamlit包一层,几分钟就能做一个可视化的问答页面,支持上传文档、流式输出。
- 支持并发查询:把推理部分拆成独立服务,用消息队列接收查询请求,适合多用户同时使用。
- 引入重排序(Rerank):先用向量粗召回Top20,再通过一个交叉编码器精排取Top4,能明显提升复杂问题的回答质量。
- 增量更新:做一个文件夹监听,新增文件自动触发入库流程。
8.2 我在实际使用中的几点体会
第一个体会是:RAG项目百分之六十的时间都花在数据清洗和切块调参上,而不是模型本身。很多人一上来就追求最新最强的模型,结果发现文档加载环节就问题百出。老老实实把PDF解析好、把文本切干净,效果提升比换大模型更明显。
第二个体会是:Ollama在本地模型管理上的体验真的很好,一条命令拉模型、一条命令跑服务,对开发调试非常友好。但也要清楚它的边界,毕竟消费级显卡跑7B模型,推理速度有限,不适合承载高并发的线上服务。
第三个体会是关于“私有”这两个字。本地部署不等于绝对安全,向量数据库的文本切片里可能包含敏感信息,有条件的团队还是应该做好磁盘加密和访问控制。至少别把包含客户资料的文档随意放在共享目录里。
8.3 附带的教程文档合集使用建议
这次整理的同时,我做了一份配套的教程文档合集,里面包含三部分:一是Ollama的简明使用手册,覆盖模型管理、API调用和常见配置项;二是LangChain对接Ollama的代码示例集,按文档加载、向量化、检索、问答拆成独立脚本;三是RAG调优笔记,记录了我测试不同切块参数、不同召回数量时的对比数据和结论。
我建议你拿到合集后不要从头到尾顺序读,而是先跑通本篇文章里的最小示例,再遇到具体问题时去合集里翻对应章节。文档里我有意保留了出错时的报错信息和排查思路,比只看成功路径更有参考价值。
最后再分享一个我踩过几次坑之后养成的小习惯:每次修改代码前,先把向量库备份一下,或者记录当前版本对应的参数组合。因为RAG项目的调优是迭代式的,改乱了想回退,没有备份就只能重新入库,有些文档源文件一旦丢失,重建的代价非常高。有了这个习惯,你有底气大胆试错,也更容易找到最适合自己的那组配置。