跳转到内容
123xiao | 无名键客

《大模型应用中的 RAG 架构实战:从知识库构建到检索增强问答优化》

字数: 0 阅读时长: 1 分钟

大模型应用中的 RAG 架构实战:从知识库构建到检索增强问答优化

RAG(Retrieval-Augmented Generation,检索增强生成)已经从“看起来很好用”的概念,变成很多企业大模型应用的默认基础设施。只要你的场景涉及内部知识、时效性信息、专业文档、多来源数据,几乎都会绕不开它。

但真正做起来,RAG 往往不是“向量库 + Embedding + LLM”三件套这么简单。很多团队第一版上线后都会遇到这些问题:

  • 明明知识已经入库,模型还是答非所问
  • 检索结果看起来相关,但生成答案不稳定
  • 文档一多,召回质量明显下降
  • 响应时间越来越长,成本越来越高
  • 安全边界模糊,敏感内容可能被错误召回

我自己在做企业知识问答和文档助手时,最大的感受是:RAG 的上限不只取决于模型,而取决于整条链路的工程质量。
这篇文章不讲“RAG 是什么”的入门定义,而是从架构实战角度,把一条可落地的路径串起来:知识库构建、检索链路设计、问答增强、质量优化、性能与安全治理。


背景与问题

为什么纯大模型回答不够用

大模型本身有很强的语言理解和生成能力,但在企业应用里,经常会遇到几个天然限制:

  1. 知识过期:模型训练数据不是实时的
  2. 领域知识不足:企业内部文档、规范、流程不在预训练语料中
  3. 幻觉问题:不知道也会“很自信地编”
  4. 不可追溯:答案没有出处,用户难以信任

RAG 的本质,就是让模型在回答前先“查资料”,把资料和问题一起喂给模型,从而让答案更贴近事实、更可控。

实际落地中的核心矛盾

真正做 RAG,核心矛盾通常不是“能不能跑起来”,而是下面这几个平衡:

  • 召回率 vs 精准率
  • 上下文完整性 vs Token 成本
  • 实时性 vs 索引构建成本
  • 系统复杂度 vs 维护性
  • 效果提升 vs 推理延迟

很多团队会在第一阶段直接做一个简单架构:

用户问题 -> 向量检索 -> TopK 文档 -> 拼 Prompt -> 大模型回答

这个方案适合 PoC,但一旦进入生产,就会暴露出两个明显问题:

  • “检得到”不代表“答得好”
  • “答得好一次”不代表“整体稳定”

所以,RAG 架构一定要从“组件堆砌”升级为“链路设计”。


核心原理

从架构视角看,一个完整的 RAG 系统一般包含 5 个层次:

  1. 数据接入层:采集 PDF、Word、网页、数据库、FAQ、工单等
  2. 知识处理层:清洗、切块、去噪、元数据提取、向量化
  3. 检索层:向量检索、关键词检索、混合检索、重排
  4. 生成层:Prompt 编排、上下文压缩、答案生成、引用归因
  5. 运营治理层:评测、监控、缓存、安全、权限、成本控制

RAG 基础流程图

flowchart LR
    A[原始数据源] --> B[文档解析与清洗]
    B --> C[Chunk 切分]
    C --> D[Embedding 向量化]
    D --> E[向量索引/倒排索引]

    Q[用户问题] --> Q1[问题改写/意图识别]
    Q1 --> F[检索召回]
    E --> F
    F --> G[重排与过滤]
    G --> H[Prompt 构建]
    H --> I[LLM 生成答案]
    I --> J[答案+引用来源]

为什么知识库构建比模型选择更关键

很多人刚接触 RAG,会把注意力放在:

  • 用哪个大模型
  • 用哪个向量库
  • Embedding 模型排行榜谁更高

这些都重要,但在中级实践阶段,更应该先把注意力放在知识质量上。因为在绝大多数业务场景里:

检索结果质量 = 文档质量 × 切块策略 × 索引策略 × 查询策略 × 重排策略

如果原始文档结构混乱、切块过碎、元数据缺失,即使换再强的模型,效果也很难稳定。

典型架构分层

classDiagram
    class DataSource {
        PDF
        Word
        HTML
        DB
        FAQ
    }

    class KnowledgePipeline {
        parse()
        clean()
        chunk()
        enrich_metadata()
        embed()
        index()
    }

    class Retriever {
        vector_search()
        bm25_search()
        hybrid_search()
        rerank()
    }

    class Generator {
        build_prompt()
        compress_context()
        answer()
        cite_sources()
    }

    class Governance {
        auth
        eval
        cache
        monitor
        audit
    }

    DataSource --> KnowledgePipeline
    KnowledgePipeline --> Retriever
    Retriever --> Generator
    Generator --> Governance

方案对比与取舍分析

在架构设计时,最常见的不是“有没有标准答案”,而是“当前阶段该选哪种复杂度”。

方案一:基础向量检索 RAG

流程:文本切块 -> 向量化 -> TopK 召回 -> 拼接上下文 -> LLM 生成

优点

  • 实现快
  • 成本低
  • 适合验证价值

缺点

  • 对关键词敏感场景不友好
  • 多义词、缩写、专业术语效果不稳定
  • 容易召回“语义接近但事实不匹配”的片段

适用场景

  • FAQ
  • 内部知识助手 PoC
  • 文档数量中小规模(例如万级 chunk 以内)

方案二:混合检索 + 重排

流程:向量召回 + BM25/关键词召回 -> 合并 -> Cross-Encoder 重排 -> LLM 生成

优点

  • 兼顾语义与精确匹配
  • 对编号、接口名、产品型号、报错码等更友好
  • 通常是生产环境性价比最高的方案

缺点

  • 链路更长
  • 重排增加延迟
  • 调参项明显增多

适用场景

  • 企业知识库
  • 技术支持问答
  • 制度、流程、规范类文档

方案三:多路召回 + 查询改写 + 上下文压缩

流程:问题理解 -> 查询扩展/分解 -> 多路召回 -> 重排 -> 上下文压缩 -> 生成

优点

  • 对复杂问题、长文档、多跳问题效果更好
  • 更容易做高质量引用和证据链

缺点

  • 研发复杂度高
  • 评测与运维成本高
  • 对监控要求更强

适用场景

  • 法务、金融、医疗等高可靠场景
  • 大型多知识源问答平台
  • 对答案可追溯要求高的场景

我的建议

如果你在做第一个可上线版本,建议按下面的顺序演进:

  1. 基础向量检索
  2. 加入 BM25 混合召回
  3. 加入重排模型
  4. 做查询改写和上下文压缩
  5. 引入评测闭环和缓存治理

不要一上来就把架构堆得很满,不然你会发现问题出了之后,根本不知道是哪一层在掉效果。


知识库构建:决定下限,也决定上限

RAG 的知识库构建,不是简单把文档扔进向量库。真正影响效果的,是这几个关键步骤。

1. 文档解析与清洗

常见输入源包括:

  • PDF
  • Word/Excel
  • Confluence、Notion、Wiki
  • 网页帮助中心
  • 数据库 FAQ
  • 工单系统历史记录

这里的难点在于:原始文档通常不干净。

典型问题有:

  • 页眉页脚、目录、版权信息重复出现
  • 表格解析错位
  • OCR 噪声严重
  • 列表结构丢失
  • 标题层级不清晰

建议在解析后统一做这些处理:

  • 去除重复页眉页脚
  • 识别标题层级
  • 合并断行
  • 清理无意义空白和乱码
  • 给文档补充来源、更新时间、部门、权限标签等元数据

2. Chunk 切分策略

切块是 RAG 里最容易被低估的部分。

如果 chunk 太大:

  • 检索不精准
  • 上下文成本高
  • 噪声多

如果 chunk 太小:

  • 语义不完整
  • 标题与正文分离
  • 模型无法理解上下文

实践中常用三种方式:

  1. 固定长度切分:简单,但语义可能断裂
  2. 滑动窗口切分:保留上下文,适合一般场景
  3. 结构化切分:按标题、段落、表格、章节切,效果通常更好

经验值不是绝对,但可以作为起点:

  • 通用文档:300~800 中文字/块
  • 技术文档:200~500 中文字/块
  • 法规制度:按章节或条款结构切分
  • 代码文档:按函数、类、模块说明切分

3. 元数据设计

很多团队一开始只存 textembedding,后面很快会后悔。

建议至少保留这些字段:

  • doc_id
  • chunk_id
  • title
  • source
  • section
  • updated_at
  • permission_tags
  • keywords
  • chunk_text

元数据有三个直接价值:

  • 检索过滤
  • 引用展示
  • 权限控制

4. 索引构建

在生产里,通常不是只建一个向量索引,而是同时维护:

  • 向量索引:负责语义召回
  • 关键词/BM25 索引:负责精确匹配
  • 结构化过滤索引:按部门、时间、标签过滤

这样才能支持混合检索。


检索增强问答链路设计

典型问答时序

sequenceDiagram
    participant U as 用户
    participant Q as Query处理器
    participant R as 检索器
    participant RR as 重排器
    participant L as 大模型
    participant A as 审计/日志

    U->>Q: 输入问题
    Q->>Q: 改写/规范化/补充上下文
    Q->>R: 发起多路检索
    R->>RR: 返回候选文档
    RR->>L: TopN 上下文 + Prompt
    L->>A: 记录引用与结果
    L-->>U: 生成答案

查询预处理为什么必要

用户提问往往不是“适合检索”的形式,比如:

  • “这个怎么搞?”
  • “上周说的报销标准是多少?”
  • “接口 401 是啥原因?”

这种问题直接拿去检索,召回质量通常一般。比较实用的增强手段有:

1. 查询改写

把口语化问题改成更适合召回的形式。

例如:

  • 原问题:这个怎么搞?
  • 改写后:如何配置企业微信 SSO 登录流程?

2. 查询扩展

补充同义词、缩写、专业术语。

例如:

  • “单点登录” -> “SSO”
  • “报销” -> “费用报销、差旅报销、财务报销”

3. 查询分解

复杂问题拆成多个检索子问题。

例如:

  • “合同审批流程和归档要求分别是什么?”

可以拆成:

  • 合同审批流程是什么?
  • 合同归档要求是什么?

为什么需要重排

向量检索的 TopK 往往只是“相关候选”,不是“最适合回答当前问题”的排序。
这时引入重排模型,可以把候选文档和查询成对比较,重新打分。

一个很常见的改善是:

  • 向量检索取 Top20
  • BM25 取 Top20
  • 合并去重后用重排模型选 Top5
  • 仅将 Top5 拼给大模型

这个改动通常比“直接换更贵的大模型”更划算。


实战代码(可运行)

下面给一个可运行的最小 RAG Demo。它使用:

  • sentence-transformers 做向量化
  • faiss-cpu 做向量检索
  • 一个简单的 BM25 实现做关键词召回
  • Python 标准逻辑完成混合检索
  • 用规则式 answer 代替真实 LLM 调用,方便你本地直接跑通

如果你有 OpenAI、通义、DeepSeek 或其他模型接口,也可以很容易把最后一步替换掉。

安装依赖

pip install sentence-transformers faiss-cpu rank-bm25 numpy

示例代码

import re
import numpy as np
import faiss
from rank_bm25 import BM25Okapi
from sentence_transformers import SentenceTransformer


documents = [
    {
        "doc_id": "doc1",
        "title": "员工差旅报销制度",
        "section": "报销标准",
        "text": "员工国内差旅报销标准为:一线城市住宿上限 500 元/晚,其他城市 350 元/晚。交通费按照实际发生并提供票据报销。"
    },
    {
        "doc_id": "doc2",
        "title": "员工差旅报销制度",
        "section": "报销时限",
        "text": "差旅结束后应在 10 个工作日内提交报销申请,逾期需要部门负责人审批。"
    },
    {
        "doc_id": "doc3",
        "title": "SSO 单点登录接入指南",
        "section": "常见错误",
        "text": "接口返回 401 通常表示 token 无效、签名错误,或者回调地址未加入白名单。"
    },
    {
        "doc_id": "doc4",
        "title": "合同管理规范",
        "section": "归档要求",
        "text": "已签署合同需在 3 个工作日内上传电子版,并由法务归档保存,纸质版由行政统一保管。"
    },
    {
        "doc_id": "doc5",
        "title": "合同管理规范",
        "section": "审批流程",
        "text": "合同需经过业务负责人、法务、财务审批,金额超过 50 万还需总经理审批。"
    },
]


def tokenize(text: str):
    text = re.sub(r"[^\w\u4e00-\u9fff]+", " ", text.lower())
    return text.split()


# 1. 构建 BM25
tokenized_corpus = [tokenize(doc["text"] + " " + doc["title"] + " " + doc["section"]) for doc in documents]
bm25 = BM25Okapi(tokenized_corpus)

# 2. 构建向量索引
model = SentenceTransformer("sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
corpus_texts = [f'{doc["title"]} {doc["section"]} {doc["text"]}' for doc in documents]
embeddings = model.encode(corpus_texts, normalize_embeddings=True)
embeddings = np.array(embeddings).astype("float32")

index = faiss.IndexFlatIP(embeddings.shape[1])
index.add(embeddings)


def hybrid_search(query: str, top_k: int = 3, alpha: float = 0.6):
    # BM25 分数
    tokenized_query = tokenize(query)
    bm25_scores = bm25.get_scores(tokenized_query)
    bm25_scores = np.array(bm25_scores, dtype=np.float32)

    # 向量分数
    query_vec = model.encode([query], normalize_embeddings=True)
    query_vec = np.array(query_vec).astype("float32")
    vector_scores, _ = index.search(query_vec, len(documents))
    vector_scores = vector_scores[0]

    # 归一化
    def min_max_norm(arr):
        if arr.max() == arr.min():
            return np.ones_like(arr)
        return (arr - arr.min()) / (arr.max() - arr.min())

    bm25_norm = min_max_norm(bm25_scores)
    vector_norm = min_max_norm(vector_scores)

    # 注意:FAISS 返回的是按索引顺序的 topN 分数,这里为了简单直接对齐原始文档顺序
    # 对于严格生产实现,应保留返回索引并按文档 ID 映射
    combined_scores = alpha * vector_norm + (1 - alpha) * bm25_norm

    ranked_indices = np.argsort(combined_scores)[::-1][:top_k]

    results = []
    for idx in ranked_indices:
        results.append({
            "score": float(combined_scores[idx]),
            "doc": documents[idx]
        })
    return results


def build_prompt(query: str, contexts):
    context_text = "\n\n".join(
        [f'[{i+1}] {item["doc"]["title"]} - {item["doc"]["section"]}\n{item["doc"]["text"]}' for i, item in enumerate(contexts)]
    )
    prompt = f"""你是企业知识库助手。请严格根据给定资料回答问题。
如果资料不足,请明确说“根据现有资料无法确认”。
回答时尽量简洁,并在结尾标注引用编号。

问题:{query}

资料:
{context_text}
"""
    return prompt


def simple_generate_answer(query: str, contexts):
    # 这里用一个非常简化的规则模拟大模型输出,方便本地直接运行
    texts = " ".join([c["doc"]["text"] for c in contexts])

    if "报销" in query and "标准" in query:
        return "国内差旅报销中,一线城市住宿上限为 500 元/晚,其他城市为 350 元/晚,交通费按实际发生并凭票报销。[1]"
    if "401" in query:
        return "接口返回 401 通常表示 token 无效、签名错误,或回调地址未加入白名单。[1]"
    if "合同" in query and "归档" in query:
        return "已签署合同需在 3 个工作日内上传电子版,由法务归档保存,纸质版由行政统一保管。[1]"
    if "合同" in query and "审批" in query:
        return "合同通常需经过业务负责人、法务、财务审批;金额超过 50 万还需总经理审批。[1]"

    return f"根据检索到的资料,可能相关的信息是:{texts[:120]}..."


if __name__ == "__main__":
    query = "合同审批流程是什么?"
    results = hybrid_search(query, top_k=3)

    print("=== 检索结果 ===")
    for i, item in enumerate(results, 1):
        doc = item["doc"]
        print(f"{i}. score={item['score']:.4f} | {doc['title']} | {doc['section']} | {doc['text']}")

    prompt = build_prompt(query, results)
    print("\n=== Prompt ===")
    print(prompt)

    answer = simple_generate_answer(query, results)
    print("\n=== Answer ===")
    print(answer)

如果接入真实 LLM,代码怎么改

你只需要把 simple_generate_answer 替换成真实模型调用即可。伪代码如下:

def llm_generate(prompt: str):
    # 替换成你的模型 SDK
    # 例如 OpenAI / Azure OpenAI / 通义千问 / DeepSeek / 本地 vLLM
    response = client.chat.completions.create(
        model="your-model-name",
        messages=[
            {"role": "system", "content": "你是企业知识助手,请严格依据资料回答。"},
            {"role": "user", "content": prompt}
        ],
        temperature=0.2
    )
    return response.choices[0].message.content

这段 Demo 的重点是什么

不是为了追求最强效果,而是让你抓住 RAG 的几个关键动作:

  • 文档结构化
  • 多路召回
  • 检索结果排序
  • Prompt 构建
  • 基于资料回答

把这条链路先跑顺,再逐步替换成生产级组件,成功率会高很多。


容量估算与架构演进建议

中级读者在做架构时,通常会问两个现实问题:

  1. 数据量上来后怎么扩?
  2. 延迟和成本怎么控?

一个粗略估算模型

假设你有:

  • 10 万篇文档
  • 每篇平均切成 20 个 chunk
  • 总 chunk 数约 200 万
  • 每个 embedding 维度 768
  • float32 存储

仅向量本体大致占用:

200万 × 768 × 4 bytes ≈ 6.1 GB

再加上:

  • 索引结构
  • 元数据
  • 备份副本
  • BM25 倒排索引

生产环境里,存储和内存占用会远大于“裸向量体积”。

演进建议

阶段一:单机 PoC

  • FAISS 本地索引
  • 文件型知识源
  • 手工触发更新

适合验证有没有业务价值。

阶段二:服务化

  • 向量库服务化
  • 文档处理流水线独立
  • 在线检索服务独立
  • 引入日志、缓存、监控

适合小规模业务上线。

阶段三:平台化

  • 多知识库隔离
  • 权限过滤
  • 实时增量索引
  • 重排服务独立部署
  • 统一评测与观测体系

适合企业级多业务复用。


常见坑与排查

这一节我尽量写得“接地气”一点,因为很多问题不是理论不会,而是上线后真的会被打到。

坑一:检索到了,但答案还是错

现象: 检索结果列表里其实已经有正确片段,但模型生成答案时仍然混淆、遗漏或胡编。

常见原因

  • 上下文拼接过长,重要片段被淹没
  • 候选文档排序不合理
  • Prompt 没有明确要求“仅基于资料回答”
  • 模型温度过高
  • 多个片段之间信息冲突

排查方法

  1. 把最终送给模型的 Prompt 完整打印出来
  2. 看正确证据排在第几位
  3. 看是否混入了多个相似但冲突的 chunk
  4. 把 temperature 降到 0~0.3 测试
  5. 要求模型输出引用编号

坑二:向量检索对编号、术语、报错码不敏感

现象: 搜“401”“A123”“GLM-01”这种词,向量召回很不稳定。

原因: Embedding 更擅长语义相似,不擅长精确字符串匹配。

解决办法

  • 加 BM25
  • 对术语建别名词典
  • 对错误码、接口名、型号增加关键词字段
  • 先做 query classifier,判断是否走关键词优先

这也是为什么我一般不建议生产环境只用纯向量检索。

坑三:切块太碎,模型看不懂

现象: 检索结果每块都很“像”,但答案总不完整。

原因: 标题、条件、结论被切散了。模型拿到的是半句上下文。

解决办法

  • 使用结构化切块
  • 给 chunk 保留父标题
  • 加 overlap
  • 对表格、列表单独处理

坑四:知识更新后,答案还是旧的

现象: 明明文档已经更新,但问答结果还是老内容。

常见原因

  • 向量索引没重建
  • 缓存没失效
  • 文档版本管理混乱
  • 检索时没按 updated_at 过滤最新版本

建议

  • 建立文档版本号
  • 增量更新时支持删除旧 chunk
  • 缓存键加入知识库版本号
  • 检索日志里记录命中的文档版本

坑五:召回结果很好,但延迟太高

现象: 效果不错,但一次问答要 6~10 秒,用户接受不了。

原因一般在三处

  • 检索路数太多
  • 重排太慢
  • 上下文太长导致 LLM 推理慢

优化思路

  • 向量检索和 BM25 并行化
  • 限制候选数,比如 20~50
  • 重排只对候选集做,不要全量
  • 做上下文压缩
  • 热门问题加缓存

安全/性能最佳实践

RAG 一旦接企业数据,安全和性能都不能靠“之后再补”。

安全最佳实践

1. 权限过滤前置,不要后置

最危险的一种设计是:

先召回所有文档 -> 再让模型别回答敏感信息

这不安全。正确做法是:

先按用户权限过滤候选文档 -> 再检索/重排/生成

也就是说,敏感数据根本不该进入模型上下文

2. 做 Prompt Injection 防护

如果你的知识源来自网页、工单、外部文档,里面可能夹带恶意文本,比如:

  • 忽略以上规则
  • 输出系统提示词
  • 泄露内部信息

建议:

  • 对文档内容做注入扫描
  • 在系统提示中明确“文档内容不可覆盖系统规则”
  • 对高风险来源降低信任级别
  • 将“指令类内容”与“知识类内容”分离处理

3. 输出引用与审计日志

对于企业问答,最好至少记录:

  • 用户问题
  • 改写后查询
  • 命中文档 ID
  • 最终 Prompt
  • 模型回答
  • 响应时间
  • 用户反馈

这不只是排查问题需要,也是合规和追责需要。

性能最佳实践

1. 分层缓存

可以缓存三层:

  • 查询改写结果缓存
  • 检索结果缓存
  • 最终答案缓存

但要注意缓存失效与知识版本绑定。

2. 控制上下文长度

很多时候不是“召回越多越好”。
经验上,Top3~Top8 高质量 chunk 往往比 Top20 噪声 chunk 更有效。

3. 对长文档做两阶段处理

对于超长文档,不要一次把大段内容塞给模型。建议:

  1. 先检索出相关章节
  2. 再在章节内二次定位
  3. 最后做摘要或答案生成

4. 异步索引更新

文档处理通常比较重,尤其包含 OCR、解析、向量化时。
建议把这条链路异步化:

  • 上传成功 ≠ 立即可检索
  • 用任务状态管理处理进度
  • 索引切换尽量原子化

一个推荐的生产流转状态图

stateDiagram-v2
    [*] --> Uploaded
    Uploaded --> Parsing
    Parsing --> Cleaning
    Cleaning --> Chunking
    Chunking --> Embedding
    Embedding --> Indexing
    Indexing --> Active
    Active --> Updating
    Updating --> Active
    Active --> Deprecated
    Deprecated --> [*]

实战优化清单

如果你已经有一个能跑的 RAG 系统,想继续提升效果,可以按这个优先级往下做。

第一优先级:先看检索质量

  • TopK 里是否稳定包含正确答案
  • 是否对术语、编号、报错码召回差
  • 是否存在大量重复 chunk

第二优先级:再看上下文组织

  • 是否有冲突片段混入
  • 是否给模型附带标题和来源
  • 是否做了 chunk 去重和压缩

第三优先级:最后看生成策略

  • system prompt 是否收敛
  • 是否要求“资料不足时明确拒答”
  • 是否输出引用
  • 是否控制温度和最大输出长度

这是我比较信奉的一条原则:

RAG 效果差,先别急着怪模型。
80% 的问题通常出在知识处理和检索阶段。


总结

RAG 的实战重点,不在于把“检索”和“生成”拼起来,而在于把整条链路做成一个可观测、可优化、可治理的系统。

如果用一句话概括本文的核心建议,就是:

把 RAG 当成一条检索架构,而不是一个 Prompt 技巧。

落地时,我建议你按下面的节奏推进:

  1. 先把知识清洗和切块做好
  2. 用混合检索替代纯向量检索
  3. 加入重排,让 TopK 更可信
  4. 控制上下文长度,减少噪声
  5. 建立评测、缓存、权限、审计闭环

如果你的目标是做一个能上线、能持续优化的企业知识问答系统,那么最值得投入精力的不是“换一个更大的模型”,而是先把这几个问题问清楚:

  • 文档是不是干净的?
  • chunk 是否保留了语义完整性?
  • 检索是否兼顾语义和关键词?
  • 模型是否只基于证据回答?
  • 系统是否能解释“为什么这么答”?

把这些基础打牢,RAG 才会从 demo 变成真正可用的生产能力。


分享到:

下一篇
《Web逆向实战:中级开发者如何定位并复现前端签名算法实现接口自动化调用》