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

参考资料

相关推荐
IT_陈寒2 小时前
Java线程池这破玩意,差点让我周末加班排查到凌晨
前端·人工智能·后端
弈栈录3 小时前
Java AI 应用的异步化与高并发设计
java·后端·架构
凤山老林3 小时前
Spring Boot 集成 iText 7 实现动态 PDF 生成与电子签章:合同、报表场景实战
spring boot·后端·pdf·itext7·电子签章
孙启超3 小时前
【AI开发之Rust】第 19 课:UniFFI 导出核心能力
开发语言·后端·rust
ServBay4 小时前
基于Jev的浏览器Agent插件狂揽 21k star,3分钟教你解放双手
后端·aigc·ai编程
Json____4 小时前
家居装修 AI 智能咨询助手:让装修咨询从“大海捞针“变成“一问即答“
java·后端·vue3·it学习·wwwoop.com
Apifox5 小时前
Apifox 9 月更新|CLI 能力升级、GitLab 私有化部署接入与产品体验优化
前端·后端·测试
鱼弦5 小时前
Agent智能体 vs 传统运维:职业天花板的3倍差距?
后端
暗夜行者之光5 小时前
LangGraph 实战:用 LangSmith 可视化追踪 AI 智能体执行轨迹
后端
鱼弦5 小时前
Agent 的工具选择策略:从硬编码到动态决策
后端