不用 LangChain,从 0 写一个 Ollama 本地 RAG

摘要:用 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 并没有把私有知识神秘地"训练进模型",它只是在每次回答前,把最像答案的几段原文找回来。

参考资料

相关推荐
考虑考虑2 小时前
Redis8.8新特性
运维·redis·后端
geovindu2 小时前
java:Observer Pattern
java·开发语言·后端·观察者模式·设计模式·行为模式
山甫aa2 小时前
【从零开始的 Web 后端学习】令牌技术一篇搞定(JWT 登录认证保姆级)
后端·学习·spring·web·jwt
wxwx_bscxy3222 小时前
springboot巡更系统10192
java·spring boot·后端·巡更系统
lmy_loveF2 小时前
go 切换go version 版本
开发语言·后端·golang
行者全栈架构师2 小时前
Spring Boot + FFmpeg 视频批量处理实战:压缩、HLS切片与异步任务引擎
后端
用户345138101342 小时前
搭建一个springboot项目并整合其他中间件(更新中)
后端
Gopher_HBo2 小时前
Cobra使用指南
后端
用户7713970207062 小时前
ASP.NET Core Identity 从入门到实战:常见问题与解决方案
后端