AI TOOLS

EP06. "RAG 基础:切块、向量化与检索流程"

首页 › AI 工具 › Claude · Developer and Platform · Amazon Bedrock · EP06
约 18 分钟· #EP06#Claude#Amazon Bedrock
🔒 登录后可标记已读
  • 文档太长时,把全部内容塞进一个 prompt 会撞到 token 上限、拖慢速度、推高成本,Claude 处理超长 prompt 时表现还会变差——RAG(Retrieval Augmented Generation)就是先把文档切成小块,每次问题只喂最相关的那几块
  • 切块(chunking)是整条 RAG pipeline 里最关键的一步:切不好,问「工程部修了哪些 bug」可能捞出医学论文里刚好也出现「bug」这个字的段落,答非所问
  • Text embedding 把一段文字变成一串数字(Bedrock Titan 模型是 1024 个数字),语意相近的文字,embedding 数值也会相近——靠这个做「语意搜索」,不是关键字比对
  • 完整 RAG 流程:切块 → 生成 embedding → 存进向量数据库 → 把用户问题也转成 embedding → 用 cosine similarity 找出最像的块 → 把这些块塞进 prompt 给 Claude
  • 前置知识:EP01 的基本 API 调用;这篇笔记不需要提前懂线性代数,cosine similarity 的部分会从头讲

重点内容


为什么要用 RAG:文档太长,一个 prompt 塞不下

处理大文档最直接的做法是把全文抽出来、整段塞进一个 prompt,但这样做有明显问题:

  • token 容量有上限,太长的文档根本塞不进去
  • prompt 太长,Claude 处理起来效果会变差
  • prompt 越大,花费越高、处理时间越长
  • 信息量太大,反而拖累表现

RAG 的做法更聪明:先把文档切成小块(比如「战略展望」「风险因素」「资产负债表」这样的段落),用户提问时先判断他到底想找什么,再定位出最相关的那几块,只把这几块塞进 prompt。

📌 RAG 的好处:Claude 只专注在真正相关的材料上;能应付超大文档、甚至一整批文档;不限于单一文档,可以跨整个文档库搜索;prompt 变短了,速度更快、成本更低。

代价:多了一个预处理阶段(要先切块);需要一套检索机制去找出相关片段;捞出来的片段可能缺了完整上下文;切文字的方法不只一种,要自己评估选哪种。

什么时候值得上 RAG:处理的是大文档或文档库、单条 prompt 装不下、要追求响应速度或控制多次搜索的成本时。


三种切块策略

策略做法优点缺点
Size-based(按大小)按固定字符数/字数切实作简单、对任何文档类型都可靠会把句子从中间切断、丢失前后文、相关内容被拆到不同块里
Structure-based(按结构)按标题、段落、章节这类文档结构切文档格式规整(比如带标题的 markdown)时效果很好,切分贴合文档本身的组织方式需要文档结构本身够稳定,纯文本或格式不统一的文档不适用
Semantic-based(按语意)用 NLP 技术把语意相关的句子分到同一组分组更贴近实际语意(课程未展开具体实作细节)

📌 加 overlap(重叠) 能缓解 size-based 切块丢上下文的问题:让相邻两块共享一部分内容,避免关键信息刚好卡在切割点上被拆散。


切块代码实作

按字符数切(最保险的兜底方案):

def chunk_by_char(text, chunk_size=150, chunk_overlap=20):
    chunks = []
    start_idx = 0

    while start_idx < len(text):
        end_idx = min(start_idx + chunk_size, len(text))
        chunk_text = text[start_idx:end_idx]
        chunks.append(chunk_text)

        start_idx = (
            end_idx - chunk_overlap if end_idx < len(text) else len(text)
        )

    return chunks

按句子切(适合散文类内容):

def chunk_by_sentence(text, max_sentences_per_chunk=5, overlap_sentences=1):
    sentences = re.split(r"(?<=[.!?])\s+", text)
    chunks = []
    start_idx = 0

    while start_idx < len(sentences):
        end_idx = min(start_idx + max_sentences_per_chunk, len(sentences))
        current_chunk = sentences[start_idx:end_idx]
        chunks.append(" ".join(current_chunk))

        start_idx += max_sentences_per_chunk - overlap_sentences

        if start_idx < 0:
            start_idx = 0

    return chunks

按章节切(文档结构规整时效果最好):

def chunk_by_section(document_text):
    pattern = r"\n## "
    return re.split(pattern, document_text)

方法怎么选

场景建议策略
用户上传、格式没保证的文档Character-based——最安全的兜底选项
格式规整的内部文档(有清楚标题结构)Section-based——效果最好
一般散文类内容Sentence-based——上下文和语意平衡得不错,但代码/技术文档里意外出现的句号会让它切错

💡 切块常常是个迭代过程——用实际文档跑一遍、看 RAG 系统检索出来的结果好不好,再回头调整策略和参数。


Text Embeddings:把文字变成一串数字

切完块之后,下一步是从一堆块里找出跟用户问题最相关的那几块——这本质是个搜索问题,而且「相关」不能只靠关键字比对,要真正理解问题和每个块在语意上讲的是什么。

主流做法:语意搜索(semantic search),靠 text embedding 实现。

Embedding 把一段文字的语意内容转成数字形式:

  • 把文字丢进一个 embedding 模型
  • 拿回一长串数字(Bedrock 的 Titan 模型通常是 1024 个)
  • 每个数字可以理解成对某种「文字特质」的打分
  • 数字范围在 -1 到 +1 之间

💡 这些数字具体测的是什么维度,人没办法知道——可能某个数字在测「文字读起来快不快乐」、另一个在测「跟海洋有没有关系」,但这些模式是 embedding 模型自己在训练过程中学出来的,不是人为设计、也无法直接解读。关键在于:语意相近的文字,embedding 数值也会相近。


用 Bedrock Titan 生成 Embedding

def generate_embedding(
    text,
    embedding_model_id="amazon.titan-embed-text-v2:0",
    dimensions=1024,
    normalize=True,
):
    request_body = {
        "inputText": text,
        "dimensions": dimensions,
        "normalize": normalize,
    }

    request_json = json.dumps(request_body)
    response = client.invoke_model(
        modelId=embedding_model_id,
        body=request_json,
        accept="application/json",
        contentType="application/json",
    )

    response_body = json.loads(response.get("body").read())
    return response_body["embedding"]

📌 这里用的是 client.invoke_model(),不是前面几课的 client.converse()——Embedding 模型走的是 Bedrock 另一条调用路径。

⚠️ 用 Titan embedding 模型可能需要先在 AWS Bedrock 控制台申请权限;如果 v2 版本暂时申请不到,v1 版本拿来学习也够用。


完整 RAG 流程六步

课程用一份两段落的示例文档(一段医学研究讲 XDR-47、一段软件工程讲分布式系统的感染向量)走一遍完整流程,用简化的二维模型示范(第一个数字代表「跟医学的相关度」、第二个代表「跟软件工程的相关度」):

  1. 切块:把源文本切成段落
  2. 生成 Embedding:每块转成向量。示例中医学段落是 [0.97, 0.34],软件工程段落是 [0.30, 0.97]
  3. 归一化(Normalization):把向量缩放到长度 1.0,变成 [0.944, 0.331] 和 [0.295, 0.955],可以想象成把它们都摆在一个单位圆上
  4. 存进向量数据库:归一化后的 embedding 连同原文一起存进 vector database——专门优化过、用来存储/比对/搜索这种长数字列表的数据库
  5. 处理用户提问:用户的问题也用同一个模型转成 embedding。示例问「软件部门去年做了什么」,得到 [0.1, 0.89],归一化后是 [0.112, 0.993]
  6. 找出最相似的 Embedding:数据库用 cosine similarity(余弦相似度) 找出最相似的存量向量

Cosine similarity 怎么算:结果落在 -1 到 1 之间,接近 1 代表高度相似,接近 0 代表两个向量互相垂直(不相关)。公式是两个向量的点积除以各自长度的乘积:

cos(a) = (A · B) / (||A|| · ||B||)

示例结果:用户问题跟软件工程段落的 cosine similarity 是 0.983,跟医学研究段落只有 0.398——明显该捞软件工程那一段。

💡 Cosine distance(余弦距离) = 1 − cosine similarity,这样数值越接近 0 就代表越相似,有时候比相似度本身更直觉。

最后一步:把用户问题和检索到的相关文本块拼在一起,组成一个带上下文的 prompt,交给 Claude 生成最终答案。


实作:VectorIndex + 五步流程

课程提供一个从零搭的 VectorIndex 类,负责存储 embedding、做相似度搜索——一个最简版的「向量数据库」。

第一步:按章节切块

with open("./report.md", "r") as f:
    text = f.read()

chunks = chunk_by_section(text)

第二步:给每块生成 Embedding

embeddings = [generate_embedding(chunk) for chunk in chunks]

第三步:连同原文一起存进向量数据库

store = VectorIndex()

for embedding, chunk in zip(embeddings, chunks):
    store.add_vector(embedding, {"content": chunk})

📌 一定要把原文跟 embedding 一起存——只存一堆数字回来没用,没有对应的原文根本没办法拿去生成回答。

第四步:把用户问题转成 Embedding

user_embedding = generate_embedding("What did the software engineering dept do last year?")

第五步:搜索、拿出最相关的几块

results = store.search(user_embedding, 2)

for doc, distance in results:
    print(distance, "\n", doc["content"][0:200], "\n")

结果按 cosine distance 排序——数值越低代表越相似。示例里「软件工程」段落距离 0.71(最相关),「研究方法」段落是 0.72,紧追在后。

常见错误

  • ❌ 文档不管三七二十一整段塞进一个 prompt——大文档一定会撞 token 上限、拖慢速度、推高成本,该先切块
  • ❌ 只存 embedding 数字、不连原文一起存——检索到的只是一堆数字,没办法直接拿去回答用户问题
  • ❌ 不管文档类型都用同一种切块策略——格式不稳定的文档硬套 section-based 反而切不准,该退回 character-based 兜底
  • ❌ 把 cosine similarity 和 cosine distance 搞反——similarity 越接近 1 越相似,distance 越接近 0 越相似,两者刚好方向相反

Sources

官方文档:

  1. Claude with Amazon Bedrock(Claude Academy 课程)— https://academy.claude.com/courses/claude-with-amazon-bedrock