摘要:用 Ollama、Python 标准库和一个 JSON 文件,完成"文档切块 → 向量化 → 相似度检索 → 带上下文回答"的最小闭环。不依赖 LangChain,所有数据留在本机。
先把问题说清楚:把公司的《差旅报销规则》丢给本地模型,并不会让模型永久记住它。用户问"高铁一等座能报销吗"时,程序必须先找到文档中对应的段落,再把这段文本和问题一起交给模型。
这个"先找再答"的动作,就是本文要拆开的 RAG。
1. 准备 Ollama 和两个模型
从 Ollama 官网 安装与系统对应的版本,然后在终端执行:
bash
ollama pull embeddinggemma
ollama pull qwen3.5:4b
embeddinggemma 只负责把文本变成向量,它不负责生成最终答案;qwen3.5:4b 负责根据检索出的文本组织回答。两个模型不要混用。
如果机器内存较小,可把生成模型换成已经能在本机流畅运行的小模型,后面通过 --chat-model 传入。
2. 创建文档目录
bash
uv init --app --bare local-rag
cd local-rag
uv python pin 3.12
mkdir docs
touch rag.py docs/报销规则.md
本例调用 Ollama HTTP API 时只使用 Python 标准库,所以没有运行时第三方依赖。
在 docs/报销规则.md 中写入:
markdown
# 差旅报销规则
员工出差前应在系统中提交出差申请,并由直接主管审批。未审批的出差不予报销。
高铁票默认报销二等座。单程行程超过 6 小时时,经部门负责人批准可报销一等座。
住宿费按城市级别限额报销:一线城市每晚不超过 600 元,其他城市每晚不超过 400 元。超出部分需附书面说明。
返程后 10 个工作日内应完成报销,同时上传发票和行程单。
3. 写完最小 RAG
将下面代码保存为 rag.py:
python
import argparse
import json
import math
import os
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "http://localhost:11434").rstrip("/")
def post_json(path: str, payload: dict[str, Any]) -> dict[str, Any]:
request = Request(
f"{OLLAMA_HOST}{path}",
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urlopen(request, timeout=120) as response:
return json.loads(response.read().decode("utf-8"))
except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"Ollama API 返回 HTTP {exc.code}: {detail}") from exc
except URLError as exc:
raise RuntimeError(f"无法连接 Ollama({OLLAMA_HOST}),请确认服务已启动: {exc}") from exc
def chunk_text(text: str, max_chars: int = 600) -> list[str]:
"""Split text on paragraphs, then split oversized paragraphs."""
chunks: list[str] = []
for paragraph in text.split("\n\n"):
clean = " ".join(paragraph.split())
if not clean:
continue
chunks.extend(clean[start : start + max_chars] for start in range(0, len(clean), max_chars))
return chunks
def load_documents(docs_dir: Path) -> list[dict[str, str]]:
documents: list[dict[str, str]] = []
for path in sorted(docs_dir.rglob("*.md")):
for chunk in chunk_text(path.read_text(encoding="utf-8")):
documents.append({"source": str(path.relative_to(docs_dir)), "text": chunk})
return documents
def embed(texts: list[str], model: str) -> list[list[float]]:
response = post_json("/api/embed", {"model": model, "input": texts})
return response["embeddings"]
def cosine_similarity(left: list[float], right: list[float]) -> float:
numerator = sum(a * b for a, b in zip(left, right))
left_norm = math.sqrt(sum(value * value for value in left))
right_norm = math.sqrt(sum(value * value for value in right))
if left_norm == 0 or right_norm == 0:
return 0.0
return numerator / (left_norm * right_norm)
def rank_chunks(
query_vector: list[float], index: list[dict[str, Any]], top_k: int
) -> list[dict[str, Any]]:
ranked = []
for item in index:
copy = {"source": item["source"], "text": item["text"]}
copy["score"] = cosine_similarity(query_vector, item["embedding"])
ranked.append(copy)
return sorted(ranked, key=lambda item: item["score"], reverse=True)[:top_k]
def build_index(docs_dir: Path, index_file: Path, embed_model: str) -> None:
documents = load_documents(docs_dir)
if not documents:
raise ValueError(f"{docs_dir} 中没有 Markdown 文档")
vectors = embed([item["text"] for item in documents], embed_model)
if len(vectors) != len(documents):
raise RuntimeError("Ollama 返回的向量数量与文本块数量不一致")
for item, vector in zip(documents, vectors):
item["embedding"] = vector
index_file.write_text(json.dumps(documents, ensure_ascii=False), encoding="utf-8")
print(f"已写入 {len(documents)} 个文本块: {index_file}")
def ask(
question: str,
index_file: Path,
embed_model: str,
chat_model: str,
top_k: int,
) -> None:
if top_k < 1:
raise ValueError("top_k 必须大于 0")
index = json.loads(index_file.read_text(encoding="utf-8"))
query_vector = embed([question], embed_model)[0]
matches = rank_chunks(query_vector, index, top_k)
context = "\n\n".join(f"[来源: {item['source']}]\n{item['text']}" for item in matches)
response = post_json(
"/api/chat",
{
"model": chat_model,
"stream": False,
"messages": [
{
"role": "system",
"content": (
"你只能根据用户提供的参考资料回答。"
"资料不足时明确说无法从已知资料确定,不要猜测。"
),
},
{
"role": "user",
"content": f"参考资料:\n{context}\n\n问题:{question}",
},
],
},
)
print("命中的文档块:")
for item in matches:
print(f"- {item['source']} score={item['score']:.3f}")
print("\n回答:")
print(response["message"]["content"])
def main() -> None:
parser = argparse.ArgumentParser(description="Ollama 本地 RAG")
parser.add_argument("--docs", type=Path, default=Path("docs"))
parser.add_argument("--index", type=Path, default=Path("index.json"))
parser.add_argument("--embed-model", default="embeddinggemma")
parser.add_argument("--chat-model", default="qwen3.5:4b")
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("index", help="为 docs 中的 Markdown 文档建立索引")
ask_parser = subparsers.add_parser("ask", help="向本地文档提问")
ask_parser.add_argument("question")
ask_parser.add_argument("--top-k", type=int, default=3)
args = parser.parse_args()
if args.command == "index":
build_index(args.docs, args.index, args.embed_model)
else:
ask(args.question, args.index, args.embed_model, args.chat_model, args.top_k)
if __name__ == "__main__":
main()
4. 建索引,然后提问
第一次运行先建立索引:
bash
uv run rag.py index
程序把每个文本块发给 Ollama 的 /api/embed 端点,再把返回的向量写入 index.json。修改 docs/ 里的文档后,需要重新执行这条命令。
现在提问:
bash
uv run rag.py ask "高铁一等座可以报销吗?"
结果会先列出命中的文档块和余弦相似度,然后给出回答。回答用词会随模型有所不同,但关键条件应当稳定:默认只报二等座;单程超过 6 小时且经部门负责人批准时,才可报一等座。
再问一个文档没写的问题:
bash
uv run rag.py ask "出差可以报销健身房费用吗?"
系统提示词要求模型在资料不足时明确说无法确定。这不能在数学上保证模型绝不猜测,但它至少把回答边界写进了每一次请求。
这 100 多行代码里,RAG 到底发生在哪
真正的分界点在 rank_chunks()。用户问题先被转成一个向量,再与文档向量逐一计算余弦相似度。排在前 top_k 的文本块才会进入最终 prompt。
这个实现使用 JSON 保存向量,查询时做全量遍历,适合学习机制和小型私有文档。它不适合几十万个文本块的生产检索;数据量到那个级别时,替换的是索引存储和近似最近邻查询,"先找再答"这条主线并不变。
常见报错
Connection refused
程序连不上 http://localhost:11434。先确认 Ollama 已启动,再用下面的命令检查 API:
bash
curl http://localhost:11434/api/tags
model not found
模型还没拉取,或命令中的模型名不一致。执行 ollama list 查看本地模型。
换了嵌入模型后结果很奇怪
文档向量和问题向量必须由同一个嵌入模型生成。更改 --embed-model 后删除旧 index.json,重新执行 index。
到这里,可以打开 index.json 看一眼。每条记录只有来源、文本和一串浮点数。RAG 并没有把私有知识神秘地"训练进模型",它只是在每次回答前,把最像答案的几段原文找回来。