Chapter 07

构建本地 RAG 系统

RAG(检索增强生成)让大模型"读懂"你的私有文档,在完全本地的环境中实现企业知识库问答。本章从嵌入模型选择到 ChromaDB 存储,实现完整的文档→向量→检索→生成流程。

本地 RAG 原理

RAG(Retrieval-Augmented Generation,检索增强生成)解决了大模型的两大局限:知识截止日期和无法访问私有数据。本地 RAG 系统完全运行在本机,无任何数据外泄风险:

本地 RAG 完整流程 【构建阶段(一次性)】 原始文档(PDF/Markdown/TXT) │ ▼ 文档加载 & 解析 文本内容(纯文本) │ ▼ 分块(Chunking) 文本块列表 [chunk1, chunk2, ..., chunkN] (每块 500-1000 字符,相邻块有 50-100 字符重叠) │ ▼ nomic-embed-text 嵌入 向量列表 [[0.12, -0.45, ...], ...] │ ▼ 存入 ChromaDB 向量库 持久化到本地磁盘 ✓ 【查询阶段(每次问答)】 用户问题:"这份报告的结论是什么?" │ ▼ 嵌入问题 问题向量 [0.23, -0.11, ...] │ ▼ ChromaDB 向量相似度搜索(top-k) 相关文本块 [chunk3, chunk7, chunk12] │ ▼ 拼接上下文 + 问题 → 发给 LLM Prompt: "基于以下文档内容回答问题:\n{context}\n\n问题:{question}" │ ▼ Ollama 本地推理 最终答案 ✓
向量嵌入(Vector Embedding)
将文本转换为高维浮点数向量的过程。语义相近的文本在向量空间中距离更近(余弦相似度更高)。"苹果很甜"和"水果的甜味"的向量距离,会远小于"苹果很甜"和"量子力学"的距离。嵌入模型是专门训练用来生成高质量嵌入的小型模型(不需要很大参数量),推荐 nomic-embed-text(768维)。
文本分块(Chunking)
将长文档切分为适当大小的文本块,以便嵌入和检索。分块大小(chunk_size)影响检索精度:太小则上下文不完整,太大则噪音增加。常用策略:按字符数分块(chunk_size=512,overlap=50),按段落分块,按句子分块。重叠(overlap)确保跨块边界的信息不丢失。
向量数据库(Vector Database)
专门存储和检索高维向量的数据库,支持高效的近似最近邻搜索(ANN)。ChromaDB 是最适合本地部署的选择:纯 Python 实现,数据存储在本地磁盘,无需服务器,嵌入式运行。其他选项:Qdrant(性能更强),Faiss(Meta 出品,纯内存)。

环境准备:安装依赖与嵌入模型

# 安装 Python 依赖
pip install chromadb ollama pypdf markdown

# 拉取嵌入模型(必须)
ollama pull nomic-embed-text    # 推荐:体积小(274MB),速度快,中英双语
ollama pull mxbai-embed-large   # 备选:更高质量,1024维,中文稍弱

# 拉取生成模型
ollama pull qwen2.5:7b   # 中文 RAG 推荐

# 验证嵌入模型可用
curl http://localhost:11434/api/embeddings \
  -d '{"model": "nomic-embed-text", "prompt": "测试"}' | \
  python3 -c "import sys,json; d=json.load(sys.stdin); print(f'维度: {len(d[\"embedding\"])}')"

完整 RAG 系统实现

"""
local_rag.py — 完整的本地 RAG 知识库问答系统
依赖:pip install chromadb ollama pypdf
"""
import ollama
import chromadb
from pathlib import Path
from typing import Optional
import hashlib

# ── 配置 ──────────────────────────────────────────────
EMBED_MODEL = "nomic-embed-text"
CHAT_MODEL  = "qwen2.5:7b"
CHUNK_SIZE  = 500   # 每块字符数
CHUNK_OVERLAP = 50  # 相邻块重叠字符数
TOP_K = 5           # 检索返回最相关的 k 个块

# ── 初始化 ChromaDB(持久化到本地磁盘)─────────────────
db_client = chromadb.PersistentClient(path="./rag_db")
collection = db_client.get_or_create_collection(
    name="knowledge_base",
    metadata={"hnsw:space": "cosine"}  # 使用余弦相似度
)

# ── 步骤1:文档加载 ──────────────────────────────────
def load_document(file_path: str) -> str:
    """支持 txt、md、pdf 格式的文档加载。"""
    path = Path(file_path)
    if path.suffix == ".txt":
        return path.read_text(encoding="utf-8")
    elif path.suffix == ".md":
        return path.read_text(encoding="utf-8")
    elif path.suffix == ".pdf":
        import pypdf
        reader = pypdf.PdfReader(file_path)
        return " ".join(page.extract_text() for page in reader.pages)
    else:
        raise ValueError(f"不支持的文件格式: {path.suffix}")

# ── 步骤2:文本分块 ──────────────────────────────────
def chunk_text(text: str, size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> list[str]:
    """按字符数分块,相邻块有重叠。"""
    chunks = []
    start = 0
    while start < len(text):
        end = min(start + size, len(text))
        chunk = text[start:end].strip()
        if chunk:
            chunks.append(chunk)
        start += size - overlap
    return chunks

# ── 步骤3:嵌入并存入向量库 ─────────────────────────
def add_document(file_path: str):
    """加载文档,分块,嵌入,存入 ChromaDB。"""
    print(f"正在处理:{file_path}")
    text = load_document(file_path)
    chunks = chunk_text(text)
    print(f"  分块数量:{len(chunks)}")

    for i, chunk in enumerate(chunks):
        # 生成向量嵌入
        resp = ollama.embeddings(model=EMBED_MODEL, prompt=chunk)
        embedding = resp["embedding"]

        # 生成唯一 ID(文件名 + 块序号)
        doc_id = f"{hashlib.md5(file_path.encode()).hexdigest()[:8]}_chunk_{i}"

        # 存入向量库
        collection.upsert(
            ids=[doc_id],
            embeddings=[embedding],
            documents=[chunk],
            metadatas=[{"source": file_path, "chunk_index": i}]
        )

    print(f"  ✓ 已添加 {len(chunks)} 个块到知识库")

# ── 步骤4:检索相关块 ───────────────────────────────
def retrieve(query: str, k: int = TOP_K) -> list[str]:
    """将查询嵌入,在向量库中检索最相关的 k 个文本块。"""
    query_emb = ollama.embeddings(model=EMBED_MODEL, prompt=query)["embedding"]
    results = collection.query(
        query_embeddings=[query_emb],
        n_results=k,
        include=["documents", "distances", "metadatas"]
    )
    return results["documents"][0]  # 返回文本块列表

# ── 步骤5:RAG 问答 ──────────────────────────────────
def rag_query(question: str) -> str:
    """检索相关文档,生成基于上下文的答案。"""
    # 检索相关文本块
    relevant_chunks = retrieve(question)
    context = "\n\n---\n\n".join(relevant_chunks)

    # 构建 RAG Prompt
    prompt = f"""请基于以下文档内容回答用户的问题。
如果文档中没有相关信息,请明确说明"文档中未找到相关信息",不要凭空捏造。

【文档内容】
{context}

【用户问题】
{question}

【回答】"""

    response = ollama.generate(
        model=CHAT_MODEL,
        prompt=prompt,
        options={"temperature": 0.3}
    )
    return response["response"]

# ── 主程序 ──────────────────────────────────────────
if __name__ == "__main__":
    # 添加文档到知识库
    add_document("./docs/company_handbook.pdf")
    add_document("./docs/product_manual.md")

    # 查询
    questions = [
        "公司的休假政策是什么?",
        "产品如何重置出厂设置?",
    ]
    for q in questions:
        print(f"\n❓ {q}")
        print(f"💡 {rag_query(q)}\n")

LangChain 与 LlamaIndex 集成

流行的 RAG 框架都内置了对 Ollama 的支持,几行代码即可集成:

# ── LangChain + Ollama ──────────────────────────────
# pip install langchain langchain-ollama langchain-chroma
from langchain_ollama import OllamaLLM, OllamaEmbeddings
from langchain_chroma import Chroma
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.chains import RetrievalQA

# 初始化模型
llm = OllamaLLM(model="qwen2.5:7b")
embeddings = OllamaEmbeddings(model="nomic-embed-text")

# 文档分块
splitter = RecursiveCharacterTextSplitter(
    chunk_size=500, chunk_overlap=50, separators=["\n\n", "\n", "。", " "]
)
with open("./docs/handbook.txt") as f:
    docs = splitter.create_documents([f.read()])

# 创建向量库并存入
vectorstore = Chroma.from_documents(
    docs, embeddings, persist_directory="./lc_rag_db"
)

# 创建 RAG Chain
qa = RetrievalQA.from_chain_type(
    llm=llm,
    retriever=vectorstore.as_retriever(search_kwargs={"k": 5})
)

result = qa.invoke({"query": "员工福利有哪些?"})
print(result["result"])
RAG 质量优化要点 1. 分块大小:中文文档建议 200-400 字符(比英文更小,因为每个汉字信息密度更高);2. 重叠比例:建议 10-15% 的重叠(如 chunk_size=500,overlap=60);3. 检索数量:k=5 通常够用,过多会引入噪音;4. 嵌入模型选择直接影响检索质量,中文文档优先选支持中文的嵌入模型。
本章小结 本地 RAG 系统核心组件:嵌入模型(nomic-embed-text)+ 向量数据库(ChromaDB)+ 生成模型(qwen2.5)。流程:加载文档 → 分块(500字/块,50字重叠)→ 嵌入存储 → 查询嵌入 → 向量检索(top-5)→ 拼接上下文 → LLM 生成。LangChain 和 LlamaIndex 均内置 Ollama 集成,可快速搭建生产级 RAG。下一章介绍图形界面工具,让非开发者也能使用本地模型。