去年开始接触大模型应用开发时,第一个遇到的实际问题就是:怎么让模型回答我自己的文档内容?直接把文档塞进提示词里肯定不行——上下文窗口就那么大,几十页 PDF 根本放不下。后来才了解到 RAG(检索增强生成)是解决这个问题的标准做法。这里把从零搭建一个本地 RAG 系统的完整过程记录下来,所有组件都是开源的,数据不出本机。

一、什么时候需要自己搭 RAG?

  • 内部知识库问答:公司有几十份技术文档、产品手册,想让 AI 基于这些文档回答问题
  • 个人笔记检索:自己写了半年的工作笔记,想用自然语言找到相关内容
  • 敏感数据不外传:文档涉及客户信息或商业机密,不能上传到 ChatGPT 等云端服务
  • 定制化回答:想让模型基于特定的技术规范或行业标准来回答问题

RAG 的核心思路很简单:先把文档拆成小块(chunk)并向量化存起来,用户提问时把问题也向量化,去库里找到最相关的几块内容,连同问题一起丢给 LLM 去回答。模型不是"记住"了你的文档,而是每次都在你的文档里"查"到答案再回答。

二、方案一:本地部署 RAG(Ollama + LangChain + ChromaDB)

这是今天要搭建的方案,完全本地运行,数据不出机器。

环境准备

# 安装 Ollama(macOS / Linux)
curl -fsSL https://ollama.com/install.sh | sh

# Windows 去官网下载安装包

# 下载一个本地模型(7B 参数,普通电脑够用)
ollama pull qwen2.5:7b

# 安装 Python 依赖
pip install langchain langchain-community chromadb pypdf sentence-transformers

第一步:文档加载与分割

from langchain_community.document_loaders import PyPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from pathlib import Path

def load_documents(doc_dir: str):
    """加载指定目录下的所有 PDF 文档"""
    docs = []
    for pdf_file in Path(doc_dir).glob("*.pdf"):
        print(f"加载文档: {pdf_file.name}")
        loader = PyPDFLoader(str(pdf_file))
        docs.extend(loader.load())
    print(f"共加载 {len(docs)} 页")
    return docs

def split_documents(docs, chunk_size=500, chunk_overlap=100):
    """将文档切分成小块,块之间保留重叠"""
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=chunk_size,
        chunk_overlap=chunk_overlap,
        separators=["\n\n", "\n", "。", "!", "?", ",", " ", ""]
    )
    chunks = splitter.split_documents(docs)
    print(f"分割为 {len(chunks)} 个文本块")
    return chunks

# 使用示例
documents = load_documents("./my_docs")
chunks = split_documents(documents)

chunk_size 和 chunk_overlap 是两个需要调的核心参数。我试了不同组合:

chunk_sizechunk_overlap效果
20050块数多但上下文容易断,长段落被切碎
500100平衡,大部分场景适用
1000200块数少但一次性返回的信息量大

500/100 的组合对大多数技术文档表现比较平衡。

第二步:向量化存储

from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_community.vectorstores import Chroma

# 使用本地 embedding 模型(不需要联网)
embeddings = HuggingFaceEmbeddings(
    model_name="BAAI/bge-small-zh-v1.5",
    model_kwargs={'device': 'cpu'},
    encode_kwargs={'normalize_embeddings': True}
)

def build_vector_store(chunks, persist_dir="./chroma_db"):
    """构建向量数据库并持久化到磁盘"""
    vector_store = Chroma.from_documents(
        documents=chunks,
        embedding=embeddings,
        persist_directory=persist_dir
    )
    vector_store.persist()
    print(f"向量库已保存到 {persist_dir}")
    return vector_store

vector_store = build_vector_store(chunks)

初次运行会下载 bge-small-zh-v1.5 模型到本地 ~/.cache/huggingface/ 目录,之后离线也能用。向量库保存在 ./chroma_db 目录下,下次启动可以直接加载,不需要重新处理文档。

第三步:检索与问答

from langchain_ollama import OllamaLLM
from langchain.chains import RetrievalQA
from langchain.prompts import PromptTemplate

# 连接 Ollama 本地模型
llm = OllamaLLM(
    model="qwen2.5:7b",
    temperature=0.3,
    num_predict=2048,
)

# 创建检索器:每次返回最相关的 4 个文本块
retriever = vector_store.as_retriever(search_kwargs={"k": 4})

# 自定义提示模板
prompt_template = """你是一个技术文档助手,请基于以下文档内容回答问题。

文档内容:
{context}

问题:{question}

请用中文回答,如果文档中没有相关信息,请直接说"文档中没有找到相关信息",不要编造答案。
"""

prompt = PromptTemplate(
    template=prompt_template,
    input_variables=["context", "question"]
)

# 组装 QA 链
qa_chain = RetrievalQA.from_chain_type(
    llm=llm,
    chain_type="stuff",
    retriever=retriever,
    return_source_documents=True,
    chain_type_kwargs={"prompt": prompt}
)

# 开始问答
def ask(question: str):
    result = qa_chain.invoke({"query": question})
    print(f"回答: {result['result']}")
    print(f"\n参考来源: {len(result['source_documents'])} 个文档片段")
    for i, doc in enumerate(result['source_documents'], 1):
        print(f"  [{i}] {doc.metadata.get('source', '未知')} 第{doc.metadata.get('page', '?')}页")
    return result

ask("我们的产品支持哪些导出格式?")

第四步:用 Gradio 搭个简单的 Web 界面

import gradio as gr

def answer_question(question, history):
    result = qa_chain.invoke({"query": question})
    sources = "\n".join([
        f"- {doc.metadata.get('source', '?')} 第{doc.metadata.get('page', '?')}页"
        for doc in result['source_documents']
    ])
    return result['result'] + f"\n\n**参考来源:**\n{sources}"

with gr.ChatInterface(
    answer_question,
    title="本地知识库助手",
    description="基于私有文档的问答系统,所有数据在本地运行",
    theme="soft",
) as demo:
    demo.launch(server_name="0.0.0.0", server_port=7860)

跑起来后浏览器打开 http://localhost:7860 就能用。我往里面塞了 5 份产品技术文档(共 120 页),问"如何配置数据备份"大概 3-4 秒出答案,比翻 PDF 快多了。

适合:有 Python 基础、需要处理私密文档、希望完全掌控数据流向的场景。

不太适合:不想折腾环境配置、只需要在线问答一次性的场景。

三、方案二:Dify 社区版——Web 界面可视化搭建

如果不想写代码,Dify 是一个开源(MIT 协议)的 LLM 应用开发平台,提供了可视化的 RAG 工作流编排界面。

# 使用 Docker 部署
git clone https://github.com/langgenius/dify.git
cd dify/docker
cp .env.example .env
docker compose up -d

启动后访问 http://localhost:3000

  1. 创建知识库 → 上传文档(支持 PDF、TXT、Markdown、网页抓取)
  2. 系统自动切分文档并向量化(内置 embedding 模型)
  3. 创建对话型应用 → 关联知识库 → 发布 API 或 Web 页面

Dify 的切分策略比手动调参友好——提供了"通用"和"父子切分"两种模式。父子切分是比较实用的功能:父块包含完整段落,子块是小片段用于精确检索,检索到子块后把父块内容送给 LLM,既命中准了又保留完整上下文。

Dify 还支持多用户权限管理,如果部门里几个人都要用同一个知识库,Dify 比脚本方案方便。

适合:不想写代码、需要 Web 管理界面、团队协作使用。

不太适合:需要深度定制检索逻辑、或者不想引入 Docker 依赖的场景。

四、方案三:使用 LangChain 自建 API 服务

如果想把 RAG 能力封装成 API 供其他系统调用,可以基于 FastAPI + LangChain 搭建推理服务:

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import List, Optional
import uvicorn

app = FastAPI(title="RAG API Service")

class QueryRequest(BaseModel):
    question: str
    top_k: Optional[int] = 4

class SourceInfo(BaseModel):
    source: str
    page: int
    content_preview: str

class QueryResponse(BaseModel):
    answer: str
    sources: List[SourceInfo]

@app.on_event("startup")
async def startup():
    """启动时加载模型和向量库"""
    global qa_chain
    qa_chain = init_qa_chain()  # 复用前文的初始化函数

@app.post("/ask", response_model=QueryResponse)
async def ask_question(request: QueryRequest):
    if not request.question.strip():
        raise HTTPException(status_code=400, detail="问题不能为空")

    result = qa_chain.invoke({"query": request.question})
    sources = []
    for doc in result['source_documents']:
        sources.append(SourceInfo(
            source=doc.metadata.get('source', '未知'),
            page=doc.metadata.get('page', 0),
            content_preview=doc.page_content[:100]
        ))
    return QueryResponse(answer=result['result'], sources=sources)

if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8000)

API 跑起来后,其他系统可以这样调用:

curl -X POST http://localhost:8000/ask \
  -H "Content-Type: application/json" \
  -d '{"question": "如何安装和配置系统?", "top_k": 3}'

返回结果包含回答和引用来源,可以在前端展示带引用的答案。

适合:需要将 RAG 能力嵌入到现有系统、需要多人多系统调用的场景。

不太适合:只需要一次性的问答需求(方案一更直接)。

五、选型建议

维度Python 脚本方案Dify 社区版FastAPI 自建服务
代码量中等(约 100 行)零代码中等(约 200 行)
部署复杂度Python 环境即可需要 DockerPython + ASGI 服务器
Web 界面Gradio 可选自带完整 UI需自建前端
定制灵活度高(可改任意环节)中(受限于平台功能)高(全栈可控)
团队协作单机支持多用户需自建用户体系
文档更新需手动重建向量库支持增量更新需自实现更新逻辑
embedding 模型本地 BGE本地或云端本地 BGE

怎么选

  • 个人的技术文档需要做问答——Python 脚本方案,100 行代码搞定,想怎么调就怎么调
  • 团队内部要共享知识库,且不想写前端——Dify 社区版,开箱即用
  • 要把 RAG 能力嵌入已有的产品系统——FastAPI API 方案,封装成微服务供其他模块调用

六、几个实践中的坑

  1. chunk_size 不是越大越好:我一开始设了 2000,结果一个问题返回的内容填满了上下文,模型反而抓不住重点。
  2. 中文用 bge 系列 embedding:试过用英文 embedding 模型处理中文文档,检索准确率明显下降。
  3. source 信息要保留:刚开始没保存文档来源信息,模型回答的内容缺少可追溯性,不敢直接用。加了 source_documents 后效果好很多。
  4. 首次加载慢是正常的:bge 模型下载 + 文档向量化初次运行要几分钟,后续加载 Chroma 的持久化数据就快多了。

七、总结

RAG 是目前落地大模型应用最务实的方案——它不需要微调模型,不需要昂贵的 GPU 训练,只需要把文档处理好、向量库搭起来、检索策略调优,就能让模型基于你的数据回答问题。从 Python 脚本到可视化平台再到 API 服务,不同的复杂度对应不同的场景,自己评估一下需求和资源,选性价比最高的方式开始。搭完第一个 RAG 系统之后,你会发现"让 AI 理解我的文档"这件事没有想象的那么复杂。


本文涉及的组件(Ollama、LangChain、ChromaDB、Dify、Gradio)均为开源项目,可在各自官方仓库查阅最新文档。

更多推荐