本地 RAG 知识库搭建:Ollama + 向量库 + 文档切片实践

本地 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 等专业向量库也不迟。

类似文章

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注