ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

200行Python实现全本地RAG:Ollama+ChromaDB搭建检索增强生成系统

200行Python实现全本地RAG:Ollama+ChromaDB搭建检索增强生成系统 1. 项目概述一次从零到一的全本地RAG搭建之旅最近在折腾大语言模型应用发现一个挺有意思的现象很多朋友一提到RAG检索增强生成第一反应就是去找各种云服务或者复杂的框架总觉得不搞个分布式、不接几个API就不够“专业”。但实际上对于绝大多数个人开发者、数据分析师或者想快速验证想法的“笔记本工程师”来说一个轻量级、全本地运行的RAG系统才是最高效的起点。它让你完全掌控数据隐私不受网络波动影响更重要的是你能把每一个环节都摸得门儿清从文本切分、向量化到检索、生成全流程了然于胸。我这次的目标很明确用尽可能简洁的Python代码在本地笔记本上搭建一个能跑起来的RAG系统。核心工具链就三样Ollama来运行开源大模型ChromaDB作为本地的向量数据库再用Python把它们粘合起来。最终我用了不到200行代码实现了核心流程。听起来是不是挺简单但这个过程里踩的坑、绕的弯那可真是不少。从Ollama的龟速下载到ChromaDB向量维度对不上的玄学错误再到Prompt设计不当导致的“胡言乱语”几乎把新手能遇到的雷都踩了一遍。这篇记录就是把这些坑和解决方案摊开来希望能帮你省下几个小时甚至几天的折腾时间。这个方案特别适合谁呢如果你是Python初学者想通过一个有趣的项目练手如果你是业务人员有些本地文档想快速做个智能问答工具或者你和我一样是个对技术黑盒有“洁癖”喜欢把所有东西都跑在自己机器上的工程师那这篇内容应该能给你带来直接的帮助。我们不需要GPU当然有更好就用CPU也能跑起来重点在于理解流程和解决问题。2. 核心思路与工具选型为什么是OllamaChromaDB在开始写代码之前花点时间想清楚“为什么”比直接动手更重要。RAG的流程可以简化为四步加载文档并切分 - 将文本块转化为向量嵌入并存储 - 根据问题检索相关文本块 - 将问题和检索到的文本块组合成提示词交给大模型生成答案。全本地实现就意味着这四步的每一个组件都需要能在你的电脑上独立运行。2.1 模型服务Ollama的压倒性优势为什么选择Ollama来运行大模型答案就在那些热搜词里“ollama下载太慢了”、“ollama国内镜像”。大家抱怨的恰恰证明了它的流行。Ollama本质上是一个将大型模型封装成易用服务的工具。它帮你处理了最繁琐的部分模型文件的下载、管理以及提供一个标准的、类似OpenAI API的本地接口。你只需要一行命令ollama run qwen2.5:7b就能拉起一个模型服务然后用HTTP请求与之对话。这比自己去手动下载十几个G的模型文件再配置复杂的转换和加载环境要简单太多了。对于本地RAG我们需要的模型其实有两种一种是用于文本嵌入的模型另一种是用于最终答案生成的LLM。Ollama同样支持运行嵌入模型比如nomic-embed-text。这样模型服务这一块Ollama一站式搞定。我最初也尝试过直接用transformers库加载模型但对笔记本内存是极大的考验且推理速度慢。Ollama的优化做得更好尤其是在Mac M系列芯片或带GPU的机器上它能利用硬件加速体验提升明显。注意Ollama默认从官网拉取模型国内速度可能很慢。解决方法是使用国内镜像。例如在运行Ollama前设置环境变量OLLAMA_HOST指向镜像源或者直接修改Ollama的配置。这是你能否顺利开始的第一步务必先解决网络问题。2.2 向量数据库ChromaDB的轻量与易用向量数据库的选择很多比如Pinecone云服务、Weaviate、Qdrant等。但在“全本地”这个前提下ChromaDB几乎是首选。它就像一个轻量级的SQLite但是为向量检索设计的。你不需要启动任何额外的服务进程在Python中import chromadb它就能在内存里或者一个本地目录里运行起来。这对于我们200行代码的demo来说简直是绝配。ChromaDB的API设计也非常直观。创建集合、添加文档、执行相似性搜索几个简单的函数调用就完成了。它默认使用余弦相似度作为距离函数这对于文本向量来说通常是合适的。你不需要去纠结“chromadb 默认的距离函数”是什么除非你有非常特殊的精度要求。它的易用性让我们能把精力集中在RAG流程本身而不是数据库的配置和维护上。2.3 粘合剂Python与相关生态Python在这里的角色是“胶水”和“大脑”。我们将使用requests库与Ollama提供的HTTP API通信用chromadb库操作向量数据库用langchain或直接手写逻辑来组织文档加载和切分。是的我提到了LangChain这个强大的框架能极大简化流程。但在我们这个“极简”项目中为了彻底搞懂原理和控制代码量我选择核心部分自己实现只借用它的一两个工具函数比如文本分割器。这样的选择带来了一个好处代码极其透明。没有层层封装的魔法每一行在做什么都清清楚楚。当出现错误时你也能快速定位是嵌入环节、检索环节还是生成环节出了问题。这对于调试和学习RAG的内在机制至关重要。3. 环境准备与依赖安装避开第一个坑万事开头难环境配置是第一个拦路虎。很多人卡在“python安装”或“vscode python环境配置”上。我的建议是无论你用什么编辑器先确保有一个干净的Python环境。使用conda或venv创建独立的虚拟环境是一个好习惯能避免包版本冲突。3.1 基础Python环境首先确保你的Python版本在3.8以上。然后我们安装最核心的几个包。打开你的终端在项目目录下执行# 创建虚拟环境可选但推荐 python -m venv .venv # 激活虚拟环境 # Windows: .venv\Scripts\activate # Mac/Linux: source .venv/bin/activate # 安装核心依赖 pip install chromadb # 向量数据库 pip install requests # 用于调用Ollama API pip install pypdf # 用于读取PDF文档你也可以装python-docx处理Word pip install langchain # 我们主要用它的文本分割器 pip install langchain-community # 社区工具包这里有个小技巧langchain包比较大如果你只想用它的RecursiveCharacterTextSplitter理论上可以尝试找更轻量的替代。但考虑到它的稳定性和普及度直接安装是性价比最高的选择。安装时如果遇到网络问题记得为pip配置国内镜像源例如清华源或阿里云源。3.2 安装与配置Ollama这是关键一步也是热搜词“ollama下载太慢了”的重灾区。访问Ollama官网根据你的操作系统Windows、macOS、Linux下载安装包。安装过程通常很简单。安装完成后打开终端运行ollama --version确认安装成功。解决下载慢的问题这是必踩的坑。Ollama默认从国外拉模型速度极慢甚至失败。方法一推荐一劳永逸修改Ollama的服务配置。找到Ollama的配置文件或服务文件。在Linux/macOS上可以编辑~/.ollama/config.json如果不存在就创建加入{ registry: { mirrors: { docker.io: https://docker.mirrors.ustc.edu.cn, gcr.io: https://gcr.mirrors.ustc.edu.cn, quay.io: https://quay.mirrors.ustc.edu.cn } } }然后重启Ollama服务systemctl --user restart ollama或直接重启电脑。方法二通过环境变量在每次运行时指定。比较复杂不推荐。方法三如果实在不行可以寻找网友分享的已经下载好的模型文件手动放入Ollama的模型目录通常位于~/.ollama/models但这需要模型版本完全匹配。拉取模型我们至少需要两个模型。# 拉取一个用于生成答案的对话模型例如Qwen2.5-7B它比较均衡 ollama pull qwen2.5:7b # 拉取一个用于生成文本向量的嵌入模型例如nomic-embed-text ollama pull nomic-embed-text这个过程可能会很慢请耐心等待。你可以通过ollama list查看已下载的模型。3.3 验证环境环境装好后写个简单的脚本验证一下import requests import json # 测试Ollama生成模型是否正常 def test_ollama(): url http://localhost:11434/api/generate payload { model: qwen2.5:7b, prompt: Hello, how are you?, stream: False } try: response requests.post(url, jsonpayload) print(Ollama响应状态码:, response.status_code) if response.status_code 200: result response.json() print(模型回复:, result.get(response, No response)) else: print(错误详情:, response.text) except Exception as e: print(连接Ollama失败:, e) # 测试ChromaDB是否能导入 def test_chromadb(): try: import chromadb print(ChromaDB导入成功) # 尝试创建一个内存中的客户端 client chromadb.Client() print(ChromaDB客户端创建成功) except Exception as e: print(ChromaDB导入或创建失败:, e) if __name__ __main__: test_ollama() test_chromadb()运行这个脚本如果能看到模型回复和成功的导入信息那么恭喜你最艰难的环境关已经过了。4. 核心代码拆解200行里的每一个细节接下来我们进入核心部分。我会把整个RAG流程拆分成几个函数每个函数负责一个明确的职责最后组合起来。完整代码大约在180行左右不含空行和大量注释。4.1 文档加载与智能切分RAG的效果很大程度上取决于检索的质量而检索的质量又依赖于文本如何被切分成“块”。块太大检索会包含无关信息块太小会丢失上下文。我们使用LangChain的RecursiveCharacterTextSplitter它尝试用换行符、句号、逗号等递归地分割文本尽量保持语义段落完整。from langchain.text_splitter import RecursiveCharacterTextSplitter import PyPDF2 # 示例用PDF可替换为其他加载器 def load_and_split_pdfs(pdf_paths): 加载PDF文档并将其切分成文本块。 参数: pdf_paths: PDF文件路径列表。 返回: 包含文本块内容和元数据的列表。 all_texts [] all_metadatas [] for pdf_path in pdf_paths: print(f正在处理: {pdf_path}) with open(pdf_path, rb) as file: pdf_reader PyPDF2.PdfReader(file) pdf_text for page_num, page in enumerate(pdf_reader.pages): page_text page.extract_text() if page_text: pdf_text fPage {page_num1}: {page_text}\n # 使用文本分割器 text_splitter RecursiveCharacterTextSplitter( chunk_size500, # 每个块大约500字符 chunk_overlap100, # 块之间重叠100字符避免上下文断裂 separators[\n\n, \n, 。, , , ] # 分割符优先级 ) chunks text_splitter.split_text(pdf_text) # 为每个块创建元数据记录来源文档和块序号 for i, chunk in enumerate(chunks): all_texts.append(chunk) all_metadatas.append({source: pdf_path, chunk_id: i}) print(f共切分出 {len(all_texts)} 个文本块。) return all_texts, all_metadatas关键参数解析chunk_size500这个值需要权衡。对于通用文档500-1000字符是个不错的起点。你可以根据你的文档平均段落长度调整。chunk_overlap100重叠非常重要它确保了当一个句子或概念恰好被切分在两个块的边界时检索时仍然能通过重叠部分捕获完整信息。没有重叠检索效果会大打折扣。separators分割符的顺序决定了优先使用哪种分割方式。这里优先按段落分再按句子分。4.2 文本向量化与存储连接Ollama和ChromaDB这是核心中的核心。我们需要调用Ollama的嵌入模型将文本块转化为向量然后存入ChromaDB。import chromadb from chromadb.config import Settings import requests import hashlib class LocalRAGSystem: def __init__(self, collection_namemy_docs, persist_directory./chroma_db): 初始化RAG系统。 参数: collection_name: ChromaDB集合名称。 persist_directory: 向量数据库持久化目录。 # 创建ChromaDB客户端并持久化到磁盘 self.client chromadb.PersistentClient(pathpersist_directory) # 获取或创建集合 self.collection self.client.get_or_create_collection(namecollection_name) # Ollama API地址 self.ollama_host http://localhost:11434 # 使用的嵌入模型名称 self.embed_model nomic-embed-text # 使用的生成模型名称 self.llm_model qwen2.5:7b def get_embedding(self, text): 调用Ollama的嵌入API将文本转换为向量。 参数: text: 输入文本。 返回: 向量列表列表形式。 url f{self.ollama_host}/api/embeddings payload {model: self.embed_model, prompt: text} try: response requests.post(url, jsonpayload, timeout60) response.raise_for_status() embedding_data response.json() # Ollama返回的嵌入向量在 embedding 字段中 return embedding_data.get(embedding, []) except requests.exceptions.RequestException as e: print(f获取嵌入向量失败: {e}) return [] def add_documents_to_db(self, texts, metadatas): 将文本块向量化并添加到ChromaDB集合中。 参数: texts: 文本块列表。 metadatas: 对应的元数据列表。 if not texts: print(没有文本可添加。) return # 为每个文本生成一个唯一ID这里用MD5哈希简化 ids [hashlib.md5((text str(meta)).encode()).hexdigest()[:20] for text, meta in zip(texts, metadatas)] # **关键步骤批量获取嵌入向量** print(正在生成文本嵌入向量这可能需要一些时间...) embeddings [] for i, text in enumerate(texts): if i % 10 0: print(f 已处理 {i}/{len(texts)} 个文本块...) emb self.get_embedding(text) if emb: embeddings.append(emb) else: # 如果获取失败填充一个零向量长度需与模型维度匹配nomic-embed-text是768维 print(f警告: 第 {i} 个文本块嵌入失败使用零向量替代。) embeddings.append([0.0] * 768) # 注意维度 # 添加到集合 self.collection.add( embeddingsembeddings, documentstexts, metadatasmetadatas, idsids ) print(f成功添加 {len(texts)} 个文档块到集合 {self.collection.name}。)踩坑实录1向量维度不一致这里有一个巨坑不同的嵌入模型输出的向量维度不同。比如nomic-embed-text是768维而all-minilm可能是384维。如果你在add时因为某个文本嵌入失败而手动补零向量必须补对维度。否则后续检索时会因为向量维度与数据库存储时的维度不匹配而报错错误信息可能很隐晦。最稳妥的方式是先成功获取一个嵌入向量查看其长度然后用这个长度作为零向量的维度。踩坑实录2批量处理与超时直接循环调用嵌入API如果文档很多会非常慢且可能遇到网络超时。上述代码是简单的串行处理。在生产环境中你需要考虑使用asyncio或concurrent.futures进行异步或并发请求。在Ollama侧调整启动参数增加超时时间。对于大量文档可以先持久化嵌入向量到文件避免每次重启都重新计算。4.3 检索与生成组装最终的答案当用户提问时系统需要检索相关文档并组合提示词交给LLM生成答案。class LocalRAGSystem(LocalRAGSystem): # 接上面的类 def retrieve(self, query, n_results3): 根据查询检索最相关的文档块。 参数: query: 用户问题。 n_results: 返回结果数量。 返回: 检索到的文档列表和元数据。 # 首先将查询问题本身也转化为向量 query_embedding self.get_embedding(query) if not query_embedding: print(查询向量化失败。) return [], [] # 使用查询向量在集合中搜索 results self.collection.query( query_embeddings[query_embedding], n_resultsn_results ) # results 是一个字典包含 documents, metadatas, distances 等键 retrieved_docs results[documents][0] if results[documents] else [] retrieved_metas results[metadatas][0] if results[metadatas] else [] return retrieved_docs, retrieved_metas def generate_answer(self, query, retrieved_docs): 基于检索到的文档生成最终答案。 参数: query: 用户问题。 retrieved_docs: 检索到的相关文档块列表。 返回: LLM生成的答案。 if not retrieved_docs: return 未能检索到相关文档信息无法回答该问题。 # 构建Prompt这是影响答案质量的关键 context \n\n---\n\n.join(retrieved_docs) # 用分隔符连接检索到的文档 prompt f请你根据以下提供的上下文信息来回答问题。如果上下文信息中没有明确答案请直接说“根据已知信息无法回答该问题”不要编造信息。 上下文信息 {context} 问题{query} 请根据上下文信息回答 # 调用Ollama生成模型 url f{self.ollama_host}/api/generate payload { model: self.llm_model, prompt: prompt, stream: False, options: { temperature: 0.2, # 较低的温度使输出更确定更依赖上下文 num_predict: 1000 # 生成的最大token数 } } try: response requests.post(url, jsonpayload, timeout120) response.raise_for_status() answer_data response.json() return answer_data.get(response, 模型未返回有效答案。).strip() except requests.exceptions.RequestException as e: return f调用模型生成答案时出错: {e} def query(self, question, n_retrieve3): 对外查询接口检索 - 生成。 参数: question: 用户问题。 n_retrieve: 检索文档数量。 返回: 最终答案。 print(f检索与问题相关的文档...) docs, metas self.retrieve(question, n_resultsn_retrieve) print(f检索到 {len(docs)} 个相关文档块。) if docs: print(正在生成答案...) answer self.generate_answer(question, docs) # 可选打印参考来源 print(\n--- 参考来源 ---) for i, (doc, meta) in enumerate(zip(docs, metas)): print(f[{i1}] 来源: {meta.get(source, 未知)}, 块ID: {meta.get(chunk_id, N/A)}) print(f 片段预览: {doc[:150]}...) # 预览前150字符 print() return answer else: return 未找到相关文档。Prompt工程心得 Prompt的设计直接决定了LLM是否会“胡言乱语”或忽略上下文。我上面的Prompt模板包含了几个关键指令明确指令“根据以下提供的上下文信息来回答问题”。这告诉模型答案的边界。安全兜底“如果上下文信息中没有明确答案请直接说‘根据已知信息无法回答该问题’不要编造信息。” 这是防止模型幻觉的关键。没有这个指令模型很可能会用自己学到的知识可能过时或错误来编造答案。清晰的结构用“上下文信息”和“问题”清晰分隔输入。结尾指令“请根据上下文信息回答”再次强化指令。参数调优temperature0.2在RAG中我们通常希望答案确定、忠实于上下文。较低的temperature如0.1-0.3可以减少随机性。num_predict1000根据你预期的答案长度设置。对于摘要或复杂问题可以设大些。5. 完整流程串联与效果测试现在我们把所有部分组合起来形成一个完整的可执行脚本。# main.py import sys from pathlib import Path def main(): # 1. 初始化RAG系统 print(初始化本地RAG系统...) rag_system LocalRAGSystem(collection_namemy_knowledge_base) # 2. 检查集合是否已有数据如果没有则加载文档 if rag_system.collection.count() 0: print(向量数据库为空开始加载文档...) # 假设你的PDF文档放在 ./docs 目录下 pdf_dir Path(./docs) if not pdf_dir.exists(): print(f错误文档目录 {pdf_dir} 不存在。) sys.exit(1) pdf_paths list(pdf_dir.glob(*.pdf)) if not pdf_paths: print(未找到PDF文件。) sys.exit(1) texts, metadatas load_and_split_pdfs(pdf_paths) if texts: rag_system.add_documents_to_db(texts, metadatas) else: print(未提取到任何文本内容。) sys.exit(1) else: print(f向量数据库中已有 {rag_system.collection.count()} 条记录跳过文档加载。) # 3. 进入交互式问答循环 print(\n *50) print(本地RAG系统准备就绪输入您的问题输入quit退出) while True: try: user_question input(\n您的问题: ).strip() if user_question.lower() in [quit, exit, q]: print(再见) break if not user_question: continue answer rag_system.query(user_question) print(\n *30) print(答案, answer) print(*30) except KeyboardInterrupt: print(\n程序被中断。) break except Exception as e: print(f处理过程中发生错误: {e}) if __name__ __main__: main()运行与测试将你的PDF文档放入项目根目录的docs文件夹。确保Ollama服务正在运行终端里ollama serve或它已在后台运行。在终端运行python main.py。首次运行会经历文档加载、切分、向量化的过程耗时取决于文档数量和大小。完成后进入问答界面。尝试问一些基于你文档内容的问题。效果评估成功模型能基于你提供的文档片段生成相关的、准确的答案。常见问题答非所问检查检索结果。是不是检索到的文档不相关可能是嵌入模型不适合你的领域或者chunk_size设置不合理。幻觉编造检查Prompt是否包含了“不知道就说不知道”的指令。检查检索到的文档是否真的包含了答案。答案不完整增加n_retrieve参数让模型看到更多上下文。或者调整chunk_size让单个块包含更完整的信息。6. 避坑指南与进阶优化踩过的坑才是宝贵的经验。下面是我在开发过程中遇到的主要问题及解决方案希望能帮你绕过去。6.1 Ollama相关问题问题Ollama下载模型极慢或失败。解决如前所述配置国内镜像源是必须的。如果某个特定模型拉取失败可以尝试在Ollama官网查看该模型是否有其他标签如:latest换成具体的版本号:v1.0。问题运行模型时内存不足特别是7B以上模型。解决关闭不必要的应用程序。为Ollama设置CPU运行虽然慢ollama run qwen2.5:7b --num-ctx 2048减少上下文长度。换用更小的模型如llama3.2:3b或专门优化的phi3:mini。在拥有Apple Silicon的Mac上确保Ollama使用了Metal GPU加速效率更高。问题Ollama API调用超时。解决在代码中增加timeout参数如timeout120。对于生成任务模型需要思考时间超时时间要设得足够长。6.2 ChromaDB与向量化问题问题collection.add时报维度错误。解决这是最典型的坑。确保你补零向量的维度与嵌入模型输出维度完全一致。写个测试函数打印出len(self.get_embedding(test))来获取准确维度。问题检索结果不相关。解决调整文本切分尝试不同的chunk_size和chunk_overlap。对于技术文档可能需要较小的块如300和较大的重叠如50。尝试不同嵌入模型nomic-embed-text是通用型。对于中文可以尝试bge-m3或bge-large-zh需要自行在Ollama上查找或转换添加。嵌入模型的质量是检索效果的天花板。使用元数据过滤如果你的文档有章节、作者等元数据可以在collection.query时使用where参数进行过滤缩小检索范围。问题ChromaDB持久化文件越来越大。解决ChromaDB的持久化目录会存储所有数据。定期清理不再需要的集合client.delete_collection(name)或者对于只读知识库可以考虑在首次构建后将向量数据导出为更紧凑的格式如Parquet但ChromaDB本身不直接支持此功能需要额外处理。6.3 提示工程与答案质量问题模型无视上下文用自己的知识回答。解决强化Prompt指令。除了前面提到的还可以在上下文前面加上“重要指令你必须仅使用以下上下文来回答问题。”在问题后加上“请严格依据上下文不要添加任何外部知识。”使用更强大的模型如qwen2.5:14b或llama3.1:8b它们遵循指令的能力更强。问题答案冗长或格式混乱。解决在Prompt中指定回答格式。例如“请用简洁的列表形式总结...” 或 “请用不超过三句话回答...”。同时可以调整生成参数temperature更低top_p等。6.4 性能与扩展优化优化嵌入速度这是最大的瓶颈。可以使用asyncio并发请求Ollama嵌入API。将嵌入任务离线化存入数据库后后续查询无需再计算。考虑使用更轻量的本地嵌入模型如all-minilm虽然维度低但速度快。引入重排序简单的向量相似度检索可能不够精准。可以引入一个“重排序”步骤即先用向量检索出Top K个结果比如20个再用一个更精细的交叉编码器模型对这20个结果进行相关性打分和重排最后取Top N个比如3个送入LLM。这就是热搜词里“rag重排序”的概念。这能显著提升精度但会增加复杂度和延迟。尝试Agentic RAG这是更前沿的思路让LLM主动决定何时检索、检索什么、如何迭代检索。这超出了我们200行代码的范畴但了解这个概念有助于你未来扩展系统。这个用200行Python搭建的全本地RAG系统虽然简陋但五脏俱全。它验证了核心流程的可行性为你提供了一个可以随意拆卸、组装的“玩具”。你可以替换其中的任何一个组件用不同的嵌入模型、换用Qdrant向量数据库、集成更复杂的LangChain链、或者为前端加一个简单的Gradio界面。最重要的是通过亲手实现一遍你对RAG如何工作、每个环节的痛点在哪里有了最直观的认识。下次当你再听到“Agentic RAG”、“重排序”、“混合检索”这些术语时你就能立刻明白它们是在解决我们这个基础框架中的哪个环节的问题。
返回列表