Post

从零手写最小 RAG 教程

一句话记住 RAG RAG 的全称是 Retrieval-Augmented Generation,中文通常称为“检索增强生成”。 Load → Chunk → Embed → Retrieve → Augment → Generate 在本次实验中,它对应: security_安全事件上报速查.m

阅读 10 点赞 0 评论 0

一句话记住 RAG

RAG 的全称是 Retrieval-Augmented Generation,中文通常称为“检索增强生成”。

Load → Chunk → Embed → Retrieve → Augment → Generate

在本次实验中,它对应:

security_安全事件上报速查.md
    → 读取为字符串
    → 切成 4 个 Chunk
    → bge-m3 转成 1024 维向量
    → 余弦相似度排序、Top-K、证据门槛
    → 用户问题 + 检索证据 + 来源组成 Prompt
    → qwen3:8b 依据证据生成
    → 带来源回答,或在证据不足时拒答

步骤

当前输入

当前输出

负责解决的问题

Load

Markdown 文件

一个 Python str

程序怎样取得原文

Chunk

完整原文

4 个较小文本块

怎样把长文拆成可检索单位

Embed

问题和 Chunk

1024 维向量

怎样表示文本语义

Retrieve

问题向量和 Chunk 向量

相似度排名、Top-K

哪些证据最可能相关

Augment

问题、证据、来源、规则

完整 Prompt

怎样把外部知识交给 LLM

Generate

Prompt

自然语言回答

怎样依据证据组织答案

两个模型各自负责什么

bge-m3:负责生成语义向量

bge-m3 是 Embedding 模型。它把文本转换成向量,让程序可以比较两段文字在语义上是否接近。

它不负责:

  • 直接回答用户问题;

  • 自动打开知识库文件;

  • 把知识库永久记到模型参数中;

  • 输出一段自然语言解释。

qwen3:8b:负责读证据并组织回答

qwen3:8b 是生成模型。它接收我们组装好的 Prompt,再把问题和已检索证据整理成自然语言回答。

它不负责:

  • 在当前脚本里主动搜索文件;

  • 计算 Chunk 相似度;

  • 决定知识库中哪一段排名第一。

所以当前程序的分工是:

bge-m3 生成向量 → Python 计算相似度、排序和把关 → qwen3:8b 依据证据回答

为什么先只用一个文件

正式盲盒目录里有 25 个文件和多种格式。如果一开始同时处理 PDF、Word、Excel、旧 Office 文件、版本冲突和脏数据,一旦结果不对,很难判断问题来自解析、清洗、分块、检索还是生成。

因此第一阶段只使用:

data/raw/00_所有待分类原始盲盒/security_安全事件上报速查.md

这个文件只有 236 个字符,结构清楚,但已经足够跑通完整 RAG 链路。

第 1 次迭代:Load

对应脚本:learning/minimal_rag.py

from pathlib import Path
​
file_path = Path(
    r"F:\AI\AgentMake\Projects\EnterpriseKnowledgeAgent"
    r"\data\raw\00_所有待分类原始盲盒"
    r"\security_安全事件上报速查.md"
)
​
text = file_path.read_text(encoding="utf-8")
​
print("数据类型:", type(text))
print("字符数量:", len(text))

实际结果:

数据类型: <class 'str'>
字符数量: 236

这里已经完成了 Load:磁盘上的文件变成了 Python 可以继续处理的字符串。

需要记住:

  • 文件路径和编码属于 Load 层;

  • 文件能打开,不代表内容已经适合检索;

  • 正式多文件系统还要保留文件名、版本、页码、章节等元数据。

第 2 次迭代:Chunk

chunks = [
    part.strip()
    for part in text.split("\n\n")
    if part.strip()
]
​
print("分块数量:", len(chunks))
​
for index, chunk in enumerate(chunks, start=1):
    print(f"\n--- Chunk {index} ---")
    print(chunk)

实际得到 4 个 Chunk:

Chunk

内容

1

文档标题

2

状态、文件编号和生效日期

3

完整的四步安全事件处置流程

4

虚构地址与紧急服务说明

这里按空行分块恰好保留了四步流程的完整性,所以本次小实验可用。但这不代表 split("\n\n") 是生产级分块器。以后面对长段落、表格、跨页内容和多级标题时,还需要研究:

  • chunk_size

  • overlap

  • 标题和正文的结构关系;

  • 页码、章节、表格行等来源定位;

  • 一个 Chunk 是否保留了完整语义。

第 3 次迭代:手工关键词检索

最早的检索逻辑是:给每个 Chunk 计算命中了几个关键词。

question = "公司的电脑不见了该如何处理?"
keywords = ["设备"]

scored_chunks = []

for index, chunk in enumerate(chunks, start=1):
    score = sum(
        1
        for keyword in keywords
        if keyword in chunk
    )

    scored_chunks.append((score, index, chunk))

scored_chunks.sort(reverse=True)
best_score, best_index, best_chunk = scored_chunks[0]

它能找到 Chunk 3,但这次成功有一个关键问题:真正参与评分的是写死的 keywords,不是 question

也就是说,即使把问题改成“今天午饭吃什么”,只要仍然写着:

keywords = ["设备"]

它依然会命中同一个 Chunk。这不是可靠的问句检索,只是我们人工提前告诉程序答案里应该出现什么词。

第 4 次迭代:从问题自动生成二元词

为了让 question 真正参与检索,我们从问题和 Chunk 中提取连续的两个中文字符,再计算交集。

def chinese_bigrams(value):
    chinese_text = "".join(
        character
        for character in value
        if "\u4e00" <= character <= "\u9fff"
    )

    return {
        chinese_text[index:index + 2]
        for index in range(len(chinese_text) - 1)
    }

question_tokens = chinese_bigrams(question)
automatic_results = []

for index, chunk in enumerate(chunks, start=1):
    chunk_tokens = chinese_bigrams(chunk)
    matched_tokens = question_tokens & chunk_tokens
    score = len(matched_tokens)

    automatic_results.append((score, index, chunk))

它比手写关键词前进了一步,因为检索单位确实来自问题:

  • 当问题使用“设备丢失”时,可以通过共同字面片段“设备”找到 Chunk 3;

  • 当问题改写为“公司的电脑不见了该如何处理?”时,问题与原文的“设备”没有共同二元词,当前脚本会全部得 0 分并拒答。

所以二元词检索仍然只是字符重叠检索,不是语义理解。它不明白:

电脑不见了 ≈ 设备丢失

这个失败实验正是引入 Embedding 的原因。

第 5 次迭代:用 bge-m3 验证语义相似度

对应脚本:learning/embedding_demo.py

我们先不做完整 RAG,只验证 Embedding 是否能识别同义表达。

1. 准备三段文本

texts = [
    "设备丢失",
    "电脑不见了",
    "今天午饭吃什么",
]

2. 调用 Ollama 的 Embedding 接口

import json
from urllib.request import Request, urlopen

ollama_url = "http://127.0.0.1:11434/api/embed"

request_body = {
    "model": "bge-m3",
    "input": texts,
}

request = Request(
    url=ollama_url,
    data=json.dumps(
        request_body,
        ensure_ascii=False,
    ).encode("utf-8"),
    headers={
        "Content-Type": "application/json",
    },
    method="POST",
)

with urlopen(request, timeout=60) as response:
    response_body = json.load(response)

embeddings = response_body["embeddings"]

结果中有 3 个向量,每个向量都是 1024 维:

文本数量: 3
向量数量: 3
每个向量的维度: 1024

3. 计算余弦相似度

本质上有点像高中数学的求余弦值

1. 基础向量形式(高中数学课本标准公式)

设向量为 \vec{a}和 \vec{b},它们的夹角为 \theta 。

根据向量数量积公式:

ab=abcosθ\vec{a} \cdot \vec{b} = |\vec{a}| |\vec{b}| \cos \theta


移项变形得到余弦值 \cos \theta :

cosθ=abab\cos \theta = \frac{\vec{a} \cdot \vec{b}}{|\vec{a}| |\vec{b}|}



2. 二维坐标系形式(高中平面向量)

设平面向量坐标为:

a=(x1,y1)b=(x2,y2) \vec{a} = (x_1, y_1)和 \vec{b} = (x_2, y_2)

  • 向量点积:

  • \vec{a} \cdot \vec{b} = x_1 x_2 + y_1 y_2

  • 向量 \vec{a}的模长:

  • |\vec{a}| = \sqrt{x_1^2 + y_1^2}

  • 向量 \vec{b} 的模长:

  • |\vec{b}| = \sqrt{x_2^2 + y_2^2}

代入余弦公式:

cosθ=x1x2+y1y2x12+y12x22+y22\cos \theta = \frac{x_1 x_2 + y_1 y_2}{\sqrt{x_1^2 + y_1^2} \sqrt{x_2^2 + y_2^2}}


同理,三维空间坐标形式(高中空间向量)就是:

设空间向量坐标为 \vec{a} = (x_1, y_1, z_1)和\vec{b} = (x_2, y_2, z_2) :

$$
\cos \theta = \frac{x_1 x_2 + y_1 y_2 + z_1 z_2}{\sqrt{x_1^2 + y_1^2 + z_1^2} \sqrt{x_2^2 + y_2^2 + z_2^2}}
$$



4. n 维向量通用形式(AI 算法/代码计算公式)

在 AI embedding 中,向量维度增加到 n 维。

设 n 维向量 \vec{a} = (a_1, a_2, \dots, a_n)$ 和 $\vec{b} = (b_1, b_2, \dots, b_n) :

  • n 维点积: \vec{a} \cdot \vec{b} = \sum_{i=1}^{n} a_i b_i

  • n 维模长: |\vec{a}| = \sqrt{\sum_{i=1}^{n} a_i^2}, |\vec{b}| = \sqrt{\sum_{i=1}^{n} b_i^2}

余弦相似度计算公式:

$$
\text{Similarity} = \cos \theta = \frac{\sum_{i=1}^{n} a_i b_i}{\sqrt{\sum_{i=1}^{n} a_i^2} \sqrt{\sum_{i=1}^{n} b_i^2}}
$$


简易代码如下:

from math import sqrt


def cosine_similarity(left_vector, right_vector):
    dot_product = sum(
        left * right
        for left, right in zip(
            left_vector,
            right_vector,
            strict=True,
        )
    )

    left_length = sqrt(
        sum(value * value for value in left_vector)
    )
    right_length = sqrt(
        sum(value * value for value in right_vector)
    )

    return dot_product / (left_length * right_length)

通过Embedding模型计算出来的实际结果:

设备丢失 ↔ 电脑不见了        0.8017
设备丢失 ↔ 今天午饭吃什么    0.4334
电脑不见了 ↔ 今天午饭吃什么  0.4304

结论:字面不同但含义相近的“设备丢失”和“电脑不见了”明显更接近。这就是语义检索可以解决同义改写的基础。

注意:

  • 余弦相似度不是“答案正确率”,0.8017 也不是 80.17% 正确。分数只适合在当前模型、当前语料和当前处理方式下做相对比较。

  • 这个值主要是基于当前已有数据集做出的计算,比如当前设备丢失 ↔ 电脑不见了 0.8017 这对值计算出来的是最高的,如果加入一个“设备遗失,电脑丢失”,那么最高的一定会变成

    • 设备丢失 ↔ 设备遗失 0.9817(假设)

    • 设备丢失 ↔ 电脑丢失 0.8017(假设)

    • 所以同样的之前“最相似的0.8017”的Ranking排名也会下降

第 6 次迭代:Retrieve 和 Top-K

对应脚本:learning/vector_retrieval.py

1. 问题和 Chunk 使用同一个模型向量化

question = "公司的电脑不见了该如何处理?"

# 第一项是问题,后面四项是待检索的 Chunk。
texts = [question, *chunks]

把这 5 段文本一起发送给 bge-m3 后:

embeddings = response_body["embeddings"]

question_embedding = embeddings[0]
chunk_embeddings = embeddings[1:]

这里依赖一个简单对应关系:输入第 1 项是问题,所以返回第 1 个向量也是问题向量;后面的向量按顺序对应 4 个 Chunk。

2. 问题向量逐个比较 Chunk 向量

ranked_chunks = []

for index, (chunk, chunk_embedding) in enumerate(
    zip(chunks, chunk_embeddings, strict=True),
    start=1,
):
    similarity = cosine_similarity(
        question_embedding,
        chunk_embedding,
    )

    ranked_chunks.append(
        (similarity, index, chunk)
    )

ranked_chunks.sort(
    key=lambda item: item[0],
    reverse=True,
)

实际相似度:

Chunk

相似度

排名

3

0.5823

1

1

0.4998

2

4

0.4389

3

2

0.3888

4

完整处置流程所在的 Chunk 3 排到了第一名。

3. 观察 Top-K

top_k = 2

for rank, (similarity, index, chunk) in enumerate(
    ranked_chunks[:top_k],
    start=1,
):
    print(
        f"\n排名 {rank}|Chunk {index}"
        f"|相似度 {similarity:.4f}"
    )
    print(chunk)

Top-K 的意思是:排序后取前 K 个候选证据。本实验设置 top_k = 2,用于观察前两名。

当前代码虽然打印了 Top-2,但 Augment 阶段实际只取 ranked_chunks[0],也就是 Top-1 的 Chunk 3。不要把当前实现误解为“Top-2 全部送给了 Qwen”。

以后可以再学习怎样去重、控制 Token,并把多个合格 Chunk 一起放进 Prompt。

第 7 次迭代:证据门槛和拒答

Top-K 永远能从候选列表中排出第一名,但“第一名”不等于“真的相关”。即使问年假,安全事件文档里也总会有一个分数最高的 Chunk。

因此在调用 Qwen 之前加入临时门槛:

best_similarity, best_index, best_chunk = ranked_chunks[0]

min_similarity = 0.55

if best_similarity < min_similarity:
    print(
        "\n根据当前知识库证据不足,"
        "无法可靠回答该问题。"
    )
    raise SystemExit(0)

两次实验结果:

问题

Top-1

门槛

结果

公司的电脑不见了该如何处理?

0.5823

0.55

放行,调用 Qwen

公司的年假有多少天?

0.3673

0.55

拦截,不调用 Qwen

这里有两个重要结论:

  1. 拒答应尽量在检索层先把关,不能只依赖 LLM 看见无关证据后自觉说不知道。

  2. 0.55 只是教学值。它只来自一个正例和一个负例,不能复制到其他模型、语料或项目中当生产阈值。以后必须用固定评测集校准。

第 8 次迭代:Augment

Augment 的意思不是训练模型,而是把刚检索到的外部证据加入 Prompt。

System Prompt

system_prompt = """
你是企业知识库问答助手。
只能依据用户提供的证据回答,不得补充证据之外的事实。
如果证据不足,请明确回答“根据当前证据无法回答”。
回答必须标注证据来源。
""".strip()

User Prompt

user_prompt = f"""
用户问题:
{question}

检索证据:
[来源:Chunk {best_index}]
{best_chunk}

请根据检索证据回答用户问题。
""".strip()

此时 Qwen 收到的不是整个文件夹,而是我们明确交给它的:

回答规则 + 用户问题 + Top-1 证据 + 来源标签

这一步中的“检索增强”,就是 RAG 名字里的 Augmented

第 9 次迭代:Generate

最后调用 Ollama 的聊天接口:

chat_url = "http://127.0.0.1:11434/api/chat"

chat_request_body = {
    "model": "qwen3:8b",
    "stream": False,
    "messages": [
        {
            "role": "system",
            "content": system_prompt,
        },
        {
            "role": "user",
            "content": user_prompt,
        },
    ],
    "options": {
        "temperature": 0,
    },
}

实际调用信息证明这次确实使用了 qwen3:8b

实际模型: qwen3:8b
是否完成: True
结束原因: stop

最终回答覆盖了检索证据中的四个步骤:

  1. 断开可疑网络或停止操作,但不要关机、重装或删除日志;

  2. 保留邮件、聊天、截图、时间和设备编号等证据;

  3. 15 分钟内联系安全团队邮箱;

  4. 按安全团队指示隔离设备、修改凭证或通知相关人员。

回答中标注了 Chunk 3 来源,至此完成了最小闭环。

最终完整代码

下面是当前 vector_retrieval.py 的等价整理版。它完整串起:

Load → Chunk → Embed → Retrieve → 门槛 → Augment → Generate
import json
from math import sqrt
from pathlib import Path
from urllib.request import Request, urlopen


embed_url = "http://127.0.0.1:11434/api/embed"
chat_url = "http://127.0.0.1:11434/api/chat"

file_path = Path(
    r"F:\AI\AgentMake\Projects\EnterpriseKnowledgeAgent"
    r"\data\raw\00_所有待分类原始盲盒"
    r"\security_安全事件上报速查.md"
)


def cosine_similarity(left_vector, right_vector):
    dot_product = sum(
        left * right
        for left, right in zip(
            left_vector,
            right_vector,
            strict=True,
        )
    )
    left_length = sqrt(
        sum(value * value for value in left_vector)
    )
    right_length = sqrt(
        sum(value * value for value in right_vector)
    )
    return dot_product / (left_length * right_length)


# 1. Load
document_text = file_path.read_text(encoding="utf-8")

# 2. Chunk
chunks = [
    part.strip()
    for part in document_text.split("\n\n")
    if part.strip()
]

question = "公司的电脑不见了该如何处理?"

# 3. Embed:第一个输入是问题,后面是待检索 Chunk。
texts = [question, *chunks]

embed_request_body = {
    "model": "bge-m3",
    "input": texts,
}

embed_request = Request(
    url=embed_url,
    data=json.dumps(
        embed_request_body,
        ensure_ascii=False,
    ).encode("utf-8"),
    headers={
        "Content-Type": "application/json",
    },
    method="POST",
)

with urlopen(embed_request, timeout=60) as response:
    embed_response_body = json.load(response)

embeddings = embed_response_body["embeddings"]
question_embedding = embeddings[0]
chunk_embeddings = embeddings[1:]

# 4. Retrieve:计算相似度并排序。
ranked_chunks = []

for index, (chunk, chunk_embedding) in enumerate(
    zip(chunks, chunk_embeddings, strict=True),
    start=1,
):
    similarity = cosine_similarity(
        question_embedding,
        chunk_embedding,
    )
    ranked_chunks.append((similarity, index, chunk))

ranked_chunks.sort(
    key=lambda item: item[0],
    reverse=True,
)

top_k = 2

print(f"问题:{question}")
print(f"Top-{top_k} 检索结果:")

for rank, (similarity, index, chunk) in enumerate(
    ranked_chunks[:top_k],
    start=1,
):
    print(
        f"\n排名 {rank}|Chunk {index}"
        f"|相似度 {similarity:.4f}"
    )
    print(chunk)

# 5. 证据门槛:不相关问题不进入生成阶段。
best_similarity, best_index, best_chunk = ranked_chunks[0]
min_similarity = 0.55

print("\n最高相似度:", f"{best_similarity:.4f}")
print("证据门槛:", f"{min_similarity:.4f}")

if best_similarity < min_similarity:
    print(
        "\n根据当前知识库证据不足,"
        "无法可靠回答该问题。"
    )
    raise SystemExit(0)

# 6. Augment:把规则、问题、证据和来源组成 Prompt。
system_prompt = """
你是企业知识库问答助手。
只能依据用户提供的证据回答,不得补充证据之外的事实。
如果证据不足,请明确回答“根据当前证据无法回答”。
回答必须标注证据来源。
""".strip()

user_prompt = f"""
用户问题:
{question}

检索证据:
[来源:Chunk {best_index}]
{best_chunk}

请根据检索证据回答用户问题。
""".strip()

# 7. Generate:调用 qwen3:8b 依据证据回答。
chat_request_body = {
    "model": "qwen3:8b",
    "stream": False,
    "messages": [
        {
            "role": "system",
            "content": system_prompt,
        },
        {
            "role": "user",
            "content": user_prompt,
        },
    ],
    "options": {
        "temperature": 0,
    },
}

chat_request = Request(
    url=chat_url,
    data=json.dumps(
        chat_request_body,
        ensure_ascii=False,
    ).encode("utf-8"),
    headers={
        "Content-Type": "application/json",
    },
    method="POST",
)

with urlopen(chat_request, timeout=120) as response:
    chat_response_body = json.load(response)

print("\n=== 模型调用信息 ===")
print("实际模型:", chat_response_body.get("model"))
print("是否完成:", chat_response_body.get("done"))
print("结束原因:", chat_response_body.get("done_reason"))

answer = chat_response_body["message"]["content"].strip()

print("\n=== RAG 最终回答 ===")
print(answer)

怎样运行这三个实验

前提:Ollama 已运行,并且本机已经有 bge-m3qwen3:8b

在当前项目的根目录依次运行:

python .\learning\minimal_rag.py
python .\learning\embedding_demo.py
python .\learning\vector_retrieval.py

三个文件的职责不要混淆:

文件

学习目标

是否完整 RAG

minimal_rag.py

Load、Chunk,以及词面检索为什么不够

embedding_demo.py

单独证明 Embedding 能表达语义相近

vector_retrieval.py

串起检索、门槛、Prompt 和生成

是,单文件最小版

01_inventory.py

下一阶段盘点 25 个原始文件

尚未开始

以后每次做 RAG 都按这个顺序检查

1. Load

  • 文件是否真的读到了?

  • 编码和解析器是否正确?

  • 是否保留文件名、版本、页码、章节等来源元数据?

  • 原始文件是否保持不变?

2. Chunk

  • Chunk 数量是否合理?

  • 关键事实是否被切断?

  • Chunk 是否过长、过短或重复过多?

  • 来源定位是否跟着 Chunk 一起保存?

3. Embed

  • 文档和问题是否使用同一个 Embedding 模型?

  • 向量数量是否与文本数量一致?

  • 向量维度是否一致?

  • 模型是否适合当前语言和领域?

4. Retrieve

  • Top-K 中是否出现了正确证据?

  • 正确证据排名第几,分数是多少?

  • 是否打印了候选内容、来源和分数?

  • 不可回答问题是否也被强行匹配?

  • 阈值是否来自评测,而不是凭感觉写死?

5. Augment

  • Prompt 是否包含用户原问题?

  • 是否只放入检索得到的证据?

  • 是否包含清楚的来源标记?

  • 是否明确要求证据不足时拒答?

  • 多个 Chunk 是否需要去重、排序和控制 Token?

6. Generate

  • 实际调用的是哪个模型?

  • 回答是否只使用证据中的事实?

  • 引用能否定位回原文?

  • 没有证据时是否拒答?

  • 问题出在检索,还是出在模型没有正确使用已召回证据?

当前最小版和正式 RAG 的区别

当前脚本为了看清原理,把全部操作放在一次运行里:

每次提问 → 重新读取文件 → 重新切块 → 重新计算全部向量 → 回答

正式 RAG 通常拆成两条链路。

离线建库

文件 → Load → 清洗 → Chunk → 文档 Embed → 保存到向量索引

这条链路只在文件新增、更新或索引重建时运行。

在线问答

用户问题 → 问题 Embed → Retrieve Top-K → 门槛/重排
         → Augment Prompt → Generate → 回答和引用

这样提问时不需要重新计算所有文档向量。

当前边界

这次已经完成:

  • 单 Markdown 的 Load;

  • 按空行切成 4 个 Chunk;

  • 从手写关键词、二元词检索迭代到语义向量检索;

  • bge-m3 的 1024 维 Embedding;

  • 余弦相似度、排序和 Top-K;

  • 教学用证据门槛;

  • Prompt 增强;

  • qwen3:8b 生成;

  • 带 Chunk 来源回答;

  • 不可回答问题在生成前拒答。

这次尚未完成:

  • 25 个原始文件的盘点与分类;

  • PDF、Word、PPT、Excel 等多格式解析;

  • 由你亲手完成的数据清洗;

  • 更合理的分块与 overlap;

  • 文件名、页码、章节、表格行和版本级引用;

  • 向量持久化和向量数据库;

  • 固定评测集与正式阈值校准;

  • 混合检索、Reranker、权限过滤;

  • Agent 路由和业务流程。

下一阶段从空文件 learning/01_inventory.py 开始,只读盘点盲盒中的 25 个文件。数据分类和清洗判断由你亲手完成,教程和助手只负责逐步解释、出题与复核。

最后再背一遍

Load → Chunk → Embed → Retrieve → Augment → Generate
文件 → Chunk → bge-m3 → Top-K/门槛 → Prompt → qwen3:8b → 带来源回答

以后无论换成 LangChain、LlamaIndex、Qdrant、Dify,还是加入 Reranker 和 Agent,这条主链路都不会消失。框架只是替你封装其中的步骤;判断每一步输入、输出和效果,仍然是你真正掌握 RAG 的标准。

评论