Day 25 / 共 68 天 · 阶段 4 RAG
检索:怎么从一堆向量里捞出最相关的几段
昨天(Day 24)你把文档切好、变成向量、存进了向量数据库——相当于书都上架了。今天讲怎么把该用的那几段书精准捞出来:把用户问题也变成向量、用余弦相似度比谁最像、取top-k 最像的几段,再用元数据过滤先圈定范围。检索是 RAG 里"答得准不准"的命门;明天(Day 26)我们再学怎么把捞回来的结果精排得更准。
📍 你在阶段 4(RAG D21-28)的位置
D23 embedding→
D24 向量库→
D25 检索→
D26 rerank&混合→
D27 评测→
D28 实战
💡 用一个类比兜住今天(今天全程沿用「进图书馆找书」的世界观)
RAG 检索 = 去一座超大图书馆找书。文档变向量并入库(昨天) = 每本书都被贴上一张"内容气味卡";query 也 embedding = 把你的问题也翻译成同一种"气味描述";余弦相似度 = 比两张卡的气味有多接近;top-k = 只抱走气味最像的前 k 本,而不是搬空整座馆;元数据过滤 = 先说"只在 2024 年·法律区找",缩小翻找范围。今天你学会怎么当一名"精准找书的图书管理员"。
L01
检索到底在解决什么
🤔 痛点大模型不知道你公司内部文档、也不知道昨天刚发生的事。直接把整本 500 页手册塞给它?上下文窗口装不下,还又贵又慢。
💡 本质检索(Retrieval)就是"回答之前,先从知识库里挑出跟这个问题最相关的一小撮内容",只把这几段喂给模型。像图书管理员:你问一句,他不搬来整座图书馆,只抽出最相关的三五页递给你。
RAG = Retrieval(检索,今天) + Augmented(增强,把检索到的塞进提示词) + Generation(生成,让模型据此作答)。今天专攻第一个字母 R。
📝 举个例子:一次检索长什么样
用户问:
公司年假有几天? → 系统不发给模型整本员工手册,而是从库里捞出最相关的 3 段,比如「第 4.2 条:入职满 1 年享 5 天年假……」→ 把这 3 段 + 原问题拼成提示词 → 模型照着这几段回答。答得准不准,一大半取决于这 3 段捞得对不对。L02
query 也要变成向量
🤔 痛点库里存的都是"向量"(一串数字),用户问的却是一句中文。数字和文字怎么比?根本不是一个东西。
💡 本质关键一步:用同一个 embedding 模型,把用户问题也翻译成向量。这样问题和文档就说"同一种语言"了,才能比谁像谁。就像每本书有"气味卡",你的问题也得先转成一张同格式的"气味描述",才能拿去比对。
👶 一定要用同一个模型存文档时用的是哪个 embedding 模型,查询时就必须用同一个。好比气味卡是"法语写的",你的问题也得翻成法语才能比——用两套不同模型,坐标系对不上,比出来全是乱的。这是新手最常踩的坑。
# 回忆 Day23:embedding = 把文字变成一串数字(向量)
# 存文档时用的模型,查询时必须一模一样
from sentence_transformers import SentenceTransformer # 一个开源 embedding 库
model = SentenceTransformer("all-MiniLM-L6-v2") # 存/查用同一个
query = "公司年假有几天?"
q_vec = model.encode(query) # 把问题翻译成向量(一串约 384 个数字)
print(len(q_vec)) # → 384 (这就是"问题的气味描述")
L03
余弦相似度:两个向量有多像
🤔 痛点问题变成向量、文档也是向量了,可"两串数字有多像"到底怎么算出来?
💡 本质最常用的量尺叫余弦相似度(cosine similarity):它只看两个向量"指的方向"像不像,不看长短。方向越一致,值越接近 1(超像);方向无关,接近 0;完全相反,接近 -1。像两个人举手指方向:都指北=1,一个指北一个指东=0。
图注:夹角越小 → 方向越一致 → 余弦值越接近 1 → 越相关。检索就是找"夹角最小"的那几段。
import numpy as np # numpy 是数学计算库
def cosine(a, b):
# 余弦 = 两向量点积 / (各自长度相乘);越接近 1 越像
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
q = np.array([1.0, 0.9]) # 假装这是"问题"向量(真实是几百维)
d1 = np.array([1.0, 0.8]) # 文档A:方向几乎一样
d2 = np.array([-1.0, 0.2]) # 文档B:方向差很多
print(round(cosine(q, d1), 2)) # → 0.99 很相关
print(round(cosine(q, d2), 2)) # → -0.5 不相关
👶 还有别的量尺吗有。欧氏距离(直线距离,越小越像)、点积(dot product)也常见。但文本检索里余弦相似度用得最多,因为它不受文本长短影响——你先记住余弦就够,其它以后遇到再说。
L04
top-k:只抱走最像的几本
🤔 痛点库里几万段,每段都能算出一个相似度。全塞给模型?窗口爆炸、又贵又慢,还会被无关内容带偏。
💡 本质top-k = 把所有段按相似度从高到低排,只取最像的前 k 段(k 常取 3~10)。像图书管理员只抱走最相关的 5 本,不搬空整座馆。k 太小怕漏、k 太大怕吵——是个要调的旋钮。
# 假设已经算出每段文档和问题的相似度
scored = [("年假第4.2条", 0.91), ("报销流程", 0.32),
("年假补充说明", 0.88), ("食堂菜单", 0.05)]
k = 2 # 只要最像的 2 段
top = sorted(scored, key=lambda x: x[1], reverse=True)[:k] # 按分数降序取前k
for text, score in top:
print(f"{score:.2f} {text}")
# → 0.91 年假第4.2条
# → 0.88 年假补充说明 (无关的报销、菜单被挡在门外)
👶 小白:k 到底设几?越大越保险吧?
👨🏫 老师:不是。k 太大,会把"沾点边但其实无关"的段也塞进去,模型反而被噪音带偏、还烧更多 token;k 太小,又可能漏掉关键那段。常见起点是 k=3~5,然后靠明天(Day 26)的精排和后天(Day 27)的评测来验证到底几最好。记住:检索不是"捞得越多越好",而是"捞得越准越好"。
L05
元数据过滤:先圈定范围再找
🤔 痛点用户问"2024 年的报销标准",库里却混着 2019~2024 各年版本。光比语义,很可能捞回一条 2019 的旧规定——语义很像,但答案是错的。
💡 本质入库时给每段挂上元数据(metadata)标签(年份、部门、来源文件…),检索时先按标签过滤,再在圈定的小范围里比相似度。像进图书馆先说"只在 2024 年·财务区找",翻找范围一下小十倍,又快又准。
图注:过滤把"翻找范围"缩小,既提速又避免捞回过期/跨部门的错内容。
# 入库时给每段挂标签(伪代码,各向量库写法略有不同)
db.add(text="报销上限 500 元", vector=v1,
metadata={"year": 2024, "dept": "财务"}) # 挂上年份和部门
# 检索时:先按元数据过滤,再在结果里比相似度
results = db.search(
query_vector=q_vec,
top_k=3,
filter={"year": 2024, "dept": "财务"} # 只在 2024 财务范围里找
)
📝 举个例子:过滤救了一次错答
问:
2024 报销上限? 不过滤时,语义最像的可能是 2019 版:上限 300 元(措辞更接近问题)。加上 filter={"year":2024} 后,2019 那条直接被排除,稳稳命中 2024 版:上限 500 元。很多"检索答错"其实靠一个元数据过滤就能修好。L06
串起来:一段能跑的极简检索
🤔 痛点概念都懂了,可"从问题到捞出答案段落"完整跑一遍,到底几行代码?
💡 本质把前面几步接起来就是完整检索:①问题 embedding → ②和每段算余弦 → ③排序取 top-k。下面这段不依赖任何数据库,纯手写,让你看清"检索内部到底在干嘛"。
import numpy as np
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("all-MiniLM-L6-v2")
# 知识库:几段小文本(真实项目里是切好的文档块)
docs = ["入职满1年享5天年假", "报销上限500元", "食堂周一供应咖喱"]
doc_vecs = model.encode(docs) # 一次性把所有段变成向量
def cosine(a, b):
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
def search(query, k=1):
q = model.encode(query) # ①问题也 embedding
scored = [(d, cosine(q, v)) for d, v in zip(docs, doc_vecs)] # ②逐段算余弦
return sorted(scored, key=lambda x: x[1], reverse=True)[:k] # ③排序取top-k
for text, score in search("年假多少天", k=1):
print(f"{score:.2f} {text}") # → 命中"入职满1年享5天年假"
👶 真实项目不用手写上面手写是为了让你看懂原理。真实项目里,②③交给向量数据库(FAISS/Qdrant…)一句
db.search() 就搞定,速度快几个数量级。但心里有这张"embedding→比余弦→取topk"的流程图,你调库时才不会瞎调。L07
今日小结 + 动手 10 分钟
🧠 今天你应该能回答
- RAG 里的"检索"在解决什么问题?为什么不直接把整本手册塞给模型?
- 为什么查询时 query 也要 embedding?为什么必须和存文档用同一个模型?
- 余弦相似度衡量什么?值接近 1 / 0 / -1 分别代表啥?
- top-k 是什么?k 太大太小各有什么坏处?
- 元数据过滤解决了什么单靠语义相似度解决不了的问题?
✋ 动手 10 分钟:手写一个迷你检索器
建 venv、装库,把 L06 跑通,再自己加两段文档、换几个问题试试命中对不对:
python -m venv .venv && source .venv/bin/activate # 建并进车间(Win:.venv\Scripts\Activate.ps1)
pip install sentence-transformers numpy # 装 embedding 库和数学库
# 新建 day25.py,把 L06 的代码粘进去,运行:
python day25.py
进阶挑战(选做):给 docs 配一个 metas 列表(每段一个 {"dept": ...}),在 search() 里先按部门过滤再比余弦——亲手实现一次元数据过滤。
明日预告 · Day 26:今天的 top-k 用余弦"粗略"捞回来,但排在前面的不一定最对。明天学rerank(精排)——先粗召回一批,再用更强的模型精挑细选;还有混合检索:把"关键词匹配(BM25)"和"语义向量"两条渠道合起来,专治"语义像但关键词对不上"或"关键词对但语义跑偏"的漏检。检索准确率会再上一个台阶。