用 RAG 搭建企业知识库:从选型到上线的完整方案

3 阅读8分钟

为什么选 RAG,而不是直接微调?

很多团队一开始想到"让模型学会我们的内部知识",第一反应是微调(Fine-tuning)。但微调有几个硬伤:

  • 数据更新了就要重新训练,成本高、周期长
  • 模型参数量有限,塞不进几千份文档
  • 无法溯源,模型说错了也不知道是哪份文档的问题

RAG(Retrieval-Augmented Generation)的思路完全不同:先检索,再生成。模型不需要"记住"所有知识,只需要在生成回答时拿到相关文档片段作为上下文。文档更新了?重新向量化就好,模型不用动。

整体架构

RAG 流水线分两个阶段:

离线阶段(文档入库)

原始文档 → 文档解析 → 文本切分 → Embedding 向量化 → 写入向量数据库

在线阶段(问答查询)

用户提问 → Embedding 向量化 → 向量检索 → 取 Top-K 文档片段 → 拼入 Prompt → LLM 生成回答

架构简单,但每个环节都有坑。下面逐一拆解。


技术选型

Embedding 模型

Embedding 模型把文本转成向量,直接影响检索质量。国内有两个主流选择:

方案模型维度特点
API 方案通义千问 text-embedding-v31024 / 2048 维无需部署,按 token 计费
开源自部署BAAI/bge-large-zh-v1.51024 维一次性成本,中文效果优秀

如果是初期验证或数据量不大,推荐直接用通义千问的 Embedding API,省去部署运维的麻烦。数据量上去后(千万级向量),再考虑迁移到自部署方案。

顺带一提,如果你的 RAG 系统同时调用多家厂商的 Embedding 和 Chat 模型,可以考虑用笔者开发的 TheRouter 来统一管理——一个 API Key 即可同时调用 embedding 和 chat 模型,切换供应商时也不需要改业务代码。

向量数据库

数据库适用场景特点
ChromaDB本地开发、小规模(百万级以内)纯 Python,零配置,内嵌运行
Qdrant生产环境,中等规模Rust 实现,性能好,有 REST API
Milvus大规模(亿级向量)分布式,功能最全,运维成本高

本文用 ChromaDB 演示(可以直接跑,不用装任何外部服务),生产建议换 Qdrant。

生成模型

知识库问答对模型的要求是:能够严格基于给定上下文回答,而不是自由发挥。推荐:

  • DeepSeek-Chat:推理能力强,指令遵循好,价格实惠
  • Qwen-Max:通义系,和 Embedding 同一套 API,账单管理方便

完整代码实现

先安装依赖:

pip install langchain langchain-text-splitters chromadb openai python-dotenv fastapi uvicorn pypdf

第一步:文档加载与切分

文档切分是 RAG 质量的关键。切太小,单个片段语义不完整;切太大,噪声多、超出上下文窗口。

# doc_loader.py
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import PyPDFLoader, DirectoryLoader
import os

def load_documents(doc_dir: str) -> list:
    """加载目录下所有 PDF 文档"""
    loader = DirectoryLoader(
        doc_dir,
        glob="**/*.pdf",
        loader_cls=PyPDFLoader,
        show_progress=True
    )
    documents = loader.load()
    print(f"加载了 {len(documents)} 个文档页面")
    return documents

def split_documents(documents: list, chunk_size: int = 512, chunk_overlap: int = 64) -> list:
    """
    文本切分
    chunk_size: 每个片段的字符数,建议 256-1024
    chunk_overlap: 相邻片段重叠字符数,建议 chunk_size 的 10-15%
    """
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=chunk_size,
        chunk_overlap=chunk_overlap,
        separators=["\n\n", "\n", "。", "!", "?", ";", " ", ""],
        length_function=len,
    )
    chunks = splitter.split_documents(documents)
    print(f"切分为 {len(chunks)} 个文本块")
    return chunks

separators 参数很重要:中文文档优先按段落(\n\n)切,其次按句号切,尽量保证每个 chunk 语义完整。

第二步:调用 Embedding API 向量化

# embedder.py
from openai import OpenAI
import os

# 通义千问 Embedding API(兼容 OpenAI SDK 格式)
client = OpenAI(
    api_key=os.environ["DASHSCOPE_API_KEY"],
    base_url="https://dashscope.aliyuncs.com/compatible-mode/v1"
)

def get_embeddings(texts: list[str], model: str = "text-embedding-v3") -> list[list[float]]:
    """
    批量获取文本的 Embedding 向量
    每次最多 25 条(API 限制),自动分批处理
    """
    all_embeddings = []
    batch_size = 25

    for i in range(0, len(texts), batch_size):
        batch = texts[i:i + batch_size]
        response = client.embeddings.create(
            model=model,
            input=batch,
            dimensions=1024  # text-embedding-v3 支持指定维度
        )
        batch_embeddings = [item.embedding for item in response.data]
        all_embeddings.extend(batch_embeddings)
        print(f"已处理 {min(i + batch_size, len(texts))}/{len(texts)} 条")

    return all_embeddings

第三步:存入 ChromaDB

# vector_store.py
import chromadb
from chromadb.config import Settings
import hashlib

def init_collection(persist_dir: str = "./chroma_db", collection_name: str = "knowledge_base"):
    """初始化 ChromaDB,数据持久化到本地目录"""
    client = chromadb.PersistentClient(
        path=persist_dir,
        settings=Settings(anonymized_telemetry=False)
    )
    collection = client.get_or_create_collection(
        name=collection_name,
        metadata={"hnsw:space": "cosine"}  # 余弦相似度
    )
    return collection

def ingest_documents(chunks: list, embeddings: list[list[float]], collection):
    """将文档块和向量写入向量数据库"""
    texts = [chunk.page_content for chunk in chunks]
    metadatas = [
        {
            "source": chunk.metadata.get("source", "unknown"),
            "page": chunk.metadata.get("page", 0),
        }
        for chunk in chunks
    ]
    # 用内容哈希作为 ID,避免重复入库
    ids = [hashlib.md5(text.encode()).hexdigest() for text in texts]

    # ChromaDB 批量写入(每批最多 5000 条)
    batch_size = 500
    for i in range(0, len(texts), batch_size):
        collection.upsert(
            ids=ids[i:i + batch_size],
            documents=texts[i:i + batch_size],
            embeddings=embeddings[i:i + batch_size],
            metadatas=metadatas[i:i + batch_size],
        )
    print(f"成功写入 {len(texts)} 条向量")

注意用 upsert 而不是 add,这样重复运行脚本不会报错(文档更新时只需重跑入库流程)。

第四步:检索 + LLM 生成

# rag_pipeline.py
from openai import OpenAI
from embedder import get_embeddings
import os

llm_client = OpenAI(
    api_key=os.environ["DEEPSEEK_API_KEY"],
    base_url="https://api.deepseek.com/v1"
)

def retrieve(query: str, collection, top_k: int = 5) -> list[dict]:
    """向量检索,返回最相关的 top_k 个文档片段"""
    query_embedding = get_embeddings([query])[0]
    results = collection.query(
        query_embeddings=[query_embedding],
        n_results=top_k,
        include=["documents", "metadatas", "distances"]
    )
    chunks = []
    for doc, meta, dist in zip(
        results["documents"][0],
        results["metadatas"][0],
        results["distances"][0]
    ):
        chunks.append({
            "content": doc,
            "source": meta.get("source", ""),
            "page": meta.get("page", 0),
            "score": 1 - dist  # 余弦距离转相似度
        })
    return chunks

def generate_answer(query: str, context_chunks: list[dict]) -> dict:
    """基于检索到的上下文生成回答"""
    # 过滤低相似度片段(阈值可调)
    filtered = [c for c in context_chunks if c["score"] > 0.5]
    if not filtered:
        return {"answer": "在知识库中未找到相关信息。", "sources": []}

    context = "\n\n---\n\n".join([c["content"] for c in filtered])
    sources = list({c["source"] for c in filtered})

    prompt = f"""你是企业知识库助手。请严格根据以下参考资料回答用户问题。
如果参考资料中没有相关信息,请明确说明"知识库中暂无此信息",不要编造内容。

参考资料:
{context}

用户问题:{query}

请给出清晰、准确的回答:"""

    response = llm_client.chat.completions.create(
        model="deepseek-chat",
        messages=[{"role": "user", "content": prompt}],
        temperature=0.1,  # 知识库问答用低温度,减少幻觉
        max_tokens=1024,
    )
    return {
        "answer": response.choices[0].message.content,
        "sources": sources
    }

temperature=0.1 是关键——知识库问答需要模型"忠实于原文",不要创意发挥。

第五步:FastAPI 封装成服务

# main.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from vector_store import init_collection
from rag_pipeline import retrieve, generate_answer

app = FastAPI(title="企业知识库 API")
collection = init_collection()

class QueryRequest(BaseModel):
    question: str
    top_k: int = 5

class QueryResponse(BaseModel):
    answer: str
    sources: list[str]
    chunks: list[dict]

@app.post("/query", response_model=QueryResponse)
async def query_knowledge_base(req: QueryRequest):
    if not req.question.strip():
        raise HTTPException(status_code=400, detail="问题不能为空")
    chunks = retrieve(req.question, collection, top_k=req.top_k)
    result = generate_answer(req.question, chunks)
    return QueryResponse(
        answer=result["answer"],
        sources=result["sources"],
        chunks=chunks
    )

@app.get("/health")
async def health():
    return {"status": "ok", "doc_count": collection.count()}

启动服务:

uvicorn main:app --host 0.0.0.0 --port 8000 --reload

优化技巧

Chunk 大小调优

没有万能的 chunk size,要根据文档类型来:

  • 技术文档、规章制度:512-768 字符。段落结构清晰,大块切分语义完整
  • FAQ 问答对:128-256 字符。每个问答本身就是独立语义单元
  • 长篇报告:768-1024 字符。需要更多上下文才能理解

实践上,先用 512 跑基线,再用一组标注好的查询-答案对做 Recall@5 评估,调整到最优值。

Retrieval Top-K 设置

top_k 越大,召回率越高,但引入噪声也越多,Prompt 越长(成本越高)。建议:

  • 初始设 top_k=5,相似度阈值 0.5
  • 如果出现"答非所问",降低阈值或减少 top_k
  • 如果出现"回答不完整",提高 top_k 或降低阈值

Reranking(重排序)

向量检索的缺点是语义粗粒度,可以在检索后加一个 Reranker 做精排:

# 使用 bge-reranker 对检索结果重排序(开源模型,本地运行)
from FlagEmbedding import FlagReranker

reranker = FlagReranker("BAAI/bge-reranker-large", use_fp16=True)

def rerank(query: str, chunks: list[dict], top_n: int = 3) -> list[dict]:
    pairs = [[query, c["content"]] for c in chunks]
    scores = reranker.compute_score(pairs)
    for chunk, score in zip(chunks, scores):
        chunk["rerank_score"] = score
    return sorted(chunks, key=lambda x: x["rerank_score"], reverse=True)[:top_n]

实测加入 Reranker 后,回答准确率能提升 10-20%。


成本估算

以一个中等规模企业知识库为例(1000 份 PDF,约 50 万字):

入库阶段(一次性)

  • Embedding:50 万 tokens × ¥0.0007/千 token ≈ ¥0.35
  • 基本可以忽略不计

查询阶段(每次查询)

  • Embedding(问题向量化):约 50 tokens,< ¥0.0001
  • LLM 生成(含 5 个 chunk 上下文):约 2000 tokens,约 ¥0.004(DeepSeek)

按日均 500 次查询估算,每月 LLM 成本约 ¥60。相比人工查阅文档节省的时间,这个成本完全值得。


小结

RAG 知识库的核心链路并不复杂,难点在于:

  1. 文档解析质量:PDF 表格、图片内容往往提取不好,需要针对性处理
  2. Chunk 粒度:直接影响检索质量,需要反复调试
  3. Prompt 工程:要明确告诉模型"只基于给定资料回答",否则模型会"发挥"

代码已覆盖完整链路,可以直接拿去跑。文档目录准备好,DASHSCOPE_API_KEY 配上,几分钟就能跑起来一个可用的知识库原型。


欢迎在评论区交流 RAG 落地中遇到的问题,特别是中文文档处理的坑。

作者:TheRouter 开发者,专注 AI 模型路由网关。项目主页:therouter.ai