news 2026/10/9 8:21:34

本地RAG实践:用Ollama和ChromaDB构建私人文档问答助手

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
本地RAG实践:用Ollama和ChromaDB构建私人文档问答助手

2026年1月25日,第29天。这是我给自己定的“100天实践计划”走到第29天的一个节点,今天干了一件之前一直想做但没敢动手的事:在本地电脑上把一堆散乱的PDF、Word和Markdown文档,变成一个能针对内容直接提问的“私人资料问答助手”。前28天我一直在补基础,过了两轮Python、熟悉了常用数据结构和命令行工具、写了不少零零碎碎的小脚本,积累到这个程度刚好够用,又刚好不够用:够用是指语言和生态都熟了,不够用是指如果一上来就想做完整产品,绝对完不成。所以这一天的目标很明确——跑通最小闭环,能在命令行里敲一个问题,得到一个有出处的回答,先不碰界面、权限、多人协作这些外围功能。

这篇记录是“Day29-20260125”当天的实操笔记,适合正在学习RAG落地、想在本地搭一个知识库问答工具、或者单纯想找一个能一天跑通的综合实战项目的开发者参考。我把选型思路、核心代码、踩坑排查过程都留下来了,照着操作基本能复现,遇到问题的那几个坑也能帮你少耽误几天。

1. 为什么把第29天押在“资料问答助手”上

1.1 前28天积累到哪一步

这个“100天实践计划”不是临时拍的脑袋,我是按阶段推进的:第1到第10天专注Python语法和面向对象,把函数、类、装饰器、迭代器这些基础过了一遍;第11到第20天开始接触常用库,文件读写、正则表达式、os和pathlib用得最多,同时把命令行工具、git的基本操作练到条件反射;第21到第28天开始做真正的小项目,包括一个爬取天气信息的脚本、一个批量重命名文件的工具、一个简单的数据处理流水线,这些项目规模不大,但每个都包含了“数据获取、清洗、输出”的完整过程。

到了第28天晚上,我坐在电脑前给第29天定方向,手里的积累其实处于一个临界状态:直接做一个复杂应用,经验不够;但如果只是在现有基础上多会一个库,又没有挑战性。这时候我想到自己工作里真正头疼的事——电脑里的各种技术文档越攒越多,有PDF教程、有开会记录、有Markdown笔记,真到要查一个具体问题时,要么翻半天,要么打开后还得一篇一篇扫。做一个“能直接问问题”的工具,既有真实需求,又能把前面学的东西全部串起来。

1.2 为什么是RAG而不是训练一个模型

这里必须说清楚一个概念,不然容易走偏。我想要的不是训练自己的模型,那既需要高质量标注数据,又需要相当的算力,一天时间根本不够。我要做的是检索增强生成,也就是RAG:把文档切分成小块,提前计算并存入向量数据库,用户提问时先检索最相关的文本片段,再把片段交给模型生成答案。

选RAG有几个现实理由:

  • 它不需要微调模型,换一份新文档只要重新入库就能回答,扩展性很好;
  • 它可以把答案追溯到具体片段,降低模型乱编的概率,这一点对“资料问答”特别重要;
  • 它的技术栈覆盖面很广,文档解析、文本切分、向量化、检索、提示词、模型调用全都包含,一天跑通下来,等于把一整条链路的常见问题都过了一遍;
  • 纯本地运行,隐私风险小,不依赖云服务,也不怕断网。

1.3 当天目标边界:不做什么

Day29一开始我就明确写了三个“不做”:不做网页界面,只做命令行交互;不做多用户权限,只面对我这台电脑上的资料;不做全格式支持,优先覆盖PDF、Word、Markdown和纯文本四类常见文件。把技术边界先锁死,才能保证一天内能看到可以验收的结果。

我始终觉得,一个好的实践日应该“有约束”,没有边界就容易陷进细节里。比如PDF解析这一项,如果去追求所有表格都完美还原,光这个点就能耗掉好几天。第一版只要能做到“能提取文字、能按块切分、能检索、能回答”,就已经是重大胜利。

2. Day29当天敲定的技术栈与取舍理由

2.1 最终组件清单

当天最终确定的技术栈是这样的:

环节使用的组件选择理由
文档解析pdfplumber、python-docx、内置文件读取表格解析相对靠谱,中文兼容性好,社区活跃
文本切分自研切分函数,基于段落和标点避免通用切分器切出语义完全不完整的碎片
向量化bge-m3 embedding 模型,通过 Ollama 调用中文语义效果更好,维度适中,本地运行可控
向量存储ChromaDB默认持久化,重启不丢数据,API简单易上手
生成模型qwen2.5 7B,通过 Ollama 调用中文生成能力强,7B参数在消费级CPU上可用
交互方式命令行Python脚本目标简单,浪费最少时间在外围功能上

2.2 关键选择背后的理由

先说我为什么坚持“全本地”。一方面是有隐私考虑,我那些文档里有些是内部资料,不适合传到外部服务;另一方面是调试方便,本地跑可以随时看清每一步的输入和输出,不用一直惦记网络延迟和配额。Ollama在这里帮了很大的忙,它的模型管理做得比较省心,一条命令就能在本地拉起一个大语言模型,还自带OpenAI兼容接口,代码里只需要用requests这种最常见的库就能调用,不必为了接入某个平台而学一套新的SDK。

向量化模型我选了bge-m3,没有用默认的英文模型。原因很直接:我手头的文档大多数是中文,英文模型对中文的理解经常停留在字面匹配,比如“部署方式”和“上线流程”这种语义相近但字面不同的说法,英文模型很容易检索不到。bge-m3对中文长文本的支持比较好,在语义相似度上明显更符合直觉。ChromaDB则是我对比了一圈之后觉得最适合第一版的选择,它的本地存储方案就是一个目录,API设计非常直白,建集合、加文档、查相似度,三步就能完成,不需要单独部署服务器。

代码库环境准备我用了一行pip命令搞定:

pip install pypdf pdfplumber python-docx chromadb

模型部分我提前装了Ollama,然后把嵌入模型和生成模型拉到了本地。这里需要说明:我选择qwen2.5 7B而不是更大参数的模型,是因为我的电脑内存有限,7B模型跑起来已经需要大概8GB内存,换成13B或更大很容易卡死,在“先跑通”的目标下,小模型足够测试链路问题了。

3. 切分、向量化、检索、生成:主流程一步步跑通

3.1 文档加载与清洗:先让内容“干净”地进入内存

做问答助手的第一个认知是:大部分文档不能直接切分。PDF提取出来经常带无关页眉、页脚、页码;Word文档里可能有批注;Markdown里混杂着代码块和链接。这些噪声如果不提前清理,向量化之后会占据大量位置,检索时还会干扰相似度计算。

我写的加载函数大概长这样:

from pathlib import Path import re def load_document(path: Path) -> str: suffix = path.suffix.lower() if suffix == ".pdf": return _load_pdf(path) elif suffix == ".docx": return _load_docx(path) else: raw = path.read_text(encoding="utf-8", errors="ignore") return raw def _load_pdf(path: Path) -> str: import pdfplumber texts = [] with pdfplumber.open(path) as pdf: for page in pdf.pages: text = page.extract_text() or "" texts.append(text) return "\n".join(texts) def _load_docx(path: Path) -> str: import docx doc = docx.Document(path) parts = [p.text for p in doc.paragraphs] return "\n".join(parts)

清洗这一步我用了几条简单的规则:去掉连续空行、去掉超短行(比如只有页码的行)、把全角空格统一转成半角、把非打印控制字符删掉。这里有个容易忽略的点:PDF按页提取后,一页末尾的换行和下一页开头的换行要合并成空格,否则同一个句子被拆到两页时,切分后语义会断掉。这个问题我一开始没注意,导致第一版检索结果明显变差,后面在踩坑环节还会细说。

3.2 切分:把长文档切成“可检索单元”

切分是整个链路里最考验经验的一环。目标很简单:让每一段文本在语义上尽量完整,同时又不能长到让检索失去精确性。切得太粗,一个片段里包含多个主题,检索时容易把不相关的内容混进来;切得太细,片段只有一两句话,语义信息不完整,模型拿到手也没法顺畅回答。

我采用的策略是:优先按段落边界切,段落太长再按标点切。具体逻辑是,先把文本按“\n\n”拆成自然段,对每个超过上限的段落,再按“。!?\n”拆句,然后以句为单位重新凑块。我设定块大小上限为600个字符,块与块之间重叠约80个字符。做一个简单对比:

块大小(字符)重叠实际效果
30030片段多、检索粒度细,但常常把一段完整的论述拆散,答案上下文不全
60080语义片段比较完整,检索也基本准确,最终选择这个参数
1000150片段少、检索粗略,连带着回答容易泛泛而谈

代码可以写得比较简单,关键是保留每个片段的来源信息:

def split_text(text: str, chunk_size: int = 600, overlap: int = 80) -> list[dict]: paragraphs = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()] chunks = [] buffer = "" for para in paragraphs: if len(buffer) + len(para) <= chunk_size: buffer += "\n" + para else: for sent in re.split(r"(?<=[。!?!?])", para): if len(buffer) + len(sent) > chunk_size and buffer: chunks.append(buffer.strip()) buffer = buffer[-overlap:] buffer += sent if buffer: chunks.append(buffer.strip()) result = [] for i, c in enumerate(chunks): result.append({"text": c, "source": None, "chunk_id": i}) return result

这里把“overlap”理解为上一块末尾的局部文本作为下一块的起始,这样即使一个关键句被切到两块开头,检索时也不会完全漏掉。

3.3 向量化入库:把文本变成可计算相似度的向量

切分完成后,下一步是对每块文本做嵌入,也就是把文本映射成一个固定维度的向量。我用Ollama的embedding接口,模型指定为bge-m3,接口返回的向量维度是1024,足够表达中文语义细节。

入库的核心逻辑是:

import chromadb import requests import json def get_embedding(text: str) -> list[float]: resp = requests.post("http://localhost:11434/api/embed", json={ "model": "bge-m3", "input": text }) resp.raise_for_status() return resp.json()["embeddings"][0] client = chromadb.PersistentClient(path="./kb_store") collection = client.get_or_create_collection( name="docs", metadata={"hnsw:space": "cosine"} ) def index_documents(chunks: list[dict], source: str): embeddings = [get_embedding(c["text"]) for c in chunks] ids = [f"{source}-{c['chunk_id']}" for c in chunks] metadatas = [{"source": source, "chunk_id": c["chunk_id"]} for c in chunks] documents = [c["text"] for c in chunks] collection.add( ids=ids, embeddings=embeddings, metadatas=metadatas, documents=documents )

有几个细节我现在要特别强调。第一,一定要给每个片段保存source元数据,不然后面回答时无法说明“答案来自哪份文档”,整个工具有出处的价值就没了。第二,批量写入时一次不要传太多片段,我在首轮测试时给几百个片段一次性提交,直接把本地服务搞崩了一次,后来改成每批50个,稳很多。第三,ChromaDB的路径参数最好用绝对路径,否则脚本在别的目录下再次运行时,会找不到之前建的库,白白浪费一次入库时间。

3.4 检索与生成:后端链路最后一段

检索阶段我做了两步:先用向量相似度把最相关的Top-K片段捞出来,然后用一个距离阈值把明显不相关的过滤掉。Top-K我设置成5,同时要求归一化后的距离小于0.4。需要说明的是,这个阈值不能拍脑袋定死,我在后续测试里调了半天,阈值太严会漏掉正确答案,太松又容易混入噪音。

生成的提示词是整个工具的灵魂。如果直接丢给模型一段检索文本让它回答,模型很容易扩写、编造、把自己训练时候的记忆混进来。我在提示词里写了几条硬约束:只依据提供资料;找不到就直说找不到;回答时标注每条信息来源的文件名;不要使用外部知识补全。

def generate_answer(query: str, chunks: list[dict]) -> str: context_lines = [] for i, chunk in enumerate(chunks, 1): context_lines.append(f"[{i}] 「{chunk['text']}」 来源: {chunk['source']}") context = "\n".join(context_lines) user_prompt = f"资料如下:\n{context}\n\n问题:{query}\n\n要求:只能依据上述资料回答,找不到依据时明确说不知道。" resp = requests.post("http://localhost:11434/api/chat", json={ "model": "qwen2.5:7b", "messages": [ {"role": "system", "content": "你是严谨的资料问答助手,禁止编造答案。"}, {"role": "user", "content": user_prompt} ], "options": {"temperature": 0.2}, "stream": False }) return resp.json()["message"]["content"]

temperature设到0.2是我反复试出来的:太高会让答案发散,0.7以上经常出现与资料无关的表述;0.2既能保证逻辑通顺,又不至于让输出过于干瘪。

实际调用时,整个命令行流程就三步:读入用户问题;把问题转成向量在Chroma里查Top-K;把查到的片段交给生成模型。这一步跑通时,我第一次在终端里看到“根据《xxx-2024.md》第3段,部署方式为……”这样的回答,说实话当时还挺兴奋的,因为整条链路都活了。

4. 检索不准与答案断裂:记录一次完整的踩坑排查链路

4.1 第一轮测试:看起来能答,其实靠蒙

当天中午,我把约30份文档全部入库,开心地输入了第一个真正的业务问题:“项目里推荐的部署方式是什么?”模型给出的答案看起来非常流畅,逻辑也通顺,但我直觉觉得不对——答案里提到的关键词在我印象里根本不来自那几份文档。我对着元数据查了一下,发现检索到的片段来自一份讲“权限设计”的资料,和“部署方式”完全没有关系。

这个现象我一定要记录:模型流畅不等于回答正确,尤其在RAG流水线里,如果检索环节就错了,生成环节再丝滑也只是在“一本正经地复述错误上下文”。我当时第一反应是怀疑嵌入模型不够好,但后来冷静下来,决定先把检索到的片段实际打印出来看一看。

看完检索结果,问题就很清楚了:我早期用的切分方案是“按段落切,超过2000字符就硬切”,导致有些段落非常长,向量被平均成了一团浆糊,和“部署方式”这种具体问题的向量距离都很远,自然捞出的是随机片段。我立刻改成上一节说的600字符方案,并对长段落按句重切,同一问题的Top-5结果里终于出现了真正提到部署方式的段落。这一轮的经验是:当你觉得“检索不准”时,先不要急着换嵌入模型,先检查切分粒度,切分问题是最容易出问题也最容易被人忽略的一环。

4.2 第二轮:切分好了,但PDF表格变成“一坨”

切分修好之后,我又试了一个关于表格数据的问题:“前三季度的收入指标是多少?”这次Top-5里确实出现了带“收入”字样的片段,但答案完全不可用——模型给出的数字是乱的,像是把表格里不同列的内容拼接到了一起。

我把原始文本拉出来看了一眼,瞬间明白了。pdfplumber的extract_text()按文本流抽取PDF内容,在处理跨越多列表格时,经常把A列第一行、B列第一行、A列第二行这种顺序错乱地混在一起。模型读到的原始文字顺序和表格的真实行列对不上,自然没法回答问题。

排查过程我用了两步:

# 第一步:抽取当前页所有单词,按坐标排序后重组 import pdfplumber def extract_table_by_position(pdf_path: str, page_num: int) -> str: with pdfplumber.open(pdf_path) as pdf: page = pdf.pages[page_num] words = page.extract_words(use_text_flow=False) words.sort(key=lambda w: (w["top"], w["x0"])) lines = {} for w in words: key = round(w["top"] / 5) lines.setdefault(key, []).append(w["text"]) return "\n".join(" ".join(lines[k]) for k in sorted(lines))

先用坐标排序把同一行的单词按x方向拼起来,再按行距把行与行区分开,这样得到的结果基本能恢复表格的阅读顺序。对少数复杂表格,我当天做了一个更粗暴但有效的决定:直接不追求解析复杂合并单元格,而是把PDF里对应的表格页单独截个图,放到一个附注文件里,检索时把这些附注文本也纳入索引。

这一轮让我记住了一个教训:PDF是RAG项目里最大的坑来源。它不是标准文本格式,同样的表格在不同PDF里生成方式完全不同。如果你的文档里有大量表格,前期花时间专门打磨解析器绝对划得来。

4.3 第三轮:检索对了,生成还是被带偏

修完表格问题后,我又问了一个关于“告警阈值配置”的问题。这次检索结果非常准确,排名靠前的5个片段里有4个都直接提到了阈值配置,但模型给出的答案还是不对劲——它把5个片段里的内容全揉进去了,其中有一个片段提到的是另一个场景下的阈值,结果和正确答案参数的语义完全相反。

这就是典型的“上下文污染”:检索到了相关内容,但片段之间可能存在矛盾或场景差异,模型没有足够的辨别力就全盘接受。我的解法分三层:

  • 第一层,过滤。阈值距离从0.5收紧到0.4,减少弱相关片段混进来的机会;
  • 第二层,精简。如果Top-5片段的总字符超过了1500,只保留距离最近的几个,宁可少也不要乱;
  • 第三层,提示词强化。在user prompt末尾加了一句“如果多个资料片段存在冲突,请优先采用片段[编号]最小且距离最近的内容,并说明其他片段与当前回答不一致。”

三层改完后再测同一个问题,输出引用了正确的片段,还主动标注了另一个片段的场景限制。虽然做不到完全消除干扰,但已经足够用。

4.4 当天的排错经验表

把当天的三个主要问题汇总成一张表,方便后续排查:

现象根因定位手段最终解法
检索结果完全无关切分块太大,语义被平均化打印Top-K原始片段,人工查看按段落+句子二次切开,块上限600
表格类问题答案数字混乱PDF文本抽取不按阅读顺序对比原始文本与PDF页面显示用坐标排序重组行列
检索正确但回答被带偏弱相关片段干扰模型判断逐个检查命中片段内容收紧距离阈值+精简上下文+强化提示词

5. Day29收尾时的实际效果与Day30调整方向

5.1 当天的测试结果

为了给“跑通”这件事一个可验收的标准,我下午做了个小测试集,从30份文档里挑了12个问题,覆盖检索类、概括类、表格类三种情况。跑完后的成绩是:完全命中的有7个,部分命中的3个,明显不达标的2个。完全命中的标准是“回答内容与目标片段一致,且来源标注正确”;部分命中的标准是“方向对了,但细节有缺失”;不达标的那两题都出在复杂表格上,其中一个涉及跨页合并单元格,一个涉及图片里的文字,这两类内容第一版本来就决定不做。

从资源占用角度看,整条流水线跑起来后,Chroma库占用大约1.2GB磁盘,内存高峰期在2GB附近,单个问题的完整回答耗时大约3到8秒,速度主要取决于切分片段的数量和生成模型的推理速度。对本地工具来说,这个数字完全可以接受。

5.2 经验沉淀:真的要先把“长尾格式”放一边

Day29最大的心得其实不是技术细节,而是流程顺序。我一开始如果把“支持所有格式”摆在“跑通回答闭环”前面,大概率会卡在PDF表格解析上出不了成果。正确顺序是先让Markdown和纯文本跑通整条链路,再逐步加Word、PDF、表格、扫描件。每加一种格式,就单独验证一遍它对检索结果的改善或影响。这样每个阶段都有明确的验收标准,不会觉得一天到头都在瞎忙。

还有一个小经验是:嵌入模型和生成模型的能力再好,也弥补不了“切分和清洗”的粗糙。切分决定了信息的基本单元,清洗决定了单元里有没有噪音。这部分做得草率,后面所有环节都会跟着遭殃。

5.3 Day30做什么

Day30的方向我已经想好了,优先做三件事:一是给文档加标签分类,让检索时可以通过目录参数缩小范围,这样能明显提升跨领域文档库的准确率;二是加一个简单的输出检查,如果模型回答中出现了不来自引用片段的明显名词,就在答案下方给你提醒;三是做一个最朴素的网页对话框,让工具可以脱离命令行,可以被更多人直接使用。

按当前进度,到这个100天计划的第40天左右,这个项目应该能变成一个能日常稳定用的私人资料检索工具。不过这些都是后话,第29天当天的里程碑就是:本地文档、自然语言提问、带出处的回答,这三件事在我这台机器上正式闭环了。

最后再留一个当天的实操小技巧:如果你也准备做这种本地问答助手,第一天千万别贪多,先拿10份以内、格式相对干净的文本文件完成首轮测试,把链路跑通后再往里面加复杂格式。我当天一开始直接上了30份文档,结果排查问题时每改一个参数都要重新入库,浪费了不少时间。文件少,迭代快,成功概率才会更高。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/9 8:21:00

SpringBoot+Vue+MySQL个人理财系统开发实战与部署指南

个人理财系统这个题目标题我看了很多遍——SpringBoot后端Vue前端MySQL&#xff0c;标注“可直接运行”。说实话&#xff0c;这类项目最大的价值恰恰就在“可直接运行”这四个字上&#xff0c;但也是最大的坑。很多同学下载完源码对着 springboot 版本太高、vue安装及环境配置、…

作者头像 李华
网站建设 2026/10/9 8:18:48

Java后端大模型接入实战:构建Prompt过滤与敏感信息脱敏防线

最近在做一个Java后端接入大模型的项目&#xff0c;先说个现象&#xff1a;用户这边觉得自己在和“智能助手”聊天&#xff0c;那边我们的服务实际上已经把用户输入的手机号、身份证号、银行卡号、家庭住址&#xff0c;连同prompt一起原封不动地发给了外部大模型API。更要命的是…

作者头像 李华
网站建设 2026/10/9 8:18:12

评分卡模型实战:从逻辑回归到信用分数映射全流程解析

简介&#xff1a;以逻辑回归为核心的评分卡模型构建项目&#xff0c;面向希望在机器学习与风控建模方向入门或进阶的学习者。项目完整覆盖特征工程、WOE编码、IV值计算与特征筛选、特征WOE化、评分卡建模等关键环节&#xff0c;输入筛选后特征的属性值即可自动得出评分&#xf…

作者头像 李华
网站建设 2026/10/9 8:16:50

OpenClaw本地AI部署实战:从环境避坑到技能库排雷

如果你正在折腾本地AI&#xff0c;搜到了不少“本地AI总报错”的求助帖&#xff0c;说明你多半已经踩进了同一个坑&#xff1a;模型文件下载了、接口也通了&#xff0c;结果一接入 Agent 框架就开始连环报错。OpenClaw 这个开源 AI 代理框架最近在社区里相当火&#xff0c;它主…

作者头像 李华
网站建设 2026/10/9 8:15:55

防火墙与IDS/IPS协同作战:边界安全加固的盾剑组合实战

干网络安全这行久了&#xff0c;你会发现一个特别有意思的现象&#xff1a;一说防火墙&#xff0c;大家都点头&#xff1b;一问IDS/IPS&#xff0c;有人就含糊了。其实这三样东西凑在一起&#xff0c;才是一套真正完整的边界安全体系——防火墙负责当“盾”&#xff0c;把不该进…

作者头像 李华
网站建设 2026/10/9 8:15:41

Pinia状态管理实战:从Vuex迁移到Vue 3的TypeScript友好方案

先把结论放在最前面&#xff1a;如果你正在用Vuex&#xff0c;或者刚接触Vue 3状态管理&#xff0c;把时间花在Pinia基础上是回报率很高的一件事。我第一次把一个老项目的Vuex迁移到Pinia时&#xff0c;原本两百多行的store配置缩到了不到八十行&#xff0c;TypeScript的提示也…

作者头像 李华