大数跨境

从入门到高阶:5 个层级带你掌握 RAG 系统(附完整代码)

从入门到高阶:5 个层级带你掌握 RAG 系统(附完整代码) AI大模型观察站
2026-03-16
0
导读:RAG 系统从简单到复杂有多种实现方式。本文按照 5 个难度等级逐步讲解 RAG 的构建方法,从基础检索到更复杂的优化策略,并提供完整代码示例,帮助开发者循序渐进地掌握 RAG 应用开发。

从“应该能用”到“真的在生产可用”

我为内部文档、客户支持和合规流程都搭过 RAG 系统。大多数第一次都失败了。有些第二次也失败了。

三个月前,我为我们的政策文档做了一个。全量 embedding,接好检索,接上 GPT-4。演示很顺利。

然后法务问了它一个关于数据保留政策的问题。

系统检索出了三个 chunk(分块)。其中两个来自我们早在几年前就替换掉的 2019 年政策。另一个来自 HR 入职文档,里面的“retention”指的是员工留存,语义完全不同,但词相同。

生成的答案把过时的政策和无关的上下文混在一起——信心满满,但完全错误。当着法务团队的面。

我把这个系统重建了四次。每次重建都修了一个之前没料到的失败模式。“演示能跑”和“生产能跑”之间的差距,不止一个点,是五个层次。

五个层次

  1. Naive RAG(朴素 RAG)
    :教程版。一上真实查询就崩。
  2. Smart Chunking(智能分块)
    :你如何切分文档,决定了你能检索到什么。
  3. Hybrid Search(混合检索)
    :当“语义相似”不等于“真正相关”。
  4. Reranking(重排)
    :第二遍打分,捞回初检错过的好内容。
  5. Production RAG(生产级 RAG)
    :当检索失败会怎样?别让 LLM 即兴发挥。

好,开始。


等级 1:朴素 RAG

对文档做 embedding。存向量。按相似度取 top-k。然后生成。


   
   
   
    
   
   
   from openai import OpenAI
import chromadb
client = OpenAI()
chroma = chromadb.Client()
collection = chroma.create_collection("docs")
def index_document(doc_id: str, text: str):
    response = client.embeddings.create(
        model="text-embedding-3-small",
        input=text
    )
    collection.add(
        ids=[doc_id],
        embeddings=[response.data[0].embedding],
        documents=[text]
    )
def naive_rag(query: str, kint = 3) -> str:
    # Embed query
    query_embedding = client.embeddings.create(
        model="text-embedding-3-small",
        input=query
    ).data[0].embedding
    
    # Retrieve
    results = collection.query(
        query_embeddings=[query_embedding],
        n_results=k
    )
    
    # Generate
    context = "\n\n".join(results["documents"][0])
    response = client.chat.completions.create(
        model="gpt-4",
        messages=[
            {"role""system""content": f"Answer based on this context:\n\n{context}"},
            {"role""user""content": query}
        ]
    )
    return response.choices[0].message.content

这就是每个 RAG 教程。也是大多数 RAG 系统停下来的地方(别问我是怎么知道的)。

会怎么崩: 语义相似 ≠ 相关性。你查“data retention policy(数据保留政策)”,会捞到“employee retention programs(员工留存项目)”的 chunk,因为 embedding 看到了词面重合。概念不相关,但向量很近。

你还会捞到“关于”正确主题、但并不“回答”问题的 chunk。三段都在讲数据保留,但都没提你需要的具体政策点。

如果你的演示能跑,是因为你测的都是你本来就知道答案的查询。

等级 2:智能分块(Chunking)

大多数 RAG 失败看起来像检索失败,其实是分块失败。

如果每 500 个 token 就切一刀,你会把一个政策条文拦腰切断。问题在一个 chunk,答案在另一个。上下文和结论被拆开。你会做出无法独立成段的 chunk。

chunk 尺寸比你想的更重要:

  • 太小(100–200 个 token):缺上下文。“90 天后删除”如果不说明“删什么”,就没意义。
  • 太大(1000+ 个 token):一个 chunk 涵盖多个主题。检索时信号与噪声一起进来。
  • 甜蜜点(300–500 个 token):上下文够用,又足够聚焦。

但尺寸不是关键。关键是 overlap(重叠)


   
   
   
    
   
   
   from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
    chunk_size=400,
    chunk_overlap=100,  # This is the key
    separators=["\n\n""\n"". "" """]
)

100 个 token 的重叠意味着如果一句话被切开,两边的 chunk 都包含它。落在边界上的答案,现在从两侧都能检到。

元数据技巧(metadata trick): 别只存文本。把来源也存上。


   
   
   
    
   
   
   def chunk_with_metadata(doc: str, source: str, doc_date: str-> list[dict]:
    chunks = splitter.split_text(doc)
    return [
        {
            "text": chunk,
            "source": source,
            "date": doc_date,
            "section"extract_section_header(chunk),
        }
        for chunk in chunks
    ]

这样当你把 2019 年的 chunk 和 2024 年的 chunk 一起捞出来时,你一眼就能看见。你的 prompt 可以说“优先使用较新的来源”,或者你的代码可以在生成前先做过滤。

(我在这篇里更详细地讲了检索系统的数据源选择:here。)

光做这些,就修掉了大约 40% 的检索失败。垃圾进,垃圾出。更好的 chunk 带来更好的检索。


等级 3:混合检索(Hybrid Search)

查询:“5 年以上工龄员工的带薪休假(PTO)政策是什么?”

语义检索会找到一般性的休假政策。概念上相似。

关键词检索会找到包含“5+ years”和“tenure”的段落。完全匹配。

单独任一者都找不到准确答案。合在一起就能。


   
   
   
    
   
   
   from rank_bm25 import BM25Okapi
import numpy as np
class HybridRetriever:
    def __init__(selfdocuments: list[str]):
        self.documents = documents
        self.embeddings = self._embed_all(documents)
        
        # BM25 for keyword matching
        tokenized = [doc.lower().split() for doc in documents]
        self.bm25 = BM25Okapi(tokenized)
    
    def _embed_all(selfdocs: list[str]) -> list[list[float]]:
        response = client.embeddings.create(
            model="text-embedding-3-small",
            input=docs
        )
        return [d.embedding for d in response.data]
    
    def search(selfquery: str, k: int = 5alpha: float = 0.5) -> list[str]:
        # Semantic scores (normalized)
        q_emb = client.embeddings.create(
            model="text-embedding-3-small",
            input=query
        ).data[0].embedding
        
        sem_scores = np.dot(self.embeddings, q_emb)
        sem_scores = (sem_scores - sem_scores.min()) / (sem_scores.max() - sem_scores.min() + 1e-8)
        
        # BM25 scores (normalized)
        bm25_scores = np.array(self.bm25.get_scores(query.lower().split()))
        if bm25_scores.max() > 0:
            bm25_scores = bm25_scores / bm25_scores.max()
        
        # Combine: alpha controls semantic vs keyword weight
        combined = alpha * sem_scores + (1 - alpha) * bm25_scores
        
        top_k = np.argsort(combined)[::-1][:k]
        return [self.documents[i] for i in top_k]

调 alpha:

  • 行业黑话多(法律、医疗、内部缩写)→ 降低 alpha,更偏 BM25
  • 自然语言提问 → 提高 alpha,更偏语义
  • 先用 0.5,从失败的查询中调整

这不花哨。没人现在还写 BM25 博客了。但它能抓住纯向量检索漏掉的失败,尤其当你的用户会输入他们期望精确命中的短语时。


等级 4:重排(Reranking)

你检索到了 5 个 chunk。它们都“关于”这个话题。但哪些是真的“回答了这个问题”?

Embedding 相似度是独立计算的。每个文档各自针对查询打分。Reranker(交叉编码器)把查询和文档放在“一起”,问的是:“这个文档能回答这个问题吗?”


   
   
   
    
   
   
   from sentence_transformers import CrossEncoder
class RerankedRetriever:
    def __init__(selfdocuments: list[str]):
        self.hybrid = HybridRetriever(documents)
        self.reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
    
    def search(selfquery: str, k: int = 3) -> list[str]:
        # Get 20 candidates (cheap, fast)
        candidates = self.hybrid.search(query, k=20)
        
        # Rerank with cross-encoder (expensive, accurate)
        pairs = [(query, doc) for doc in candidates]
        scores = self.reranker.predict(pairs)
        
        # Return top k after reranking
        reranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
        return [doc for doc, _ in reranked[:k]]

为什么好用: Cross-encoder(交叉编码器)不能预计算文档 embedding。它需要把查询和文档一起看。这让它不适合做初检(你不可能给一万篇文档都打一次交叉分)。但用来对 20 个候选里选前 3?刚刚好。

我在内部文档上的测试中,把“正确 chunk 进 top 3”的命中率从 68% 提升到了 89%。检索其实找到了相关 chunk,只是没把它们排在最前面。

注意:重排救不了糟糕的初检。如果正确的 chunk 根本不在 20 个候选里,重排也浮不出来。先把等级 2 和 3 做好。


等级 5:生产级 RAG

上面的都在提升检索质量。生产级 RAG 要处理的是——当检索仍然失败时,会发生什么。

因为它一定会失败。用户会问到你文档里没有覆盖的东西。或者你的分块刚好漏掉了关键段落。或者问题足够模糊,以至于检索到的 chunk 自相矛盾。

问题不在“如何彻底避免检索失败?”,而在“我的系统在检索失败时会怎么做?”

护栏

当缺少好的上下文时,别让 LLM 即兴发挥。

加拿大航空(Air Canada)就是个惨痛教训——他们因为聊天机器人臆造了一个并不存在的退票政策而败诉了(详解在此)。


   
   
   
    
   
   
   def guarded_rag(query: str, retriever, min_score: float = 0.6) -> str:
    results = retriever.search_with_scores(query, k=3)
    
    # Check: Do we have ANY confident results?
    top_score = results[0][1] if results else 0
    if top_score < min_score:
        return (
            "I don't have enough information to answer that confidently. "
            "Could you rephrase, or is there a specific document I should look at?"
        )
    
    # Check: Are sources from different time periods?
    dates = [r["date"for r, _ in results]
    date_warning = ""
    if len(set(dates)) > 1:
        newest = max(dates)
        if any(d < newest for d in dates):
            date_warning = "\n\n[Note: Some sources are older. The most recent policy takes precedence.]"
    
    # Generate with explicit grounding instruction
    context = "\n\n---\n\n".join([r["text"for r, _ in results])
    
    response = client.chat.completions.create(
        model="gpt-4",
        messages=[
            {
                "role""system",
                "content": f"""Answer based ONLY on the provided context.
If the context doesn't contain enough information, say so explicitly.
Never infer or make up information not directly stated.

Context:
{context}"
""
            },
            {"role""user""content": query}
        ]
    )
    
    return response.choices[0].message.content + date_warning

评估

不能衡量就无法改进。构建一个有标准答案的查询测试集。


   
   
   
    
   
   
   test_cases = [
    {
        "query""What's our data retention policy for customer records?",
        "must_retrieve": ["data-retention-policy-2024.md"],
        "answer_must_contain": ["7 years""deletion request"],
        "answer_must_not_contain": ["2019""employee retention"]
    },
    # ... 50+ more cases covering your actual use cases
]

每次改动都跑一遍。跟踪检索准确率(有没有拿到对的文档?)和回答准确率(答案里有没有包含正确事实?)。当某个指标掉了,你就知道是哪一环出问题了。

即便这样,还是会有边界情况。用户的提问方式总能出乎意料。文档里也可能有你不知道的矛盾。

你一定会错过一些边界情况。确保系统承认它不知道,而不是胡编乱造。

Each level catches failures the previous level missed.

何时该止步

不是每个用例都需要做到等级 5。

Match your use case to the level of complexity it actually needs.

如何判断是否需要升一个等级:

Diagnose your RAG failures by what users are telling you.

还记得那个给法务演示吗?我的 RAG 系统把过时政策和无关上下文自信地混在一起的那次?

他们现在每天都在用这个系统。系统在没把握时会拒答。当来源冲突或过时时会标注。面对模糊问题,会先澄清而不是硬猜。

我重建了四次。每一次都修了一个我第一次没预见到的失败模式。流程就是这样:搭、踩雷、理解原因、升级。

从等级 1 开始。观察它在哪里崩。只有当你搞清楚“为什么崩”时,再往上走。

这就是如何把 RAG 做到真的能用。


正在用 RAG 搭东西?

如果你在把检索接到 Agent 里,我的 LangGraph 指南覆盖了编排这一侧: The Complete Guide to Building Your First AI Agent with LangGraph(https://medium.com/data-science-collective/the-complete-guide-to-building-your-first-ai-agent-its-easier-than-you-think-c87f376c84b2)。这篇被阅读了 10 万+,从基础图到生产部署都讲到了。


【声明】内容源于网络
0
0
AI大模型观察站
专注于人工智能大模型的最新进展,涵盖Transformer架构、LLM训练优化、推理加速、多模态应用等核心技术领域。通过深度解析论文、开源项目和行业动态,揭示大模型技术的演进趋势,助力开发者、研究者和AI爱好者把握前沿创新。
内容 318
粉丝 0
AI大模型观察站 专注于人工智能大模型的最新进展,涵盖Transformer架构、LLM训练优化、推理加速、多模态应用等核心技术领域。通过深度解析论文、开源项目和行业动态,揭示大模型技术的演进趋势,助力开发者、研究者和AI爱好者把握前沿创新。
总阅读1.4k
粉丝0
内容318