通过实现 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 小时 |
一. 实验目标
- 掌握知识粒度控制:将非结构化文档加工为可检索的知识单元,理解分块策略(Chunking)如何影响证据的完整性与精确性。
- 解构向量检索器:手写文本向量化、余弦相似度搜索与 Top-K 召回,看清向量检索的数学本质,并解释其与关键词检索的互补关系。
- 实现证据注入机制:构建 Prompt 组装器,将检索到的文档块动态注入指令模板,理解"把外部知识拼入 LLM 上下文"这一增强机制。
- 量化分析核心参数 :通过控制变量法,量化分析分块大小(
chunk_size)、召回数量(K)、有无检索对事实正确性的具体影响。 - 理解 RAG 的本质特征:通过对比实验,深刻理解 RAG 区别于纯 LLM 的三大特征------知识外挂、幻觉降低、可追溯性。
二. 前置知识与环境准备
-
Python 基础:类、列表/字典操作、字符串处理、numpy 基础矩阵运算。
-
API 准备 :访问 阿里云百炼控制台 创建 API Key(建议开启"免费额度用完即停"),并设置环境变量:
bashexport DASHSCOPE_API_KEY="sk-xxxx" # Linux / macOS set DASHSCOPE_API_KEY=sk-xxxx # Windows CMD $env:DASHSCOPE_API_KEY="sk-xxxx" # Windows PowerShell -
依赖安装:
bashpip 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.严禁使用
langchain、llama-index等高级编排框架。2.所有检索逻辑(包括余弦相似度计算)和 Prompt 组装逻辑必须手写 。
3.允许使用 Python 标准库、
openaiSDK 和numpy。
四. 核心设计指引
1. 分块:知识的粒度控制
- 基本策略:按固定字符数滑动窗口切分,优先在句号、换行等自然边界处断句。
- 重叠机制 :相邻块间保留
chunk_overlap个字符的重叠,防止关键语义被硬切断。分块质量直接影响检索命中率,这正是测试 3 要验证的核心。
2. 向量检索:从文本到余弦相似度
index():为每个 chunk 调用嵌入 API,获取向量并存入self.chunks和self.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_KEY,base_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_size 和 overlap?
3. RAG 的局限与改进方向
在测试 7 中,如果两个文档块语义相近但并不直接匹配问题关键词,纯向量检索可能失效。你会想到哪些方法来提升检索的召回率?(提示:混合检索、重排序等)
资源附录
- 实现源码:agent-grok-labs
- 实验文档:密码:84k2