本地 RAG 知识库搭建:Ollama + 向量库 + 文档切片实践
内容标识:本文为 AI 辅助生成内容。依据 《人工智能生成合成内容标识办法》2025-09-01,已添加显式标识(本声明)与隐式标识(文件元数据 / 图片 ComfyUI 元信息)。文中技术细节与结论均由作者复核确认。
本地大模型直接问私有资料,十有八九在胡编——它根本没见过你的文档。解决思路就是 RAG(检索增强生成):先按问题从自己的文档库里检索出相关片段,把片段塞进提示词,让模型「看着材料答题」。之前我在 PVE 虚拟机里把 Ollama 跑起来了(见 用 Ollama 在 PVE 虚拟机里跑本地大模型),这篇就在此基础上把整套 RAG 管道搭通,重点聊聊最容易踩坑的切片粒度问题。
选型:embedding 模型与向量库
RAG 需要两类模型:一个生成答案的聊天模型,一个把文本转成向量的 embedding 模型。后者经常被忽略,但中文场景下选错 embedding,召回质量直接崩。我的组合是:
- 聊天模型:
qwen3:8b(8G 显存可跑量化版) - Embedding 模型:
bge-m3(中英混排效果好,Ollama 官方库直接收录)
ollama pull qwen3:8b
ollama pull bge-m3
向量库用 Chroma:纯 Python、单文件持久化、无需额外服务进程,个人知识库量级(几万条 chunk)完全够用:
pip install chromadb
切片:粒度决定召回质量
这是全文最想强调的一点:RAG 效果的一半在切片,另一半在 embedding,模型本身反而不是瓶颈。
切片太碎(比如 100 字),每个 chunk 缺乏上下文,向量化后语义模糊,召回回来的片段答非所问;切片太大(比如 2000 字),一个 chunk 里主题混杂,检索精度被稀释,还会挤占提示词窗口。实践中的起点是:
- chunk size 取 300–500 字(中文按字符计,不按 token)
- 相邻 chunk 保留 10%–15% 重叠(overlap),防止一句话被正好切断
- 按文档结构切(标题、段落)优先于按固定长度硬切,Markdown 先按
##分节再按长度细分
一个判断标准:随机抽 20 条 chunk 出来人工读,如果「单独看这一条也能看懂在讲什么」,粒度就基本合适;如果大量 chunk 缺主语、缺背景,说明切碎了。
入库:最小可用脚本
下面是一个最小可用的入库脚本:读目录下的 Markdown 文件,按段落聚合到目标长度,调用 Ollama 的 embedding 接口写进 Chroma:
import pathlib, chromadb, requests
OLLAMA = "http://127.0.0.1:11434"
def embed(text: str) -> list[float]:
r = requests.post(f"{OLLAMA}/api/embed",
json={"model": "bge-m3", "input": text}, timeout=60)
r.raise_for_status()
return r.json()["embeddings"][0]
def split_text(text: str, size: int = 400, overlap: int = 50):
paras, chunks, buf = text.split("nn"), [], ""
for p in paras:
if len(buf) + len(p) > size and buf:
chunks.append(buf)
buf = buf[-overlap:] + p # 尾部重叠
else:
buf += "nn" + p if buf else p
if buf:
chunks.append(buf)
return chunks
db = chromadb.PersistentClient(path="./rag_db").get_or_create_collection("docs")
for md in pathlib.Path("./docs").glob("**/*.md"):
for i, chunk in enumerate(split_text(md.read_text(encoding="utf-8"))):
db.add(ids=[f"{md.stem}-{i}"], documents=[chunk],
embeddings=[embed(chunk)],
metadatas=[{"source": md.name}])
print("入库完成,chunk 总数:", db.count())
检索与生成:拼出完整问答
查询时用同一个 embedding 模型把问题向量化,取 top-k 相关 chunk,拼进提示词交给聊天模型:
def ask(question: str, k: int = 4):
hits = db.query(query_embeddings=[embed(question)], n_results=k)
context = "nn---nn".join(hits["documents"][0])
prompt = (f"根据以下资料回答问题,资料中没有的信息请明说不知道:nn"
f"{context}nn问题:{question}")
r = requests.post(f"{OLLAMA}/api/generate",
json={"model": "qwen3:8b", "prompt": prompt,
"stream": False}, timeout=300)
return r.json()["response"]
两个调优方向:一是 top-k 不是越大越好,k=4 左右通常足够,塞太多无关片段反而干扰模型;二是可以在 metadata 里记录来源文件,回答时把出处一起打印出来,方便人工核对——这对建立「这套系统可信赖」的信心非常重要。
总结
本地 RAG 的最小闭环就三件事:文档切成 300–500 字、带重叠的 chunk,用 bge-m3 这类中文友好的 embedding 模型向量化入库,查询时检索 top-k 片段拼进提示词交给本地模型。整条管道不需要任何云服务,文档不出内网,这正是私有知识库相对在线方案的核心价值。先跑通最小脚本,再花时间打磨切片策略——按文档结构切、人工抽检 chunk 可读性,比换更大的模型见效快得多。如果文档量上到十万级 chunk,再考虑把 Chroma 换成 Qdrant 等专业向量库也不迟。