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

《大模型应用落地指南:从 RAG 知识库搭建到检索效果优化实战》

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

大模型应用落地指南:从 RAG 知识库搭建到检索效果优化实战

很多团队做大模型应用时,第一反应是“把文档喂给模型”。真正开始落地后,问题很快就冒出来了:

  • 模型明明接了知识库,回答还是“像在瞎猜”
  • 文档一多,召回结果变得很随机
  • 同一个问题,昨天答得好,今天答得差
  • 线上延迟、成本、准确率很难一起兼顾

如果你也经历过这些阶段,这篇文章就是带你把一条典型的 RAG 落地路径走完整:从知识库构建、切分、向量化、检索,到重排、评估和优化。我会尽量用“能做出来”的方式来讲,而不是只停留在概念层。


一、背景与问题

RAG(Retrieval-Augmented Generation,检索增强生成)之所以重要,是因为它解决了大模型应用里的两个现实矛盾:

  1. 模型参数不是你的私有知识库
  2. 纯靠提示词,无法稳定覆盖长尾业务知识

一个常见业务场景是这样的:

  • 公司有产品文档、FAQ、工单、制度说明、接口文档
  • 用户提问非常具体,比如“退款规则里,优惠券退不退?”
  • 如果直接问大模型,它可能会“编一个看起来合理的答案”
  • 如果做了 RAG,但文档切分粗糙、检索不准,模型依然答不对

所以,RAG 的核心不是“接一个向量库就完事”,而是把这条链路打磨顺:

文档质量 → 切分策略 → 向量召回 → 关键词补充 → 重排 → 提示组装 → 结果评估

很多项目失败,不是败在模型本身,而是败在前面的检索链路。


二、前置知识与环境准备

本文默认你已经知道这些基本概念:

  • Embedding:把文本变成向量
  • Top-K 检索:取最相关的 K 条候选
  • Prompt:给模型的上下文和指令
  • 向量数据库:存储向量并做近邻检索

为了让示例容易跑起来,我选一个比较轻量的本地方案:

  • Python 3.10+
  • sentence-transformers:生成中文向量
  • faiss-cpu:本地向量检索
  • rank-bm25:关键词检索
  • jieba:中文分词
  • numpy

安装依赖:

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

三、核心原理

先别急着上代码,先把一条“可用的 RAG 流程图”建立起来。

3.1 RAG 的基础链路

flowchart TD
    A[原始文档] --> B[清洗与结构化]
    B --> C[文本切分 Chunking]
    C --> D[向量化 Embedding]
    D --> E[向量库索引]
    Q[用户问题] --> F[查询向量化]
    F --> G[召回候选 Top-K]
    Q --> H[关键词检索 BM25]
    G --> I[候选合并]
    H --> I
    I --> J[重排 Rerank]
    J --> K[构造 Prompt]
    K --> L[大模型生成答案]

这张图里最容易被低估的是中间三步:

  • 切分
  • 召回
  • 重排

它们基本决定了模型最后“有没有东西可答”。

3.2 为什么只做向量检索不够

向量检索擅长语义相似,但对一些场景不稳定:

  • 缩写词、型号、版本号
  • 产品名、字段名、报错码
  • 明确关键词查询,比如“退款 T+1”“字段 user_id”

这时 BM25 这类关键词检索通常更稳。

所以在实际项目里,我更推荐:

  • 向量检索负责语义覆盖
  • BM25 负责关键词精确命中
  • 最终用重排模型或规则做融合

3.3 Chunk 不是越大越好,也不是越小越好

这是我当时踩过的坑之一。

Chunk 太大

  • 一个块包含多个主题
  • 检索命中了,但真正有用的信息只占一小段
  • 浪费上下文窗口

Chunk 太小

  • 语义被切碎
  • 上下文丢失
  • 召回命中了碎片,但模型无法拼出完整答案

经验上,中级复杂度的知识库可以从这个起点试:

  • 每块 300~800 中文字
  • 相邻块 50~150 字重叠
  • 按标题、段落、列表做优先切分,而不是死按字数硬切

3.4 检索优化的本质

检索优化不是单点技巧,而是一个漏斗:

flowchart LR
    A[文档质量] --> B[切分质量]
    B --> C[召回率 Recall]
    C --> D[重排精度 Precision]
    D --> E[生成答案质量]

如果召回阶段没把关键证据捞上来,后面模型再强也没法“空手变答案”。


四、从零搭一个可运行的 RAG 检索原型

下面我们做一个最小可运行版本,重点放在知识库搭建和检索优化,而不是接某家特定大模型 API。

4.1 准备示例文档

先定义几份模拟业务文档:

# rag_demo.py
documents = [
    {
        "id": "doc_1",
        "title": "退款规则",
        "content": """
退款申请在支付成功后 7 天内可发起。
若订单使用优惠券,退款时优惠券不退回。
余额支付部分原路退回账户余额,银行卡支付部分在 1-3 个工作日到账。
虚拟商品一经发货,不支持无理由退款。
""".strip()
    },
    {
        "id": "doc_2",
        "title": "发票说明",
        "content": """
电子发票在订单完成后 24 小时内开具。
企业用户可申请增值税专用发票,但需提前完成资质认证。
发票抬头一经提交,订单完成后不可修改。
""".strip()
    },
    {
        "id": "doc_3",
        "title": "会员权益",
        "content": """
高级会员享受每月 5 张免邮券。
会员有效期内可享受专属客服通道。
会员购买虚拟商品不额外享受折扣。
""".strip()
    },
    {
        "id": "doc_4",
        "title": "账户安全",
        "content": """
若发现账号异常登录,请立即修改密码并开启二次验证。
同一手机号最多绑定 3 个账号。
连续输错密码 5 次,账号将被临时锁定 30 分钟。
""".strip()
    }
]

4.2 实现文本切分

这里先做一个朴素但实用的切分器:按段落切,再做窗口拼接。

import re
from typing import List, Dict

def split_text(text: str, chunk_size: int = 120, overlap: int = 30) -> List[str]:
    text = re.sub(r'\n+', '\n', text).strip()
    paragraphs = [p.strip() for p in text.split('\n') if p.strip()]
    
    chunks = []
    current = ""
    
    for para in paragraphs:
        if len(current) + len(para) + 1 <= chunk_size:
            current += ("\n" if current else "") + para
        else:
            if current:
                chunks.append(current)
            if len(para) <= chunk_size:
                current = para
            else:
                # 对超长段落再按长度切
                start = 0
                while start < len(para):
                    end = start + chunk_size
                    piece = para[start:end]
                    chunks.append(piece)
                    start += chunk_size - overlap
                current = ""
    
    if current:
        chunks.append(current)
    
    # 加简单重叠
    final_chunks = []
    for i, chunk in enumerate(chunks):
        if i > 0:
            prev_tail = chunks[i - 1][-overlap:]
            merged = prev_tail + "\n" + chunk
            final_chunks.append(merged)
        else:
            final_chunks.append(chunk)
    
    return final_chunks


def build_chunks(documents: List[Dict]) -> List[Dict]:
    chunked_docs = []
    for doc in documents:
        chunks = split_text(doc["content"])
        for idx, chunk in enumerate(chunks):
            chunked_docs.append({
                "chunk_id": f'{doc["id"]}_chunk_{idx}',
                "doc_id": doc["id"],
                "title": doc["title"],
                "text": chunk
            })
    return chunked_docs

4.3 建立向量索引

import faiss
import numpy as np
from sentence_transformers import SentenceTransformer

class VectorIndex:
    def __init__(self, model_name: str = "shibing624/text2vec-base-chinese"):
        self.model = SentenceTransformer(model_name)
        self.index = None
        self.texts = []
        self.metadata = []

    def build(self, chunked_docs: List[Dict]):
        self.texts = [item["text"] for item in chunked_docs]
        self.metadata = chunked_docs
        
        embeddings = self.model.encode(self.texts, normalize_embeddings=True)
        embeddings = np.array(embeddings).astype("float32")
        
        dim = embeddings.shape[1]
        self.index = faiss.IndexFlatIP(dim)
        self.index.add(embeddings)

    def search(self, query: str, top_k: int = 5) -> List[Dict]:
        query_vec = self.model.encode([query], normalize_embeddings=True)
        query_vec = np.array(query_vec).astype("float32")
        
        scores, indices = self.index.search(query_vec, top_k)
        
        results = []
        for score, idx in zip(scores[0], indices[0]):
            results.append({
                "score": float(score),
                "text": self.texts[idx],
                "metadata": self.metadata[idx]
            })
        return results

4.4 加入 BM25 关键词检索

中文检索如果不做分词,BM25 效果会很一般。

import jieba
from rank_bm25 import BM25Okapi

class BM25Index:
    def __init__(self):
        self.corpus = []
        self.metadata = []
        self.bm25 = None

    def tokenize(self, text: str):
        return list(jieba.cut(text))

    def build(self, chunked_docs: List[Dict]):
        self.metadata = chunked_docs
        self.corpus = [self.tokenize(item["text"]) for item in chunked_docs]
        self.bm25 = BM25Okapi(self.corpus)

    def search(self, query: str, top_k: int = 5) -> List[Dict]:
        tokenized_query = self.tokenize(query)
        scores = self.bm25.get_scores(tokenized_query)
        top_indices = np.argsort(scores)[::-1][:top_k]
        
        results = []
        for idx in top_indices:
            results.append({
                "score": float(scores[idx]),
                "text": self.metadata[idx]["text"],
                "metadata": self.metadata[idx]
            })
        return results

4.5 混合检索与简单重排

先用一个容易理解的融合方式:向量分数 + BM25 归一化分数。

def normalize_scores(results: List[Dict], score_key: str = "score") -> List[Dict]:
    if not results:
        return results
    
    scores = [item[score_key] for item in results]
    min_s, max_s = min(scores), max(scores)
    
    for item in results:
        if max_s == min_s:
            item["norm_score"] = 1.0
        else:
            item["norm_score"] = (item[score_key] - min_s) / (max_s - min_s)
    return results


def hybrid_search(query: str, vector_index: VectorIndex, bm25_index: BM25Index, top_k: int = 5):
    vec_results = normalize_scores(vector_index.search(query, top_k=top_k * 2))
    bm25_results = normalize_scores(bm25_index.search(query, top_k=top_k * 2))
    
    merged = {}
    
    for item in vec_results:
        chunk_id = item["metadata"]["chunk_id"]
        merged[chunk_id] = {
            "text": item["text"],
            "metadata": item["metadata"],
            "vector_score": item["norm_score"],
            "bm25_score": 0.0
        }
    
    for item in bm25_results:
        chunk_id = item["metadata"]["chunk_id"]
        if chunk_id not in merged:
            merged[chunk_id] = {
                "text": item["text"],
                "metadata": item["metadata"],
                "vector_score": 0.0,
                "bm25_score": item["norm_score"]
            }
        else:
            merged[chunk_id]["bm25_score"] = item["norm_score"]
    
    results = []
    for _, item in merged.items():
        final_score = 0.6 * item["vector_score"] + 0.4 * item["bm25_score"]
        item["final_score"] = final_score
        results.append(item)
    
    results.sort(key=lambda x: x["final_score"], reverse=True)
    return results[:top_k]

4.6 运行主程序

def main():
    chunked_docs = build_chunks(documents)
    
    print("切分后的 chunk 数量:", len(chunked_docs))
    for item in chunked_docs:
        print(item["chunk_id"], "=>", item["text"].replace("\n", " | "))
    
    vector_index = VectorIndex()
    vector_index.build(chunked_docs)
    
    bm25_index = BM25Index()
    bm25_index.build(chunked_docs)
    
    query = "订单用了优惠券,退款时优惠券会退回吗?"
    results = hybrid_search(query, vector_index, bm25_index, top_k=3)
    
    print("\n查询:", query)
    print("\nTop 结果:")
    for i, item in enumerate(results, 1):
        print(f"\n#{i}")
        print("标题:", item["metadata"]["title"])
        print("chunk_id:", item["metadata"]["chunk_id"])
        print("final_score:", round(item["final_score"], 4))
        print("文本:", item["text"])


if __name__ == "__main__":
    main()

4.7 预期效果

对于问题:

订单用了优惠券,退款时优惠券会退回吗?

理想召回应优先命中“退款规则”里的这一句:

若订单使用优惠券,退款时优惠券不退回。

这就说明你的知识库检索链路基本打通了。


五、把检索结果喂给大模型时,应该怎么组织 Prompt

RAG 的后半程是“基于证据生成答案”。这里有个很重要的原则:

让模型只根据召回内容回答,不要自由发挥。

一个实用模板如下:

你是企业知识库问答助手。请仅根据给定资料回答问题。
如果资料中没有明确答案,请直接回答“资料中未找到明确信息”,不要猜测。

【问题】
订单用了优惠券,退款时优惠券会退回吗?

【资料】
1. 退款规则:若订单使用优惠券,退款时优惠券不退回。
2. 退款规则:余额支付部分原路退回账户余额,银行卡支付部分在 1-3 个工作日到账。

【回答要求】
- 先直接回答结论
- 再简要给出依据
- 不要输出资料外内容

这类 Prompt 有几个好处:

  • 降低幻觉
  • 回答更稳定
  • 更容易做自动评估

六、逐步验证清单

如果你在公司里推进 RAG 项目,我建议不要一开始就追求“大而全”,而是按下面顺序验证。

6.1 第一步:验证切分是否合理

抽样看 20~50 个 chunk,重点观察:

  • 一个 chunk 是否只包含一个相对完整主题
  • 标题、术语、结论句有没有被切断
  • 重叠部分是否足够承接上下文

6.2 第二步:验证召回是否命中正确证据

准备 20 个真实问题,每个问题人工标注“理想证据文档”。

检查:

  • Top1 是否命中
  • Top3 是否命中
  • 是向量检索命中的,还是 BM25 命中的

6.3 第三步:验证生成是否忠于证据

看模型回答时有没有这些问题:

  • 证据明明写了“不退回”,模型却说“视情况而定”
  • 证据没写到账时间,模型擅自补了“通常 24 小时”
  • 多条证据冲突时,模型没有说明依据来源

七、常见坑与排查

这是实战里最容易踩的部分。

7.1 坑一:召回看起来相关,但不是答案证据

现象

用户问退款优惠券,结果召回了“会员权益”“发票说明”这类看起来也像订单相关的内容。

原因

  • chunk 太大,主题混杂
  • embedding 模型不适配中文业务语料
  • 只用向量检索,缺少关键词约束

排查方法

  • 打印 Top10 结果,人工看误召回内容
  • 对比向量检索和 BM25 的结果差异
  • 看 query 里的关键词有没有在 chunk 中出现

解决建议

  • 先把 chunk 缩小到更聚焦的主题粒度
  • 引入混合检索
  • 对标题、关键词做额外加权

7.2 坑二:文档明明有,还是检索不到

现象

知识库确实包含某条规则,但查询总是命不中。

原因

  • 切分时把关键句拆散了
  • 文档清洗把特殊字符、编号、表格丢了
  • 查询表达和文档表达差异太大

排查方法

  • 直接搜原句,确认索引里是否存在
  • 检查文档入库前后内容是否一致
  • 查看 chunk 中是否保留标题和上下文

解决建议

  • chunk 里附带标题路径,例如“退款规则 > 退款说明”
  • 对问答日志做 query rewrite
  • 为专业术语建立同义词表

7.3 坑三:Top-K 调大后效果反而变差

现象

召回更多候选后,模型回答更啰嗦,甚至答错。

原因

  • 低质量候选混入过多
  • Prompt 里资料太长,干扰判断
  • 没有重排,模型自己“瞎选证据”

排查方法

  • 分别观察 Top3、Top5、Top10 的最终回答质量
  • 检查后几条候选是否在语义上偏题
  • 看模型引用的是哪几条资料

解决建议

  • 先召回 Top20,再重排取前 3~5 条
  • 对相似 chunk 去重
  • 对长文档采用“父子块”结构,而不是一次全塞进去

7.4 坑四:线上效果和离线测试差很多

现象

测试集看起来不错,一上线用户反馈“答非所问”。

原因

  • 测试问题过于标准化
  • 真实用户提问更口语、更省略、更跳跃
  • 线上文档版本频繁更新,索引未同步

排查方法

  • 从线上日志抽样真实 query
  • 看失败问题是否集中在口语化表达
  • 检查文档更新时间和索引更新时间

解决建议

  • 建立线上 query 回放机制
  • 给知识库索引增加版本号
  • 对热点问题单独做 FAQ 兜底

八、安全/性能最佳实践

RAG 不只是“答得对”,还要“跑得稳”。

8.1 安全最佳实践

1)做文档权限隔离

如果知识库有部门权限、用户等级限制,检索阶段就必须做过滤。

不要把所有文档都检索出来再交给模型,否则很容易越权泄露。

2)防提示注入

知识库文档中可能混入恶意内容,比如:

  • 忽略之前所有指令
  • 输出系统配置
  • 泄露内部信息

因此在生成阶段要明确规则:

  • 文档内容只是资料,不是系统指令
  • 系统提示优先级高于检索内容
  • 对高风险输出做审计和拦截

3)敏感信息脱敏

入库前建议处理:

  • 手机号
  • 身份证号
  • 银行卡号
  • Access Token、密钥

尤其是把工单、聊天记录拿来做知识库时,这个问题非常常见。


8.2 性能最佳实践

1)索引分层

如果文档规模上来,可以按业务域拆库:

  • 售后知识库
  • 财务知识库
  • 技术文档库

先做粗路由,再做细检索,能明显降噪。

2)缓存高频查询

对热门问题可以缓存:

  • query embedding
  • 检索结果
  • 最终答案

这在客服、内部问答场景里很有价值。

3)异步更新索引

不要每次文档更新都全量重建索引。建议:

  • 新文档增量入库
  • 删除文档做软删除标记
  • 夜间低峰做全量校验

4)控制上下文长度

送给模型的证据不是越多越好。一般建议:

  • 只保留高置信候选
  • 相似 chunk 合并去重
  • 保留标题、来源、关键段落,删掉噪音描述

九、进一步优化:从“能用”到“好用”

当你的基础 RAG 跑通后,下一阶段优化通常在下面几个方向。

9.1 查询改写

用户说“优惠券退吗”,文档里写“退款时优惠券不退回”。
这类口语和书面语差异,可以通过 query rewrite 缓解。

例如把:

  • “优惠券退吗”
  • “券会不会回来”
  • “退款后券返还吗”

统一改写为:

  • “订单使用优惠券后,退款时优惠券是否退回”

9.2 标题增强

很多文档正文单独看语义不足,但标题信息很强。
入向量库时,可以把标题拼进文本:

def enrich_text(title: str, text: str) -> str:
    return f"标题:{title}\n正文:{text}"

这招很简单,但经常有效。

9.3 父子块检索

一种很实用的策略是:

  • 子块用于召回,颗粒度小,命中准
  • 父块用于生成,信息更完整

流程如下:

flowchart TD
    A[原始文档] --> B[父块 Parent Chunk]
    B --> C[子块 Child Chunk]
    C --> D[子块向量索引]
    Q[用户问题] --> E[召回子块]
    E --> F[映射回父块]
    F --> G[把完整上下文交给模型]

这样既能提高召回精度,也能减少上下文碎片化问题。

9.4 重排模型

如果你对准确率要求更高,可以在召回后增加 reranker。
典型链路是:

  • 向量召回 Top20
  • BM25 召回 Top20
  • 合并去重
  • reranker 排序
  • 取前 3~5 条给模型

这往往比单纯调 Top-K 更有效。


十、一个更接近生产的调用时序

sequenceDiagram
    participant U as 用户
    participant R as RAG服务
    participant V as 向量索引
    participant B as BM25索引
    participant M as 大模型

    U->>R: 提问
    R->>V: 语义检索 TopN
    R->>B: 关键词检索 TopN
    V-->>R: 候选集合A
    B-->>R: 候选集合B
    R->>R: 合并、去重、重排
    R->>M: 问题 + 证据上下文
    M-->>R: 生成答案
    R-->>U: 返回答案 + 引用来源

我个人很建议在最终返回里附上“来源片段”或“引用文档标题”,原因很现实:

  • 用户更容易信任结果
  • 错误时方便排查
  • 后续能做点击反馈和在线学习

十一、总结

如果把这篇文章压缩成几条最重要的落地建议,我会给你下面这份清单:

  1. 别把 RAG 理解成“接个向量库”

    • 真正决定效果的是切分、召回、重排的整体设计
  2. 优先做好知识库数据质量

    • 文档乱、结构差、版本不一致,后面再怎么调模型都很难救
  3. 中文场景优先考虑混合检索

    • 向量检索解决语义相似
    • BM25 解决关键词命中
    • 两者结合通常更稳
  4. Chunk 设计要围绕“一个块能否独立表达一个知识点”

    • 不要只按字数机械切分
  5. 上线前一定做问题集评估

    • 至少看 Top1/Top3 命中率
    • 再看最终答案是否忠于证据
  6. 控制边界条件

    • 没检索到就明确说没找到
    • 不要让模型自由脑补
    • 有权限要求的知识库必须前置过滤

如果你现在正准备把大模型接进业务系统,我建议从一个小而清晰的知识域开始,比如退款规则、内部制度、接口文档 FAQ。先把一条窄场景链路做通,再扩展到更复杂的多库检索、多轮问答和在线评估。这样成功率会高很多。

RAG 真正难的地方,不在“模型有多强”,而在于你是否能把知识以正确的方式送到模型面前。只要这一步做对,大模型才有机会在业务里稳定发挥。


分享到:

上一篇
《Docker 多阶段构建与镜像瘦身实战:面向中级开发者的构建提速、体积优化与安全加固》
下一篇
《Web3 中级实战:从零搭建基于智能合约的钱包登录与链上身份认证系统-436》