从数据流动理解每一行代码

这页不只解释”怎么写”,更是把”文本怎么流到向量、chunk 怎么影响召回、Prompt 为什么这样组装”拆成可跟踪的数据流。

标签:V1 最小闭环 | V2 分块策略 | 数据流 | 实现理解

这页负责解释数据流和实现,不负责统一定义概念。 如果你还没建立 embedding / 余弦的直觉,先回到概念手册。


Page Summary

  • V1 的核心:把”文本 → 向量 → 排序 → Prompt 注入”这条最小闭环看清楚。
  • V2 的核心:理解 chunk_size 和 overlap 如何影响检索命中的完整性,而不是只看代码写法。
  • 必须记住的结论:RAG 质量的上限先由检索决定,而检索上限先由分块质量决定。

对应代码code/01_v1_最小RAG循环.pycode/02_v2_文档分块策略.py


V1 · 最小 RAG 循环

“三步走完,RAG 的本质就在这里”

涉及概念:Embedding · cosine_similarity · Top-K 检索 · Prompt 注入上下文 · 有/无 RAG 对比


STEP 01 · 文档 → Embedding 向量

把文本变成可以计算”距离”的数字

LLM 不能直接比较两段文字”意思有多像”,但计算机可以计算两个向量的夹角。Embedding 就是把文本压缩成一个高维向量的过程——语义相近的文本,向量的方向会相近。text-embedding-3-small 输出 1536 维向量,每个维度捕捉语言的某种特征。

# 知识库:5 段文本(真实场景来自 PDF/Wiki/手册)
DOCUMENTS = [
    "RAG 在生成前检索外部文档,让回答有据可查,有效减少幻觉。",
    "向量数据库通过余弦相似度找到语义最接近的文档块。",
    "LangChain 封装了文档加载、分块、检索、生成的完整流程。",
    "Transformer 的核心是注意力机制,让模型动态关注输入。",
    "RAG 的瓶颈在检索而非生成:垃圾进,垃圾出。",
]
 
def embed(text: str) -> np.ndarray:
    """
    调用 OpenAI Embedding API。
    为什么用 API 而不是本地模型?
    → 快速验证概念。v4 会讨论模型选型和本地部署。
    """
    response = client.embeddings.create(
        input=text,
        model="text-embedding-3-small"  # 1536 维,中英文均可
    )
    return np.array(response.data[0].embedding)  # shape: (1536,)
 
# 为所有文档建立索引
doc_embeddings = np.array([embed(doc) for doc in DOCUMENTS])
# doc_embeddings.shape → (5, 1536)   [文档数 × 向量维度]

预期输出(节选)

STEP 1 / 为知识库建立 Embedding 索引
  模型: text-embedding-3-small  |  文档数: 5

  Doc[0] RAG 在生成前检索外部文档,让回...
         维度=1536  示例(前4维)=[ 0.02341 -0.01823  0.04512 -0.00834]
  Doc[1] 向量数据库通过余弦相似度找到语...
         维度=1536  示例(前4维)=[ 0.01902 -0.02341  0.03819  0.01023]
  ...

  → 知识库矩阵形状: (5, 1536)  [文档数 × 向量维度]

认知对齐: 5 段文字现在变成了一个 5×1536 的数字矩阵。每行是一篇文档的”语义指纹”。这个矩阵就是索引——真实系统中它会被存进向量数据库(v3 实现持久化)。


STEP 02 · 余弦相似度 + Top-K 检索

找到语义最相近的文档,不是字面匹配

余弦相似度公式(衡量两向量的语义距离):

cos(θ) = (a · b) / (|a| × |b|)

值域:1.0 = 完全相同方向(语义极相似)| 0.0 = 垂直(语义无关)| -1.0 = 相反方向

RAG 实践中,得分 > 0.75 通常才算真正相关。

def cosine_sim(a: np.ndarray, b: np.ndarray) -> float:
    return float(
        np.dot(a, b)                               # 点积
        / (np.linalg.norm(a) * np.linalg.norm(b))  # 除以两向量的模
    )
 
def retrieve(query: str, doc_embeddings: np.ndarray, top_k=2):
    q_emb = embed(query)                            # 问题也转成向量
    scores = [cosine_sim(q_emb, de) for de in doc_embeddings]
    ranked = sorted(
        enumerate(scores),
        key=lambda x: x[1], reverse=True
    )[:top_k]                                      # 取最高的 K 个
    return [{"text": DOCUMENTS[i], "score": s} for i, s in ranked]

预期输出

STEP 2 / 语义检索
  用户问题: RAG 系统里最关键的环节是什么?

  全库相似度得分(热力条):
  [0] 0.8234  ████████████████████████████████
       RAG 在生成前检索外部文档,让回答有据可查...
  [1] 0.7103  ████████████████████████
       向量数据库通过余弦相似度找到语义最接近的...
  [2] 0.6281  ████████████████████
       LangChain 封装了文档加载、分块、检索...
  [3] 0.5410  █████████████████
       Transformer 的核心是注意力机制...
  [4] 0.8567  ██████████████████████████████████
       RAG 的瓶颈在检索而非生成:垃圾进,垃圾出...

  → Top-2 命中文档:
     ✦ [0.8567]  RAG 的瓶颈在检索而非生成:垃圾进,垃圾出。
     ✦ [0.8234]  RAG 在生成前检索外部文档,让回答有据可查...

认知对齐: 注意 Doc[4](垃圾进垃圾出)得分最高,因为它和”最关键的环节”语义最接近。Doc[3](Transformer)得分最低。这是语义检索,不是关键词匹配——问题里没有”瓶颈”这个词,但语义一致。


STEP 03 · Prompt 注入上下文(RAG 的”增强”)

把检索结果塞进 Prompt,扩展 LLM 的视野

这里是 RAG 中 “A”(Augmented 增强)的字面含义:把检索到的真实资料拼进 Prompt,让 LLM 不需要从训练数据”回忆”,而是从当前上下文”阅读后回答”。

关键提示词设计:明确要求 LLM 只用参考资料,不要猜测。这不是银弹(LLM 仍可能忽视指令),但是控制幻觉的标准做法。

def build_prompt(query: str, contexts: list) -> str:
    ctx = "\n".join(
        f"  [{i+1}] {c['text']}"
        for i, c in enumerate(contexts)
    )
    return f"""你是一个严谨的知识助手。请仅根据以下参考资料回答问题。
若参考资料中没有相关信息,请明确说"资料中未提及",不要猜测。
 
参考资料:
{ctx}
 
问题:{query}
回答:"""
 
# 构建后的 Prompt 长这样:
# ┌──────────────────────────────────────────────┐
# │ 你是一个严谨的知识助手...(系统指令)           │
# │ 参考资料:                                    │
# │   [1] RAG 的瓶颈在检索而非生成...             │  ← 检索结果注入
# │   [2] RAG 在生成前检索外部文档...             │  ← 检索结果注入
# │ 问题:RAG 系统里最关键的环节是什么?            │  ← 用户原问题
# └──────────────────────────────────────────────┘

STEP 04 · 对比实验:有 RAG vs 无 RAG

这是最重要的一步——看见差异

# temperature=0:让输出稳定,便于对比,不是随机创作
def chat(prompt: str) -> str:
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        temperature=0,
    )
    return resp.choices[0].message.content.strip()
 
# 对比实验
ans_raw = chat(QUERY)              # ❌ 无 RAG,直接问
ans_rag = chat(prompt_rag)         # ✅ 有 RAG,带上下文

预期输出对比

❌ 无 RAG(直接问 LLM):
   RAG 系统中最关键的环节因不同视角而异。从数据质量来看,
   知识库的构建质量至关重要;从系统设计来看,检索策略和
   生成模型的协调同样关键;从用户体验来看...
   (泛泛而谈,没有明确结论)

✅ 有 RAG(基于检索内容):
   根据参考资料,RAG 系统中最关键的环节是检索而非生成。
   资料明确指出"垃圾进,垃圾出"——即使生成能力再强,
   如果召回了错误的文档,最终回答也无法保证质量。

认知对齐: 没有 RAG 时,LLM 用训练时学到的”泛知识”回答,倾向于面面俱到却不具体。有 RAG 后,LLM 被约束在检索到的资料里回答,答案更具体、有出处、可溯源。这就是 RAG 的核心价值。

V1 完整数据流

知识库文档 × 5
  ↓ embed() × 5(API 调用)
向量矩阵 [5 × 1536]            ← 这是"索引"
         ↑
用户问题: "RAG 最关键的环节是什么?"
  ↓ embed()(API 调用)
query 向量 [1536]
  ↓ cosine_sim() × 5 → 排序
Top-2 文档 ✓
  ↓ build_prompt() 注入上下文
增强 Prompt(问题 + 参考资料)
  ↓ chat()(API 调用)
最终回答 ✓

V2 · 文档加载与分块策略

“分块决定上限,垃圾进垃圾出”

涉及概念:chunk_size · chunk_overlap · 固定大小分块 · 句子感知分块 · 边界信息保护 · 分块对比实验

V1 的知识库只有 5 句话。真实场景:一份产品手册 100 页,一个 Wiki 几千篇,一个 PDF 几万字。你不能把整篇文档塞进 Prompt(Token 限制 + 注意力稀释)。

解决方案:把长文档切成小块(Chunk),每块独立存入向量库,检索时只取相关的几块。

分块是 RAG 的第一道工程决策,直接决定检索质量的上限。


策略 A · 固定大小分块(无 overlap)

最简单,但边界信息会丢失

def chunk_fixed_no_overlap(text: str, chunk_size: int = 150) -> list[str]:
    """
    按字符数切割,走到哪切到哪。
    
    问题:可能在句子中间切断。
    例:"推荐分块大小在 200 到 500 个字符之间,并设置重叠"
    → 块 A:...推荐分块大小在 200 到 500
    → 块 B:个字符之间,并设置重叠...
    两个块都是残缺信息。
    """
    text = text.strip()
    chunks, start = [], 0
    while start < len(text):
        chunk = text[start : start + chunk_size].strip()
        if chunk: chunks.append(chunk)
        start += chunk_size    # 每次直接跳 chunk_size,无回退
    return chunks

⚠ 当 chunk_size=120,文档里”推荐分块大小在200到500个字符,设置10%~20%重叠”这句关键信息极有可能被切成两半,任何单块都拿不到完整的工程建议。


策略 B · 固定大小 + Overlap(推荐生产基线)

overlap 是解决边界问题的最简单方案

Overlap 的核心机制: 下一个块的起点 = 上一个块的终点 - overlap。即相邻两块有 overlap 个字符是重叠的。这保证了边界附近的信息一定会完整出现在某个块里。

def chunk_fixed_with_overlap(text: str, chunk_size=200, overlap=40):
    text = text.strip()
    chunks, start = [], 0
    while start < len(text):
        end = start + chunk_size
        chunks.append(text[start:end].strip())
        next_start = end - overlap    # ← 核心:回退 overlap 个字符再开始下一块
        if next_start <= start: next_start = start + 1  # 防死循环
        start = next_start
        if start >= len(text): break
    return [c for c in chunks if len(c) > 20]
 
# 直觉理解 overlap=40 的效果:
#
#  位置:  0        200      360      520  ...
#          ├────────┤         ├────────┤
#  块 A:  [0      :200]
#  块 B:          [160     :360]   ← 从 200-40=160 开始
#  块 C:                  [320   :520]   ← 从 360-40=320 开始
#
#  160~200 这 40 个字符,同时出现在块 A 和块 B 里。
#  任何跨越 200 这个边界的句子,都会完整保存在某个块中。

overlap 效果验证

STEP 2 / Overlap 的作用:保护边界信息
  目标关键句:「推荐分块大小在200到500个字符之间,并设置约10%到20%的重叠区域」

  策略A(120字无overlap):关键句完整存在? ❌ 被切断了
  策略B(200字有overlap):关键句完整存在? ✅ 是
  策略C(句子感知):      关键句完整存在? ✅ 是

  策略A中被切断的情况:
  Chunk[X]: "...分块太大(如2000个字符):召回的块包含太多无关内容,LLM在
             长上下文中丢失焦点,答案精度降低。工程实践建议:推荐分块大小在200到500"
  Chunk[X+1]: "个字符之间,并设置约10%到20%的重叠区域(overlap)防止边界信息..."

  → 看到了吗?数字建议被切成两截,两块都是残缺信息。

策略 C · 句子感知分块

先分句,再合并,不在句子中间切断

def chunk_by_sentence(text: str, max_chars: int = 300) -> list[str]:
    # 按中文标点分句,保留标点(lookahead 不消耗字符)
    sentences = re.split(r'(?<=[。!?;\n])', text)
    sentences = [s.strip() for s in sentences if s.strip() and len(s) > 5]
 
    chunks, current = [], ""
    for sent in sentences:
        if len(current) + len(sent) <= max_chars:
            current += sent           # 累积句子,不超限就继续加
        else:
            if current: chunks.append(current.strip())
            current = sent             # 超限,保存旧块,开新块
    if current.strip():
        chunks.append(current.strip()) # 最后一块别忘了
    return [c for c in chunks if len(c) > 20]

关键实验 · 分块策略 → 检索质量 → 回答质量因果链

这是 v2 最重要的实验

问题: RAG 的分块策略有哪些工程建议?推荐用多大的分块和 overlap?

── 策略 A|固定 120 字,无 overlap(块太小)──
  [0.7821] "...推荐分块大小在200到500"      ← 句子被切断了
  [0.7640] "个字符之间,并设置约10%到20%..."  ← 上一句的后半段

  回答: 根据资料,分块大小建议在200到500...
        (注意:两个残缺的块凑在一起,LLM 可能拼出答案,
         但也可能混乱,且没有"overlap"建议的完整上下文)

── 策略 B|固定 200 字 + 40 字 overlap──
  [0.8234] "...推荐分块大小在200到500个字符之间,
             并设置约10%到20%的重叠区域(overlap)防止边界信息丢失。"

  回答: 根据参考资料,RAG 的工程建议:分块大小推荐 200~500 字符,
        并设置 chunk_size 10%~20% 的 overlap。

── 策略 C|句子感知,max=300──
  [0.8401] 完整的"分块策略"段落(句子完整,语义最好)

  回答: 根据资料,分块建议:①大小 200-500 字符;
        ②overlap 比例 10%-20%;③不同文档类型有不同最优策略。

认知对齐: 分块太小,关键信息被切断,检索命中的是残缺内容;合理分块,关键信息完整保留,回答更准确。这就是”分块决定上限”——不管你的检索算法多好、Reranker 多精准,如果 chunk 里根本没有完整的答案,LLM 就算再聪明也无法凭空生成正确答案。

三种策略对比

策略chunk_sizeoverlap语义完整性块数量推荐场景
A 固定无重叠任意0低(边界易断)最少快速原型
B 固定+overlap200~50010%~20%中(推荐基线)略多通用文档
C 句子感知max_chars高(不切句子)不固定技术规范/手册

V2 的边界在哪里 → V3 要解决什么

现在的问题(V2 的局限)

  chunks 只存在内存里
    ↓ 程序退出 → 全部消失
  每次运行都要重新计算所有 chunk 的 embedding
    ↓ 100 块文档 = 100 次 API 调用(慢 + 贵)
  没有持久化,不支持增量更新
    ↓ 添加一篇新文档 = 重算所有文档(不可接受)

V3 要解决:把 chunks + embeddings 存入 ChromaDB
  新文档只算新文档的 embedding → 写入 → 不影响旧数据
  程序重启 → 从磁盘加载 → 毫秒级恢复
  支持 metadata 过滤:按来源、日期、部门筛选文档

下一步与回查

  • 回到概念:如果你还在问”为什么文本能变成向量”,回到概念手册。
  • 进入评估:如果你已经能比较分块策略,下一步去工程手册 / 实验记录,把观察变成指标。
  • 快速回查:文档索引 · 知识地图