EP06. "RAG 基础:切块、向量化与检索流程"
🔒 登录后可标记已读- 文档太长时,把全部内容塞进一个 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、一段软件工程讲分布式系统的感染向量)走一遍完整流程,用简化的二维模型示范(第一个数字代表「跟医学的相关度」、第二个代表「跟软件工程的相关度」):
- 切块:把源文本切成段落
- 生成 Embedding:每块转成向量。示例中医学段落是
[0.97, 0.34],软件工程段落是[0.30, 0.97] - 归一化(Normalization):把向量缩放到长度 1.0,变成
[0.944, 0.331]和[0.295, 0.955],可以想象成把它们都摆在一个单位圆上 - 存进向量数据库:归一化后的 embedding 连同原文一起存进 vector database——专门优化过、用来存储/比对/搜索这种长数字列表的数据库
- 处理用户提问:用户的问题也用同一个模型转成 embedding。示例问「软件部门去年做了什么」,得到
[0.1, 0.89],归一化后是[0.112, 0.993] - 找出最相似的 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
官方文档:
- Claude with Amazon Bedrock(Claude Academy 课程)— https://academy.claude.com/courses/claude-with-amazon-bedrock