生成式 AI · Bedrock

RAG 做得好不好,八成取决于检索而不是模型

同一个模型,检索召回质量从 60% 提到 90%,回答准确率能翻倍。我们把功夫花在切分、召回和重排上。

  • 典型准确率调优后 88%-94%
  • 首字延迟1.2-2.5 秒
  • 交付周期4-8 周

RAG 链路架构

接入层

Web / 企微 / 钉钉 API Gateway 身份鉴权 会话管理

编排层

查询改写 多路召回 重排序 Rerank 上下文组装 答案生成

检索层

向量检索 OpenSearch 关键词检索 BM25 元数据过滤 父子块召回

模型层

Bedrock Claude Embedding 模型 Rerank 模型 护栏 Guardrails

数据层

S3 原始文档 解析与切分管道 向量索引 增量同步

运营层

调用审计 成本归属 反馈闭环 评测集回归

混合检索(向量 + 关键词)加上重排序,通常能把召回质量提升 20-30 个百分点,这是性价比最高的一步。

落地六步

  1. 01

    场景收敛与评测集构建

    先明确问答范围,收集 100-300 条真实问题与标准答案。没有评测集就没法判断优化是否有效。

    交付物:场景边界文档、评测集(问题 + 标准答案 + 出处)、验收指标定义

    1-2 周

  2. 02

    文档解析与切分

    PDF、Word、Excel、扫描件分别处理。表格要保留结构,长文档用父子块策略:小块用于检索,大块用于生成。

    交付物:解析管道、切分策略配置、文档质量报告

    1-2 周

  3. 03

    索引构建与混合检索

    向量索引 + BM25 关键词索引并行,用 RRF 融合排序。元数据字段支持按部门、时间、文档类型过滤。

    交付物:索引结构、混合检索接口、召回率基线

    1-2 周

  4. 04

    重排与提示词工程

    召回 Top 50 后用 Rerank 模型精排到 Top 5。提示词强制要求引用出处,无依据时明确说不知道。

    交付物:重排配置、提示词模板、引用格式规范

    1-2 周

  5. 05

    护栏与合规

    配置 Guardrails 拦截敏感话题与 PII 泄露,所有调用留审计日志,按用户权限过滤可见文档。

    交付物:护栏策略、权限过滤逻辑、审计日志方案

    1 周

  6. 06

    评测与迭代

    跑评测集,逐项分析错误来源(召回缺失 / 排序错误 / 生成幻觉),针对性优化后再跑。

    交付物:评测报告、错误归因分析、上线验收结论

    持续

混合检索 + 重排 + 生成(Python)

python rag_pipeline.py
"""RAG 检索管道:混合召回 + RRF 融合 + 重排 + 带引用生成。"""

from __future__ import annotations

import json
from dataclasses import dataclass
from typing import Any

import boto3

BEDROCK = boto3.client("bedrock-runtime", region_name="ap-northeast-1")
OPENSEARCH_INDEX = "kb-chunks"

GEN_MODEL = "anthropic.claude-3-5-sonnet-20241022-v2:0"
EMBED_MODEL = "amazon.titan-embed-text-v2:0"


@dataclass
class Chunk:
    chunk_id: str
    text: str
    source: str
    page: int
    score: float = 0.0


def embed(text: str) -> list[float]:
    """生成查询向量。"""
    resp = BEDROCK.invoke_model(
        modelId=EMBED_MODEL,
        body=json.dumps({"inputText": text, "dimensions": 1024, "normalize": True}),
    )
    return json.loads(resp["body"].read())["embedding"]


def vector_search(os_client, query: str, k: int = 30) -> list[Chunk]:
    """向量召回:语义相近但用词不同的内容也能命中。"""
    body = {
        "size": k,
        "query": {"knn": {"embedding": {"vector": embed(query), "k": k}}},
        "_source": ["chunk_id", "text", "source", "page"],
    }
    hits = os_client.search(index=OPENSEARCH_INDEX, body=body)["hits"]["hits"]
    return [Chunk(**h["_source"], score=h["_score"]) for h in hits]


def keyword_search(os_client, query: str, k: int = 30) -> list[Chunk]:
    """BM25 召回:精确术语、编号、型号这类查询靠它兜底。"""
    body = {
        "size": k,
        "query": {
            "multi_match": {
                "query": query,
                "fields": ["title^3", "text"],
                "type": "best_fields",
            }
        },
        "_source": ["chunk_id", "text", "source", "page"],
    }
    hits = os_client.search(index=OPENSEARCH_INDEX, body=body)["hits"]["hits"]
    return [Chunk(**h["_source"], score=h["_score"]) for h in hits]


def rrf_fuse(lists: list[list[Chunk]], k: int = 60) -> list[Chunk]:
    """Reciprocal Rank Fusion:不同召回通道的排名融合,无需归一化分数。"""
    scores: dict[str, float] = {}
    pool: dict[str, Chunk] = {}
    for chunks in lists:
        for rank, c in enumerate(chunks, start=1):
            scores[c.chunk_id] = scores.get(c.chunk_id, 0.0) + 1.0 / (k + rank)
            pool[c.chunk_id] = c
    ordered = sorted(scores.items(), key=lambda kv: kv[1], reverse=True)
    out = []
    for cid, s in ordered:
        c = pool[cid]
        c.score = s
        out.append(c)
    return out


def rerank(query: str, chunks: list[Chunk], top_n: int = 5) -> list[Chunk]:
    """用 Rerank 模型精排。这一步对准确率的提升通常最明显。"""
    resp = BEDROCK.invoke_model(
        modelId="cohere.rerank-v3-5:0",
        body=json.dumps({
            "query": query,
            "documents": [c.text for c in chunks],
            "top_n": top_n,
        }),
    )
    results = json.loads(resp["body"].read())["results"]
    out = []
    for r in results:
        c = chunks[r["index"]]
        c.score = r["relevance_score"]
        out.append(c)
    return out


PROMPT = """你是企业知识库助手。严格依据 <参考资料> 回答问题。

规则:
1. 只使用参考资料中的信息,不要用你的先验知识补充
2. 每个结论后面用 [来源N] 标注依据
3. 如果参考资料不足以回答,直接说「现有资料无法回答这个问题」,并说明缺什么
4. 不要复述规则,直接给答案

<参考资料>
{context}
</参考资料>

问题:{question}"""


def answer(os_client, question: str) -> dict[str, Any]:
    """完整链路:召回 → 融合 → 重排 → 生成。"""
    candidates = rrf_fuse([
        vector_search(os_client, question),
        keyword_search(os_client, question),
    ])
    top = rerank(question, candidates[:50], top_n=5)

    context = "\n\n".join(
        f"[来源{i}] (出自 {c.source} 第 {c.page} 页)\n{c.text}"
        for i, c in enumerate(top, start=1)
    )

    resp = BEDROCK.converse(
        modelId=GEN_MODEL,
        messages=[{"role": "user", "content": [
            {"text": PROMPT.format(context=context, question=question)}
        ]}],
        inferenceConfig={"maxTokens": 1500, "temperature": 0.1, "topP": 0.9},
    )

    return {
        "answer": resp["output"]["message"]["content"][0]["text"],
        "citations": [
            {"source": c.source, "page": c.page, "score": round(c.score, 4)}
            for c in top
        ],
        "usage": resp["usage"],
    }

temperature 设 0.1 而不是 0,是为了避免模型在边界情况下陷入重复输出。生产环境务必记录 usage 做成本归属。

常见问题与解法

现象根因解法
答案编造,引用的内容对不上召回质量差,模型被迫编提高召回率,提示词强制「无依据就说不知道」
精确编号查不到只用了向量检索加 BM25 关键词通道,RRF 融合
表格数据回答错误切分时表格结构被破坏表格单独解析成 Markdown 或 CSV,整块入索引
回答太笼统切块太大,噪声多父子块策略:小块检索、大块生成
跨文档综合问题答不好Top-K 太小扩大召回后重排,或引入多轮检索
响应太慢串行调用 + 上下文过长并行召回、精简上下文、开启流式输出

下一步

把云的复杂度交给我们,你只管做业务

留下需求,沐杉云的解决方案架构师会在一个工作日内联系你,提供免费的现状评估、迁移方案草案与 TCO 测算表。