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。
余弦相似度:只比"方向"像不像 问题向量 文档A(夹角小→很像,≈0.95) 问题向量 文档B(夹角大→不像,≈0.1)
图注:夹角越小 → 方向越一致 → 余弦值越接近 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 年·财务区找",翻找范围一下小十倍,又快又准。
先过滤,再比相似度 全库(几万段) 各年份/各部门 都混在一起 元数据过滤 年份=2024 部门=财务 小范围里 比余弦取 top-k
图注:过滤把"翻找范围"缩小,既提速又避免捞回过期/跨部门的错内容。
# 入库时给每段挂标签(伪代码,各向量库写法略有不同)
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)"和"语义向量"两条渠道合起来,专治"语义像但关键词对不上"或"关键词对但语义跑偏"的漏检。检索准确率会再上一个台阶。
← Day 24 · 向量数据库 Day 26 · rerank & 混合检索 →