教程 12:完整的本地 RAG 流程

作者: Jakub Rusinowski最后更新 12 分钟阅读Advanced

用一个简短的 Python 脚本,自己动手实现“与文档对话”:读取 PDF,把内容切分成段落,用 Ollama 中的本地模型生成向量(embedding),存入本地向量数据库,然后带引用地回答问题。所有数据都不会离开你的电脑。

各页面的分工。 想不写代码就用起来?请看操作指南 Chat with your PDFs privately(AnythingLLM、Open WebUI 或 LM Studio;英文页面)。想先了解什么是 RAG?请阅读 RAG 入门。本页是“自己动手”的版本,适合想看清并掌控每一步的读者。

你将构建什么

  1. 用 pypdf 提取每一页的文本。
  2. 把每一页切分成相互重叠的段落。
  3. 用 Ollama 中的向量模型(embeddinggemma)为每个段落生成向量。
  4. 把段落、向量以及文件名和页码存入 Chroma——一个保存在你本地磁盘文件夹中的向量数据库。
  5. 检索与问题最接近的段落,再用本地聊天模型回答,并要求它注明来源页。

1. 安装

你需要较新版本的 Python 3 和 Ollama。

bash
pip install ollama chromadb pypdf
ollama pull embeddinggemma

然后下载一个用于回答问题的聊天模型。模型查找器 会为你的电脑挑选合适的模型;你在 Ollama 中已有的任何聊天模型也都可以。

2. 脚本

将以下内容保存为 rag.py:

python
"""Local RAG: index a folder of PDFs with Ollama embeddings into Chroma, then ask questions."""
import argparse
import pathlib

import chromadb
import ollama
from pypdf import PdfReader

EMBED_MODEL = "embeddinggemma"   # an Ollama embedding model; change it and you must re-index
DB_PATH = "rag-db"               # where Chroma keeps the index on disk
CHUNK_CHARS = 1200               # passage length, in characters
OVERLAP_CHARS = 200              # how much consecutive passages share


def chunks(text: str):
    step = CHUNK_CHARS - OVERLAP_CHARS
    for start in range(0, len(text), step):
        yield text[start:start + CHUNK_CHARS]


def embed(texts: list[str]) -> list[list[float]]:
    return ollama.embed(model=EMBED_MODEL, input=texts)["embeddings"]


def collection():
    return chromadb.PersistentClient(path=DB_PATH).get_or_create_collection("docs")


def index(folder: str) -> None:
    col = collection()
    for pdf in sorted(pathlib.Path(folder).glob("*.pdf")):
        for page_no, page in enumerate(PdfReader(pdf).pages, start=1):
            text = (page.extract_text() or "").strip()
            if not text:
                print(f"  skipped {pdf.name} page {page_no}: no text layer (a scan needs OCR first)")
                continue
            parts = list(chunks(text))
            col.upsert(
                ids=[f"{pdf.name}:{page_no}:{i}" for i in range(len(parts))],
                documents=parts,
                embeddings=embed(parts),
                metadatas=[{"file": pdf.name, "page": page_no}] * len(parts),
            )
        print(f"indexed {pdf.name}")


def ask(question: str, model: str, k: int) -> None:
    hits = collection().query(query_embeddings=embed([question]), n_results=k)
    context = "\n\n".join(
        f"[{meta['file']} p.{meta['page']}]\n{doc}"
        for doc, meta in zip(hits["documents"][0], hits["metadatas"][0])
    )
    reply = ollama.chat(model=model, messages=[
        {"role": "system", "content": "Answer only from the context. After each claim, cite the [file p.N] tag it came from. "
                                      "If the context does not contain the answer, say you cannot find it."},
        {"role": "user", "content": f"Context:\n{context}\n\nQuestion: {question}"},
    ])
    print(reply["message"]["content"])


if __name__ == "__main__":
    p = argparse.ArgumentParser()
    sub = p.add_subparsers(dest="cmd", required=True)
    sub.add_parser("index").add_argument("folder")
    a = sub.add_parser("ask")
    a.add_argument("question")
    a.add_argument("--model", required=True, help="a chat model you have pulled in Ollama")
    a.add_argument("-k", type=int, default=4, help="how many passages to retrieve")
    args = p.parse_args()
    if args.cmd == "index":
        index(args.folder)
    else:
        ask(args.question, args.model, args.k)

各部分的作用:

  • chunks() 把一页切成长度为 CHUNK_CHARS 个字符的段落,相邻段落共享 OVERLAP_CHARS 个字符,这样在边界处被切断的句子仍会完整地出现在某一个段落中。
  • embed() 调用 Ollama 的向量 API。文档和问题必须使用同一个向量模型。
  • index() 保存每个段落及其文件名和页码,并使用 upsert,因此对同一文件夹再次运行时会更新索引,而不会重复写入。
  • ask() 检索最接近的 k 个段落,并要求聊天模型只根据这些段落作答,同时注明 [文件 p.N]。

3. 运行

把 PDF 放进一个文件夹,然后运行:

bash
python rag.py index ./my-pdfs
python rag.py ask "What is the notice period for ending the lease?" --model <your-chat-model>

好的回答会引用正确的段落,并以 [lease.pdf p.1] 这样的标记结尾。打开那一页核对一下。你也可以直接用中文提问。

4. 进一步改进

  • 扫描版 PDF 没有文本层。 pypdf 无法读取图片中的文字;脚本会跳过这些页面并给出提示。请先做 OCR(例如 Tesseract),再为结果建立索引。
  • 段落长度。 短段落更容易精确命中事实,长段落能保留更多上下文。修改 CHUNK_CHARS 和 OVERLAP_CHARS,删除 rag-db 文件夹,然后重新建立索引。
  • 检索的段落数量。 如果答案遗漏了分散在多页中的信息,请调大 -k。段落越多,聊天模型需要的上下文窗口就越长。
  • 更换向量模型就要重建索引。 不同模型生成的向量不能混用:删除 rag-db,再运行一次 index。
  • 其他语言。 embeddinggemma 使用多种语言的文本训练,因此中文文档无需任何修改即可使用。
  • 总结不等于检索。 “总结整篇文档”需要整篇文档,而不是四个段落。请逐页总结。

判断检索到的段落是否真正相关,是一个是/否问题,而决策模型会给出一个概率,你可以为它设置阈值;如何选择这个阈值有专门的指南介绍。

延伸阅读