乐于分享
好东西不私藏

Ollama 实用案例(二)本地文档 RAG

Ollama 实用案例(二)本地文档 RAG

很多团队第一次做 RAG,会先讨论向量数据库、检索框架、权限系统、知识库后台。结果几天过去,真正的问题还没回答:模型能不能从自己的文档里找到正确证据?

本地 RAG 的第一步不该是搭一套平台,而是做一个最小闭环:把文档切块,转成向量,用相似度找回片段,再让本地模型只基于片段回答。

Ollama 已经把这个闭环里最麻烦的两件事降到很低:本地运行大语言模型,本地生成 embeddings。几百篇 Markdown、会议纪要、项目文档,完全可以先用一个 Python 脚本跑起来。

RAG 的价值不在“接了向量库”,而在能不能把正确上下文送到模型面前,并让模型承认资料里没有答案。

先给结论

个人或小团队做本地文档问答,我建议先不要上复杂架构。

最小版本只需要四步:

  • • 读取 .md .txt 文档。
  • • 按较小片段切块。
  • • 用 Ollama 的 embedding 模型把片段和问题都变成向量。
  • • 用余弦相似度取 Top K,再把片段塞进本地聊天模型。

这个版本没有权限管理、没有增量索引、没有 rerank、没有向量数据库。但它足够回答一个更重要的问题:你的文档质量、切块方式和检索信号是否足以支撑问答。

共同底座:Ollama + 一个生成模型 + 一个 embedding 模型

先安装 Ollama,并确认本地 API 可用:

ollama pull qwen3:8bollama run qwen3:8b "用一句话解释 RAG"

再拉一个 embedding 模型。Ollama 官方文档说明,embeddings 会把文本变成数值向量,可用于语义搜索、检索和 RAG;/api/embed 支持单条文本,也支持批量输入。

可以直接用官方推荐列表里的模型,例如:

ollama pull embeddinggemmapip install ollama numpy

如果你已经习惯 nomic-embed-text,也可以继续使用:

ollama pull nomic-embed-text

关键原则只有一个:索引文档和查询问题必须使用同一个 embedding 模型。

最小 RAG 流水线

RAG 不是魔法,它的链路很朴素。

一份文档不会直接整篇塞给模型。你要先把它切成片段,例如每 300 到 500 个词一个 chunk。然后对每个 chunk 生成向量,得到一个矩阵。

用户提问时,也把问题生成向量。接着计算问题向量和每个文档片段向量的相似度,取最接近的几个片段作为上下文。

最后把这些片段放进 prompt,并加上一条硬约束:

只根据下面的上下文回答问题。如果上下文没有答案,请直接说“资料中没有找到答案”。

这条约束很重要。RAG 系统最危险的失败,不是回答慢,而是在没有证据时编一个听起来很像真的答案。

一个可以跑的最小版本

下面这段代码保留了核心逻辑:加载文档、切块、生成向量、相似度检索、调用本地模型回答。

from pathlib import Pathimport sysimport numpy as npimport ollamaEMBED_MODEL = "embeddinggemma"CHAT_MODEL = "qwen3:8b"CHUNK_WORDS = 400TOP_K = 3def load_chunks(folder: str) -> list[str]:    chunks = []    for path in Path(folder).rglob("*"):        if path.suffix not in {".md"".txt"}:            continue        words = path.read_text(encoding="utf-8").split()        for i in range(0len(words), CHUNK_WORDS):            chunk = " ".join(words[i:i + CHUNK_WORDS]).strip()            if chunk:                chunks.append(f"[{path.name}]\n{chunk}")    return chunksdef embed(texts: list[str]) -> np.ndarray:    resp = ollama.embed(model=EMBED_MODEL, input=texts)    return np.array(resp["embeddings"], dtype=np.float32)def top_k(query_vec: np.ndarray, doc_mat: np.ndarray, k: int) -> list[int]:    q = query_vec / (np.linalg.norm(query_vec) + 1e-8)    d = doc_mat / (np.linalg.norm(doc_mat, axis=1, keepdims=True) + 1e-8)    scores = d @ q    return np.argsort(scores)[::-1][:k].tolist()def answer(query: str, chunks: list[str], doc_mat: np.ndarray) -> str:    q_vec = embed([query])[0]    picked = top_k(q_vec, doc_mat, TOP_K)    context = "\n\n---\n\n".join(chunks[i] for i in picked)    prompt = f"""只根据下面的上下文回答问题。如果上下文没有答案,请直接说“资料中没有找到答案”。上下文:{context}问题:{query}"""    resp = ollama.chat(        model=CHAT_MODEL,        messages=[{"role""user""content": prompt}],    )    return resp["message"]["content"]if __name__ == "__main__":    folder, query = sys.argv[1], sys.argv[2]    chunks = load_chunks(folder)    doc_mat = embed(chunks)    print(answer(query, chunks, doc_mat))

运行方式:

python rag.py ~/Documents/notes "上次会议对定价策略做了什么决定?"

这不是生产级代码,但它把 RAG 的关键部件都放在眼前了。你能清楚看到错误来自哪里:文档没读到、chunk 切坏了、embedding 不准、Top K 取错了,还是模型没有遵守上下文。

为什么先不用向量数据库

向量数据库当然有价值,但它解决的是规模化之后的问题。

当你只有几百个文档、几千个 chunk 时,NumPy 矩阵已经足够快。把所有向量放进内存,一次矩阵乘法就能得到相似度排序。这个阶段更该关注检索质量,而不是系统形态。

什么时候需要换成 sqlite-vec、LanceDB、Qdrant、Milvus 这类方案?

  • • 文档量已经大到每次启动重新 embedding 很慢。
  • • 需要持久化索引,而不是每次运行都重建。
  • • 需要按项目、用户、权限、时间过滤。
  • • 需要增量更新和删除。
  • • 需要并发查询和服务化 API。

在那之前,一个脚本更适合学习和验证。

真正容易踩坑的地方

第一个坑是切块太粗。整篇文档一个向量,会把主题压成平均值;模型检索到的是“像这篇文档”,不是“回答这个问题的那几句话”。

第二个坑是切块太碎。每句话一个向量,语义上下文会断掉,模型拿到片段后不知道前因后果。

第三个坑是 prompt 不约束。没有“只根据上下文回答”的约束,本地模型很容易把训练知识和检索内容混在一起。

第四个坑是只看回答,不看证据。更好的做法是把命中的 chunk 文件名和片段一起打印出来。先检查检索对不对,再检查生成好不好。

如何评估效果

本地 RAG 的验收不需要复杂指标,先准备 20 个真实问题。

其中 10 个问题答案确实存在于文档里,5 个需要跨两个片段合并,5 个文档里没有答案。然后看三类结果:

  • • 有答案的问题,是否命中正确片段。
  • • 跨片段问题,是否能合并而不乱编。
  • • 无答案问题,是否敢说找不到。

如果“无答案问题”经常被模型编出来,优先修 prompt 和检索展示;如果“有答案问题”经常找不到,优先修切块、embedding 模型和 Top K。

总结

本地 RAG 最值得学习的不是某个框架,而是检索增强的基本闭环。

Ollama 提供本地生成模型和 embedding 接口,NumPy 负责最小相似度检索,一个严格 prompt 负责把模型限制在证据范围内。这个组合不华丽,但足够让你理解 RAG 在真实项目中到底会坏在哪里。

先用最小脚本把自己的文档跑起来。等你能稳定回答真实问题,再决定要不要升级向量数据库、rerank、权限过滤和服务化架构。