云计算百科
云计算领域专业知识百科平台

大模型又开始一本正经地胡说了?用 Python 手写一个最小 RAG 给它“递证据”

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

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

原因主要有三个:

  • 成本:输入越长,处理时间和调用成本通常越高;
  • 噪声:无关资料越多,关键证据越容易被淹没;
  • 位置影响:研究发现,相关信息位于长上下文中部时,一些模型的表现会明显下降,这类现象常被称为 “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)

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

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

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

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

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

  • Context Relevance:找回的资料与问题相关吗?
  • Faithfulness:回答是否忠于给定资料?
  • Answer Relevance:回答是否真正解决了用户的问题?
  • 可以按这张决策图定位故障,而不是一出错就盲目更换大模型:

    在这里插入图片描述

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

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

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

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

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

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

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

    文档切块

    TF-IDF 向量化

    余弦相似度排序

    Top-K 与阈值过滤

    带编号证据的提示词

    生成、引用与校验

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

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

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


    参考资料

  • Lewis 等,《Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks》,NeurIPS 2020:https://papers.neurips.cc/paper_files/paper/2020/hash/6b493230205f780e1bc26945df7481e5-Abstract.html
  • Manning、Raghavan、Schütze,《Introduction to Information Retrieval》:https://nlp.stanford.edu/IR-book/
  • Stanford IR Book,Term frequency and weighting:https://nlp.stanford.edu/IR-book/html/htmledition/term-frequency-and-weighting-1.html
  • Reimers、Gurevych,《Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks》:https://aclanthology.org/D19-1410/
  • Liu 等,《Lost in the Middle: How Language Models Use Long Contexts》:https://aclanthology.org/2024.tacl-1.9/
  • Es 等,《RAGAs: Automated Evaluation of Retrieval Augmented Generation》:https://aclanthology.org/2024.eacl-demo.16/
  • 说明:本文代码与配图均为原创。示例用于解释检索与增强原理,不代表生产级系统的性能,也不声称 RAG 能彻底消除模型幻觉。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 大模型又开始一本正经地胡说了?用 Python 手写一个最小 RAG 给它“递证据”
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!