大模型又开始一本正经地胡说了?用 Python 手写一个最小 RAG 给它“递证据”
不依赖 LangChain,不需要向量数据库,也不用申请 API Key。本文只用 Python 标准库,从零实现文本切块、TF-IDF、余弦相似度、Top-K 检索和带证据的提示词拼装。我们不仅让代码跑起来,还要看清 RAG 在哪里有效、会在哪里翻车,以及怎样从教学玩具升级到生产系统。
一、事故发生在下班前一分钟
假设时间是周五 17:59。
你问公司知识助手:
“试用期员工可以申请几天远程办公?”
模型沉思了 0.8 秒,用一种仿佛参加过董事会的语气回答:
“根据公司 2025 年新版制度,试用期员工每月可申请 3 天远程办公。”
听起来逻辑严密,措辞专业,甚至还贴心地补了一句“建议提前向直属主管报备”。
唯一的问题是:公司根本没有这条制度。
这就是大模型最让人哭笑不得的能力——不知道答案时,它有时不会沉默,反而会努力把语气调整得更像答案。
RAG(Retrieval-Augmented Generation,检索增强生成)的思路并不神秘:先别让模型急着开口,先去知识库找证据,再把问题和证据一起交给它。像一位靠谱的助理,在老板发言前悄悄递上一叠标好重点的资料。
但请先记住:
RAG 能降低无依据回答的风险,却不能为模型颁发“永不胡说终身成就奖”。
如果知识库本身错误、检索找错材料,或者模型拿到证据后仍然自由发挥,答案照样可能翻车。
二、RAG 到底增加了什么?
经典 RAG 论文将两类“记忆”结合起来:
- 参数化记忆:模型训练后写进参数里的知识;
- 非参数化记忆:可以独立查询、更新和追踪来源的外部资料。
普通问答大致是:
问题 → 大模型 → 答案
RAG 则多了一条证据链:
问题 → 检索知识库 → 选出相关资料 → 拼入上下文 → 大模型 → 答案与来源
这意味着更新公司制度时,不必重新训练整个模型,只需更新知识库并重新建立索引。模型不再只凭“脑内印象”回答,而是多了一次查资料的机会。

图中分为两条路径。
1. 离线建库
原始文档 → 清洗 → 文本切块 → 向量化 → 建立索引
文档可以来自 Markdown、PDF、网页、数据库或内部接口。切块后,每个文本块除了正文,通常还应保存:
- 文档标题;
- 来源链接或文件名;
- 章节位置;
- 更新时间;
- 权限标签;
- 版本号。
2. 在线问答
用户问题 → 问题向量化 → 检索 → 排序 → Top-K → 拼装上下文 → 生成 → 校验
很多演示做到“生成”就结束了,生产环境却不能忘记最后的“校验”。毕竟模型写完答案后,不会主动举手说:“我刚才第三段其实有点心虚。”
三、为什么不直接把整本 PDF 塞给模型?
这是一个非常自然的问题:既然模型上下文越来越长,为什么还要检索?
原因主要有三个:
所以“上下文很长”并不等于“模型能同等认真地读完每一段”。把 300 页制度全部塞给模型,有时就像把整座图书馆推进会议室,然后问它:“刚才那张请假单在哪里?”
检索的价值不是让模型看到更多,而是让它优先看到更相关、更聚焦、可追踪的内容。
四、先做一个最小知识库
新建 knowledge.txt,用空行分隔文本块:
RAG 是检索增强生成的缩写。系统先从外部知识库检索与问题相关的资料,再把资料和问题一起交给生成模型。外部知识可以独立更新,并能为回答提供可检查的依据。
大语言模型的参数可以记忆训练语料中的知识,但参数中的知识不容易被精确更新,也不能保证每次回答都能给出可靠来源。检索可以补充模型参数之外的上下文,但不能从理论上彻底消除幻觉。
TF-IDF 会提高在当前文档中频繁、但在整个文档集合中较少出现的词的权重。余弦相似度通过比较两个向量的方向衡量相关性,常用于向量空间检索。
示例按空行切块:
import re
def split_documents(text: str) –> list[str]:
return [
part.strip()
for part in re.split(r"\\n\\s*\\n", text)
if part.strip()
]
真实项目不能这样“一刀切”。更合理的切块策略通常要尊重标题、段落、列表和代码块:
- 块太大:主题混杂,检索命中了半篇无关内容;
- 块太小:上下文断裂,答案上半句在 A 块,下半句在 B 块;
- 没有重叠:边界处的信息可能被切断;
- 重叠太多:索引膨胀,相似片段互相抢排名。
切块很像切蛋糕:太大没人吃得下,太小满桌都是渣,切得刚好才显得你来过高级餐厅。
五、不装分词库,中文怎样向量化?
英文可以按空格和单词切分,中文没有天然空格。为了让示例只依赖标准库,我们组合三类特征:
- 英文字母、数字和下划线组成的单词;
- 单个汉字;
- 连续中文中的二元字词。
def tokenize(text: str) –> list[str]:
text = text.lower()
tokens = re.findall(r"[a-z0-9_]+", text)
for block in re.findall(r"[\\u4e00-\\u9fff]+", text):
tokens.extend(block)
tokens.extend(
block[i : i + 2]
for i in range(len(block) – 1)
)
return tokens
“检索增强生成”会产生单字,以及“检索、索增、增强、强生、生成”等二元字词。
这不是完整的中文语义理解。它不知道“报销”与“费用 reimbursement”可能表达相近概念,只能较好地捕捉字词重合。但作为教学检索器,它有三个优点:透明、可运行、出了问题容易拆开看。
六、TF-IDF:让稀有但关键的词站到前排
如果只按出现次数排序,“系统”“模型”“可以”这类高频词会占据舞台中央。它们很勤奋,但区分文档的能力并不强。
TF-IDF 同时考虑:
- TF(Term Frequency):词在当前文档中出现得多不多;
- IDF(Inverse Document Frequency):词在整个知识库中是否足够稀有。
本文采用带平滑的 IDF:
idf(t) = log((N + 1) / (df(t) + 1)) + 1
其中,N 是文档总数,df(t) 是包含词 t 的文档数。
def _build_idf(tokenized_documents):
document_count = len(tokenized_documents)
document_frequency = Counter()
for tokens in tokenized_documents:
document_frequency.update(set(tokens))
return {
term: math.log(
(document_count + 1) / (frequency + 1)
) + 1
for term, frequency in document_frequency.items()
}
这里的 set(tokens) 很关键。文档频率统计的是“多少篇文档包含这个词”,不是这个词总共出现了多少次。
七、余弦相似度:看方向,不看谁块头大
问题和文档被表示为向量后,可以用余弦相似度衡量方向是否接近:
cos(q, d) = (q · d) / (||q|| × ||d||)
下面这张图把“词如何变成检索排名”串成了一条完整因果链: 
如果问题向量与某个文档向量方向接近,分数就更高。余弦相似度关注的是夹角,而不是单纯比较向量长度,因此不会因为某篇文档特别长就自动判它获胜。
@staticmethod
def _cosine(left, right):
if not left or not right:
return 0.0
common_terms = left.keys() & right.keys()
dot_product = sum(
left[term] * right[term]
for term in common_terms
)
left_norm = math.sqrt(
sum(weight ** 2 for weight in left.values())
)
right_norm = math.sqrt(
sum(weight ** 2 for weight in right.values())
)
return (
dot_product / (left_norm * right_norm)
if left_norm and right_norm
else 0.0
)
图中左下角那份没有上榜、表情略显委屈的文档提醒我们:不是每一段文字都必须进入上下文。被检索器淘汰,不代表它没有价值,只代表它不该参加这一题。
八、把检索器组装起来
初始化时计算所有文档向量;查询时只需向量化问题、计算分数并排序:
class TfidfRetriever:
def __init__(self, documents):
if not documents:
raise ValueError("知识库不能为空")
self.documents = documents
self.doc_tokens = [
tokenize(document)
for document in documents
]
self.idf = self._build_idf(self.doc_tokens)
self.doc_vectors = [
self._vectorize(tokens)
for tokens in self.doc_tokens
]
def search(self, query, top_k=2):
if top_k < 1:
raise ValueError("top_k 必须大于等于 1")
query_vector = self._vectorize(tokenize(query))
ranked = [
SearchResult(
self._cosine(query_vector, vector),
document,
)
for document, vector
in zip(self.documents, self.doc_vectors)
]
ranked.sort(
key=lambda item: item.score,
reverse=True,
)
return ranked[:top_k]
top_k 不是“越大越保险”。
- 太小:关键证据可能漏掉;
- 太大:无关片段混进上下文;
- 完全不设阈值:即使所有分数为 0,也会硬挑几个“倒霉文档”陪模型演戏。
更稳妥的做法是同时设置 Top-K 和最低相关度阈值。最高分低于阈值时,直接承认知识库没有足够证据。
九、运行:看看它到底找到了谁
完整代码见同目录的 minimal_rag.py,仅使用 Python 标准库,建议 Python 3.10 或更高版本:
python minimal_rag.py
测试问题:
RAG 为什么能降低大模型回答中的幻觉风险?
本地实测得到:
1. score=0.309 | 大语言模型的参数可以记忆训练语料中的知识……
2. score=0.163 | RAG 是检索增强生成的缩写……
排名符合预期:第一段直接讨论参数知识与幻觉,第二段解释 RAG 的基本工作方式。
还可以测试:
retriever.search("文本切块太大会有什么问题?", top_k=2)
retriever.search("余弦相似度有什么作用?", top_k=2)
retriever.search("如何在火星部署数据库?", top_k=2)
第三个问题故意超出知识库范围。如果系统仍然自信地给出“火星数据库部署最佳实践”,那不是探索精神,是拒答机制还没上班。
十、检索结果怎样变成“证据”?
检索只解决“找什么”,增强阶段还要解决“怎样交给模型”。
一个比较明确的提示词应包含:
def build_prompt(query, results):
context = "\\n\\n".join(
f"[资料 {index}|相关度 {result.score:.3f}]\\n"
f"{result.text}"
for index, result in enumerate(results, start=1)
)
return f"""你是一名严谨的技术助手。
请仅根据给定资料回答问题。
如果资料不足,请明确回答“现有资料不足”,不要猜测。
回答末尾请标出使用了哪些资料编号。
问题:
{query}
资料:
{context}
"""
本文没有调用在线大模型,而是输出最终提示词。这是刻意设计的边界:我们已经完整实现 Retrieval 和 Augmentation,并为 Generation 留出接口,但不会虚构某个模型的运行结果。
十一、RAG 最常见的四种翻车姿势
1. 知识库没有答案
检索器再努力,也不能从员工手册里找出量子力学答案。此时正确行为是拒答或切换知识源。
2. 找到了“长得像”的错误资料
TF-IDF 擅长字词匹配,却不真正理解语义。“苹果退款规则”可能同时命中水果商城和手机售后。元数据过滤、语义向量和重排序都能缓解这个问题。
3. 证据太多,模型在中间迷路
把大量文本塞入上下文不一定更好。“Lost in the Middle”研究表明,相关信息在长上下文中的位置会影响一些模型的表现。检索结果需要去重、压缩和排序,而不是把 Top-100 当作诚意礼包。
4. 回答听话,但证据已经过期
检索结果忠实不等于事实最新。知识库需要版本、更新时间、失效机制和来源追踪。否则模型可能非常忠诚地引用一份三年前的错误制度。
十二、从教学玩具升级到生产级 RAG

本文的小程序是一台透明的“教学发动机”。要进入生产环境,可以逐步替换组件:
| 空行切块 | 结构化解析、滑动窗口、语义切块 | 上下文断裂 |
| TF-IDF | Embedding 语义检索 | 同义表达匹配 |
| 单路检索 | BM25 + 向量的混合检索 | 兼顾关键词与语义 |
| 直接 Top-K | Cross-Encoder 重排序 | 提高前几名精度 |
| 无过滤 | 元数据、时间和权限过滤 | 串库与越权 |
| 固定提示词 | 引用约束、拒答与防注入策略 | 减少无依据生成 |
| 看起来能用 | 离线评测、线上监控与人工抽检 | 防止“凭感觉优化” |
Sentence-BERT 等工作展示了如何得到适合相似度比较的句子向量。实际系统中,向量检索适合处理语义相近但字面不同的表达;关键词检索则擅长型号、错误码、人名和精确术语。两者混合,往往比“选边站队”更实用。
重排序器可以对初步召回的候选逐个精排。通俗地说:第一轮像海选,第二轮才是评委看回放。这样不必对整个知识库做昂贵的深度比较。
十三、别只测最终答案:RAG 是一条流水线
RAG 出错时至少要区分三件事:
可以按这张决策图定位故障,而不是一出错就盲目更换大模型:

RAGAS 等研究型评测框架正是围绕检索上下文、回答忠实度和回答质量等不同维度展开。原因很简单:
没找对资料,是检索问题;
资料对但回答编错,是生成问题;
答案正确却答非所问,是交互问题。
只看最终一句“好像挺通顺”,就像验收一辆车时只检查喇叭响不响。
一个最小评测集至少应包含:
- 20~50 个真实业务问题;
- 每个问题对应的正确证据;
- 应该拒答的问题;
- 相似但容易混淆的问题;
- 旧版本与新版本冲突的问题。
十四、总结:先给证据,再让模型开口
手写这个最小版本后,RAG 不再是一串框架名,而是一条可以逐段检查的链路:
文档切块
↓
TF-IDF 向量化
↓
余弦相似度排序
↓
Top-K 与阈值过滤
↓
带编号证据的提示词
↓
生成、引用与校验
框架能减少工程代码,却不能替代对链路的理解。以后使用 LangChain、LlamaIndex、Embedding 模型或向量数据库时,你可以明确知道每个组件替换了什么,也能在答案出错时先问一句:
“是侦探找错了证据,还是发言人拿对了材料却临场发挥?”
这比把温度参数从 0.7 调到 0.6,然后虔诚地刷新页面,要可靠得多。
参考资料
说明:本文代码与配图均为原创。示例用于解释检索与增强原理,不代表生产级系统的性能,也不声称 RAG 能彻底消除模型幻觉。
网硕互联帮助中心




评论前必须登录!
注册