大模型又开始一本正经地胡说了?用 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 塞给模型?

这是一个非常自然的问题:既然模型上下文越来越长,为什么还要检索?

原因主要有三个:

  1. 成本:输入越长,处理时间和调用成本通常越高;
  2. 噪声:无关资料越多,关键证据越容易被淹没;
  3. 位置影响:研究发现,相关信息位于长上下文中部时,一些模型的表现会明显下降,这类现象常被称为 “Lost in the Middle”。

所以“上下文很长”并不等于“模型能同等认真地读完每一段”。把 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)

第三个问题故意超出知识库范围。如果系统仍然自信地给出“火星数据库部署最佳实践”,那不是探索精神,是拒答机制还没上班。

十、检索结果怎样变成“证据”?

检索只解决“找什么”,增强阶段还要解决“怎样交给模型”。

一个比较明确的提示词应包含:

  1. 问题;
  2. 带编号的资料;
  3. 只依据资料回答的约束;
  4. 证据不足时的拒答要求;
  5. 引用资料编号的要求。
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 出错时至少要区分三件事:

  1. Context Relevance:找回的资料与问题相关吗?
  2. Faithfulness:回答是否忠于给定资料?
  3. Answer Relevance:回答是否真正解决了用户的问题?

可以按这张决策图定位故障,而不是一出错就盲目更换大模型:

在这里插入图片描述

RAGAS 等研究型评测框架正是围绕检索上下文、回答忠实度和回答质量等不同维度展开。原因很简单:

没找对资料,是检索问题;
资料对但回答编错,是生成问题;
答案正确却答非所问,是交互问题。

只看最终一句“好像挺通顺”,就像验收一辆车时只检查喇叭响不响。

一个最小评测集至少应包含:

  • 20~50 个真实业务问题;
  • 每个问题对应的正确证据;
  • 应该拒答的问题;
  • 相似但容易混淆的问题;
  • 旧版本与新版本冲突的问题。

十四、总结:先给证据,再让模型开口

手写这个最小版本后,RAG 不再是一串框架名,而是一条可以逐段检查的链路:

文档切块
  ↓
TF-IDF 向量化
  ↓
余弦相似度排序
  ↓
Top-K 与阈值过滤
  ↓
带编号证据的提示词
  ↓
生成、引用与校验

框架能减少工程代码,却不能替代对链路的理解。以后使用 LangChain、LlamaIndex、Embedding 模型或向量数据库时,你可以明确知道每个组件替换了什么,也能在答案出错时先问一句:

“是侦探找错了证据,还是发言人拿对了材料却临场发挥?”

这比把温度参数从 0.7 调到 0.6,然后虔诚地刷新页面,要可靠得多。


参考资料

  1. Lewis 等,《Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks》,NeurIPS 2020:https://papers.neurips.cc/paper_files/paper/2020/hash/6b493230205f780e1bc26945df7481e5-Abstract.html
  2. Manning、Raghavan、Schütze,《Introduction to Information Retrieval》:https://nlp.stanford.edu/IR-book/
  3. Stanford IR Book,Term frequency and weighting:https://nlp.stanford.edu/IR-book/html/htmledition/term-frequency-and-weighting-1.html
  4. Reimers、Gurevych,《Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks》:https://aclanthology.org/D19-1410/
  5. Liu 等,《Lost in the Middle: How Language Models Use Long Contexts》:https://aclanthology.org/2024.tacl-1.9/
  6. Es 等,《RAGAs: Automated Evaluation of Retrieval Augmented Generation》:https://aclanthology.org/2024.eacl-demo.16/

说明:本文代码与配图均为原创。示例用于解释检索与增强原理,不代表生产级系统的性能,也不声称 RAG 能彻底消除模型幻觉。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐