Lab:RAG 核心链路实现与证据注入

通过实现 RAG 的核心链路------从文档分块、向量检索到提示组装与生成------来解剖"检索器 + 生成器"这一架构的本质。理解外部知识如何被精准"拼入" LLM 的上下文中以消除幻觉。

阶段 预估耗时 建议
环境准备 10 min 注册 DashScope + 获取 Key + 开启"用完即停"
实现 chunker.py 20 min 先固定字数切分,再补充句子边界逻辑
实现 retriever.py 25 min 跑通 Embedding API + 手写 numpy 余弦相似度
实现 generator.py 20 min 构建 Prompt 模板,调用 Chat API
调试测试 1-2 20 min 首次串联,确认网络、API 与基础 RAG 流程正常
通过测试 3-5 30 min 核心阶段:反复调参,观察分块与 K 值的影响
通过测试 6-7 20 min 运行对比实验与多文档综合生成
撰写实验报告 30 min 结合运行现象回答分析题
总计 约 2.75 小时

一. 实验目标

  1. 掌握知识粒度控制:将非结构化文档加工为可检索的知识单元,理解分块策略(Chunking)如何影响证据的完整性与精确性。
  2. 解构向量检索器:手写文本向量化、余弦相似度搜索与 Top-K 召回,看清向量检索的数学本质,并解释其与关键词检索的互补关系。
  3. 实现证据注入机制:构建 Prompt 组装器,将检索到的文档块动态注入指令模板,理解"把外部知识拼入 LLM 上下文"这一增强机制。
  4. 量化分析核心参数 :通过控制变量法,量化分析分块大小(chunk_size)、召回数量(K)、有无检索对事实正确性的具体影响。
  5. 理解 RAG 的本质特征:通过对比实验,深刻理解 RAG 区别于纯 LLM 的三大特征------知识外挂、幻觉降低、可追溯性。

二. 前置知识与环境准备

  • Python 基础:类、列表/字典操作、字符串处理、numpy 基础矩阵运算。

  • API 准备 :访问 阿里云百炼控制台 创建 API Key(建议开启"免费额度用完即停"),并设置环境变量:

    bash 复制代码
    export DASHSCOPE_API_KEY="sk-xxxx"      # Linux / macOS
    set DASHSCOPE_API_KEY=sk-xxxx           # Windows CMD
    $env:DASHSCOPE_API_KEY="sk-xxxx"        # Windows PowerShell
  • 依赖安装

    bash 复制代码
    pip install openai numpy pytest

三. 项目脚手架

text 复制代码
rag_core_lab/
├── src/
│   ├── chunker.py        # 文档分块器
│   ├── retriever.py      # 向量化与检索(使用 text-embedding-v3)
│   ├── generator.py      # 提示组装与 LLM 调用(使用 qwen-plus)
│   └── experiment.py     # 辅助函数(可选,用于加载测试文档)
├── tests/
│   └── test_rag.py       # 全部 7 个测试(已为你准备好)
└── requirements.txt      # openai, numpy, pytest

你必须实现的接口(严禁修改签名):

python 复制代码
class SimpleChunker:
    def __init__(self, chunk_size: int = 300, chunk_overlap: int = 50):
        self.chunk_size = chunk_size
        self.chunk_overlap = chunk_overlap

    def split_text(self, text: str) -> list[str]:
        """
        将文本切分为块列表。
        尽量在句子边界断句,相邻块间保留 chunk_overlap 个字符的重叠。
        """
        pass
python 复制代码
import os
import numpy as np
from openai import OpenAI

class BaseRetriever:
    def __init__(self, embedding_model: str = "text-embedding-v3"):
        self.client = OpenAI(
            api_key=os.getenv("DASHSCOPE_API_KEY"),
            base_url="https://dashscope.aliyuncs.com/compatible-mode/v1"
        )
        self.embedding_model = embedding_model
        self.chunks = []
        self.embeddings = []

    def _get_embedding(self, text: str) -> list[float]:
        """调用 API 获取文本的向量表示。"""
        pass

    def index(self, chunks: list[str]) -> None:
        """对切好的块进行向量化并存储(内存中)。"""
        pass

    def retrieve(self, query: str, k: int = 3) -> list[str]:
        """输入问题,计算余弦相似度,返回最相关的 Top-K 个文本块。"""
        pass
python 复制代码
import os
from openai import OpenAI
from src.retriever import BaseRetriever

class RAGGenerator:
    def __init__(self, retriever: BaseRetriever, model: str = "qwen-plus"):
        self.retriever = retriever
        self.model = model
        self.client = OpenAI(
            api_key=os.getenv("DASHSCOPE_API_KEY"),
            base_url="https://dashscope.aliyuncs.com/compatible-mode/v1"
        )

    def answer(self, question: str, k: int = 3, return_context: bool = False) -> str | dict:
        """
        使用检索到的块构建 Prompt,调用千问模型生成答案。
        如果 return_context 为 True,返回 {"answer": ..., "chunks": [...]}
        """
        pass

!请遵循以下约束:

1.严禁使用 langchainllama-index 等高级编排框架。

2.所有检索逻辑(包括余弦相似度计算)和 Prompt 组装逻辑必须手写

3.允许使用 Python 标准库、openai SDK 和 numpy

四. 核心设计指引

1. 分块:知识的粒度控制

  • 基本策略:按固定字符数滑动窗口切分,优先在句号、换行等自然边界处断句。
  • 重叠机制 :相邻块间保留 chunk_overlap 个字符的重叠,防止关键语义被硬切断。分块质量直接影响检索命中率,这正是测试 3 要验证的核心。

2. 向量检索:从文本到余弦相似度

  • index() :为每个 chunk 调用嵌入 API,获取向量并存入 self.chunksself.embeddings
  • retrieve():将 query 编码为向量,使用 numpy 计算与所有 chunk 向量的余弦相似度( \\frac{A \\cdot B}{\|A\| \|B\|} ),返回得分最高的 k 个 chunk。
  • 思考:手写余弦相似度的意义在于,你可以完全看清向量化的数学结果,而不是被高级框架的 similarity_search 屏蔽。

3. 生成:Prompt 组装

  • 调用 retriever.retrieve() 获取 Top-K 块。

  • 将块拼接为"参考资料"字符串,构建指令式 Prompt:

    text 复制代码
    请根据以下参考资料回答问题。如果资料中没有信息,则明确告知不知道。
    参考资料:
    {chunks}
    问题:{question}
    答案:
  • 调用千问 Chat API 返回答案。若 return_context=True,同时返回用到的块列表。

4. 常见阻碍

  • API 调用不通 :检查环境变量名是否严格为 DASHSCOPE_API_KEYbase_url 是否指向 DashScope 兼容端点。
  • 测试 4 断言偶发失败:大模型输出具有随机性。如果 K=1 时 LLM 偶尔通过自身参数"幻觉"出了 12%,属正常现象。理解 K 值对召回率的影响趋势,比严格的通过/失败更重要。
  • numpy 维度报错 :确保在计算余弦相似度前,将 API 返回的 list 转换为 numpy array,并注意向量归一化时的维度对齐(如使用 np.linalg.norm 时指定 axis)。

五. 测试用例

请将以下代码完整放入 tests/test_rag.py。测试将直接调用真实的千问 API,请确保环境变量已配置。

测试 1:无 RAG 基线(纯 LLM 回答)

目标:验证在没有外部知识注入时,纯 LLM 面对私有领域问题会产生幻觉或无法提供具体细节。

通过标准

  • 使用空的 Retriever(retrieve 返回空列表,不注入任何上下文)。
  • gen.answer 的最终返回文本中不包含 知识库内的具体私有编号(如 "XJ-2024-007"),证明 LLM 未产生特定幻觉。
python 复制代码
import pytest
from src.generator import RAGGenerator
from src.retriever import BaseRetriever

# ================= 辅助类 =================
class EmptyRetriever(BaseRetriever):
    def index(self, chunks): pass
    def retrieve(self, query, k=3): return []

# ================= 测试用例 =================
def test_no_rag_baseline():
    gen = RAGGenerator(EmptyRetriever())
    answer = gen.answer("XJ-2024-007 号钻探报告的核心结论是什么?")
    
    assert "XJ-2024-007" not in answer, f"LLM 不可能知道这个编号,但幻觉输出了:{answer}"

测试 2:基本 RAG 功能(检索增强回答)

目标:验证给定知识库后,系统能成功检索到相关信息,并将其注入 Prompt 生成包含事实的准确答案。

通过标准

  • 构建包含 "XJ-2024-007""花岗岩基底" 的文档块并建立索引。
  • gen.answer 的返回字典中,answer 字段必须包含字符串 "花岗岩基底"
  • 返回字典中的 chunks 列表长度必须大于 0,证明检索成功发生。
python 复制代码
def test_rag_basic():
    docs = [
        "XJ-2024-007 号钻探报告显示,矿区深部存在厚层花岗岩基底。",
        "2023年公司营收为12亿元,同比增长8%。"
    ]
    retriever = BaseRetriever()
    retriever.index(docs)
    gen = RAGGenerator(retriever)
    
    result = gen.answer("XJ-2024-007 钻探报告发现了什么?", return_context=True)
    
    assert "花岗岩基底" in result["answer"], f"答案未包含事实:{result['answer']}"
    assert len(result["chunks"]) > 0

测试 3:分块大小对检索完整性的影响

目标 :观察 chunk_size 过小时,单一事实可能被切断,导致检索失效。

通过标准

  • chunk_size=20 时,切分出的所有块中,没有任何一个块 能同时包含 "钻探""花岗岩基底"
  • chunk_size=200 时,至少有一个块能同时包含这两个关键词,保证事实完整性。
python 复制代码
from src.chunker import SimpleChunker

def test_chunk_size_impact():
    text = "XJ-2024-007 号钻探报告指出,矿区深部存在厚层花岗岩基底,这是本次勘探的主要发现。"
    
    small_chunker = SimpleChunker(chunk_size=20, chunk_overlap=0)
    small_chunks = small_chunker.split_text(text)
    assert len(small_chunks) > 1
    for ch in small_chunks:
        assert not ("钻探" in ch and "花岗岩基底" in ch), "小块不应同时包含两者"
        
    large_chunker = SimpleChunker(chunk_size=200, chunk_overlap=0)
    large_chunks = large_chunker.split_text(text)
    combined = any("钻探" in ch and "花岗岩基底" in ch for ch in large_chunks)
    assert combined, "大块应能包含完整事实"

测试 4:检索数量 K 对答案完整性的影响

目标:证明当需要多条信息时,K 太小会导致信息缺失(即"管中窥豹"效应)。

通过标准

  • k=1 时,由于只召回效率提升的块,LLM 的回答中不包含 "12%"(成本降低信息)。
  • k=2 时,召回了包含成本信息的块,LLM 的回答中必须包含 "12%"
python 复制代码
def test_k_value_impact():
    chunks = [
        "XJ-2024-008 报告:新钻探技术效率提升 20%。",
        "同时,该技术使每米钻进成本降低 12%。"
    ]
    retriever = BaseRetriever()
    retriever.index(chunks)
    gen = RAGGenerator(retriever)
    
    ans_k1 = gen.answer("新钻探技术提升了多少效率,成本有何变化?", k=1, return_context=True)
    ans_k2 = gen.answer("新钻探技术提升了多少效率,成本有何变化?", k=2, return_context=True)
    
    # 注:大模型有微小概率在 K=1 时通过自身参数幻觉出 12%,若偶发失败请理解其趋势
    assert "12%" not in ans_k1["answer"], f"K=1 时意外包含了成本信息:{ans_k1['answer']}"
    assert "12%" in ans_k2["answer"], f"K=2 时应包含成本信息:{ans_k2['answer']}"

测试 5:检索来源的可追溯性

目标:验证 RAG 能给出引用来源,增强生成结果的可解释性与可追溯性。

通过标准

  • gen.answer 返回的 answer 字符串中包含关键事实 "1100"
  • 返回的 chunks 列表中,至少有一个块 包含原始文本 "1100°C",证明答案有据可查。
python 复制代码
def test_source_traceability():
    chunks = [
        "F-2024-01: 单晶高温合金可在 1100°C 工作。",
        "F-2024-02: 该合金抗氧化性能提升 30%。"
    ]
    retriever = BaseRetriever()
    retriever.index(chunks)
    gen = RAGGenerator(retriever)
    
    result = gen.answer("F-2024-01 研究中的合金耐温多少?", return_context=True)
    
    assert "1100" in result["answer"]
    assert any("1100°C" in ch for ch in result["chunks"])

测试 6:与纯 LLM 对比(幻觉对比)

目标:通过控制变量法对比 RAG 和纯 LLM,直观展示 RAG 在私有数据上的事实准确性优势。

通过标准

  • RAG 版本(注入包含 "1.8%" 的文档)的回答中必须包含 "1.8%"
  • 纯 LLM 版本(EmptyRetriever)的回答中不包含 "1.8%"
python 复制代码
def test_rag_vs_llm_hallucination():
    doc = "XJ-2024-009 号勘探区发现高品位锂矿,氧化锂平均品位 1.8%。"
    retriever = BaseRetriever()
    retriever.index([doc])
    gen_rag = RAGGenerator(retriever)
    
    gen_no_rag = RAGGenerator(EmptyRetriever())
    
    ans_rag = gen_rag.answer("XJ-2024-009 的主要发现是什么?")
    ans_no_rag = gen_no_rag.answer("XJ-2024-009 的主要发现是什么?")
    
    assert "1.8%" in ans_rag, f"RAG 答案应包含 1.8%,实际为:{ans_rag}"
    assert "1.8%" not in ans_no_rag, f"纯 LLM 不应知道 1.8%,实际为:{ans_no_rag}"

测试 7:多文档综合回答

目标:验证当答案分散在多个文档块中时,检索和生成模块能正确综合多源信息。

通过标准

  • 设置 k=2 召回两个不同的报告块。
  • gen.answer 的最终返回文本中,必须同时包含 "12%"(降水量)和 "1.2°C"(气温)两个关键数据。
python 复制代码
def test_multi_doc_synthesis():
    chunks = [
        "报告 A: 该地区年降水量增加 12%。",
        "报告 B: 同期平均气温上升 1.2°C。"
    ]
    retriever = BaseRetriever()
    retriever.index(chunks)
    gen = RAGGenerator(retriever)
    
    ans = gen.answer("该地区气候变化的关键数据有哪些?", k=2)
    
    assert "12%" in ans and "1.2°C" in ans, f"答案应同时包含两个数据,实际为:{ans}"

六. 思考检验

"1. 检索器 + 生成器"架构的本质优势

如果去掉检索器,仅靠 LLM 本身,在处理私有或实时知识时存在哪些根本缺陷?RAG 是如何用"外挂记忆"和"证据注入"来解决这些问题的?

2. 分块策略的设计权衡

根据测试 3 的结果,分析:如果块太大,会引入什么噪音?如果块太小,会导致什么信息断裂?在你的实际应用中,你会如何选择 chunk_sizeoverlap

3. RAG 的局限与改进方向

在测试 7 中,如果两个文档块语义相近但并不直接匹配问题关键词,纯向量检索可能失效。你会想到哪些方法来提升检索的召回率?(提示:混合检索、重排序等)

资源附录