查看全课大纲(第 64 / 69 节)
- 1.NLP 的研究领域、应用与学习路径
- 2.自然语言的基本术语
- 3.信息论基础:信息熵与交叉熵
- 4.语言的进化与语义变迁
- 5.主观概率、客观概率与条件概率
- 6.贝叶斯原理与推理
- 7.随机变量:二项式、期望方差与联合概率
- 8.常见概率分布
- 9.辛普森悖论与贝叶斯解题实例
- 10.语言模型:语言概率与词袋模型
- 11.n-gram 语言模型(二元 / 三元 / N-Gram)
- 12.语言模型评价:交叉熵、困惑度与插值
- 13.朴素贝叶斯文本分类:垃圾邮件
- 14.拼写检查
- 15.中文分词:构词法、最大匹配、最优路径与 Jieba
- 16.词表示问题与语义相似度
- 17.TF-IDF 权重处理
- 18.One-Hot 表达与词表示的局限
- 19.Word2Vec 核心思想
- 20.Word2Vec 训练过程
- 21.Skip-gram 模型详解
- 22.词性标注任务与标注方法(规则 + N-Gram)
- 23.EM 算法与混合模型
- 24.高斯混合模型 GMM
- 25.从混合模型到隐马尔可夫模型
- 26.隐马尔可夫模型(HMM)推导与维特比解码
- 27.线性链条件随机场 CRF
- 28.贝叶斯网络与图模型
- 29.隐马尔可夫链与 HMM 推导
- 30.PLSA 主题模型
- 31.LSI 与 SVD 向量表达
- 32.LDA 与狄利克雷分布
- 33.主题模型实验复现(困惑度 / 分类 / 相似度 / 延展)
- 34.上下文无关文法(CFG)与句法分析
- 35.概率上下文无关文法 PCFG
- 36.统计机器翻译:词对齐与翻译模型
- 37.统计机器翻译中的对齐模型与参数估计
- 38.从统计机器翻译到神经机器翻译
- 39.神经元与反向传播速览(指向深度学习课)
- 40.CNN 原理及其在 NLP 中的应用
- 41.循环神经网络的基本原理与 Elman / Jordan 网络
- 42.LSTM:长短期记忆网络的核心思想与分步实现
- 43.Encoder-Decoder 框架
- 44.Seq2Seq 与早期注意力机制
- 45.注意力机制的背景与动机
- 46.注意力模型实现原理与匹配函数
- 47.记忆网络:组成与计算
- 48.注意力机制的应用与历史延展
- 49.Transformer 结构:Encoder-Decoder 栈与 Self-Attention
- 50.Multi-Head Attention、FFNN 与位置编码
- 51.层归一化、损失函数与 Universal Transformer 延展
- 52.预训练范式:从 ELMo 到 BERT
- 53.BERT 与掩码语言模型
- 54.BERT 下游任务微调与 GLUE 口径
- 55.GPT 与自回归语言建模
- 56.GPT-1 到 GPT-4 的演进
- 57.指令微调与 RLHF
- 58.分词器:BPE、WordPiece 与中文词表
- 59.上下文窗口与长文本处理
- 60.能力边界、成本与适用场景
- 61.文本分类实战:从朴素贝叶斯到逻辑回归
- 62.命名实体识别实战
- 63.文本生成与摘要实战
- 64.语义检索与向量库实战
- 65.RAG 知识库问答
- 66.NLP 评估指标:BLEU / ROUGE / BERTScore 与人工评估
- 67.幻觉与事实性问题
- 68.文本与知识结合(知识图谱压缩)
- 69.跨媒体信息检索与未来展望
语义检索与向量库实战
小象实战讲义 · 自然语言处理
欢迎来到《自然语言处理》课程的第11章《应用与实战》。在前面的章节中,我们系统学习了从词表示、序列标注到预训练大模型等一系列核心技术。现在,我们将进入实战环节,学习如何将这些技术应用于解决实际问题。本节《语义检索与向量库实战》将聚焦于RAG(检索增强生成)系统中的关键一环——检索层。学完本节,你将能够理解并实现一个基础的语义检索系统,掌握从文档处理、向量化到相似度计算与排名的完整流程,并深刻理解其中常见的陷阱与优化方向。
💡 核心导读
本节你将学到:
- 稀疏检索与稠密检索:理解TF-IDF等字面匹配方法与向量语义检索的根本区别,以及工程中混合检索的常见策略。
- 归一化陷阱:通过一个精心设计的微例子,直观感受为什么在计算余弦相似度前必须对向量进行L2归一化,否则结果会被文档长度“绑架”。
- 分块策略的影响:了解如何根据语义边界对文档进行分块,以及分块大小如何直接影响召回质量,并通过量化统计演示其影响。
- 检索质量评估:学习如何用Recall@k、MRR等指标评估检索系统,以及在缺乏标注数据时如何进行有效的人工抽检。
- 索引与更新机制:了解向量数据库选型的核心考量(过滤能力、召回率、延迟),以及全量重建与增量更新的取舍。
一、检索系统:从原理到微例实战
检索系统的核心任务是:给定一个用户查询(Query),从海量文档集合(Corpus)中找出最相关的若干文档(或文档片段)。其核心流程可以抽象为:文档处理 → 向量化 → 索引建立 → 查询向量化 → 相似度计算 → 结果排序。
本节我们将通过一个最小化的闭环示例,亲手实现两种检索方法,并揭示一个关键陷阱。
1.1 数据准备与分词
我们首先硬编码6条长度不等的短文档和1条查询,并采用最简单的按字符分词(对于中文,更复杂的做法是使用分词工具,但为保持示例自包含和确定性,我们使用字符级处理)。请注意,在实际项目中,分词是预处理的关键步骤,需要根据语言和领域选择合适的工具。
import numpy as np
# 硬编码6条文档和1条查询
docs = [
"自然语言处理是人工智能的重要分支。",
"深度学习模型需要大量数据进行训练。",
"检索增强生成(RAG)结合了检索与生成模型。",
"向量数据库用于高效存储和检索向量数据。",
"注意力机制让模型能够关注输入的不同部分。",
"预训练语言模型如BERT在多项任务上表现出色。"
]
query = "如何利用检索和生成模型进行问答?"
# 简单的字符级分词函数
def char_tokenize(text):
"""将中文字符串按字符分割为列表,忽略空格。"""
return [ch for ch in text if ch != ' ']
# 对文档和查询进行分词
tokenized_docs = [char_tokenize(doc) for doc in docs]
tokenized_query = char_tokenize(query)
# 构建词表(所有出现过的字符)
vocab = sorted(set([ch for doc in tokenized_docs for ch in doc] + tokenized_query))
word_to_idx = {word: idx for idx, word in enumerate(vocab)}
idx_to_word = {idx: word for word, idx in word_to_idx.items()}
print(f"词表(共{len(vocab)}个词):")
print(vocab)
print("\n文档与查询分词结果:")
for i, tokens in enumerate(tokenized_docs):
print(f"文档{i+1}: {docs[i]}")
print(f" 分词: {tokens}")
print(f"查询: {query}")
print(f" 分词: {tokenized_query}")词表(共89个词):
['A', 'B', 'E', 'G', 'R', 'T', '。', '上', '不', '与', '习', '了', '于', '人', '任', '何', '储', '入', '关', '出', '分', '利', '制', '力', '务', '合', '同', '向', '和', '在', '型', '增', '处', '多', '够', '大', '如', '存', '学', '工', '库', '度', '强', '意', '成', '据', '支', '效', '数', '是', '智', '机', '检', '模', '注', '深', '然', '现', '理', '生', '用', '的', '答', '索', '练', '结', '能', '自', '色', '行', '表', '要', '言', '让', '训', '语', '输', '进', '部', '重', '量', '问', '需', '项', '预', '高', '(', ')', '?']
文档与查询分词结果:
文档1: 自然语言处理是人工智能的重要分支。
分词: ['自', '然', '语', '言', '处', '理', '是', '人', '工', '智', '能', '的', '重', '要', '分', '支', '。']
文档2: 深度学习模型需要大量数据进行训练。
分词: ['深', '度', '学', '习', '模', '型', '需', '要', '大', '量', '数', '据', '进', '行', '训', '练', '。']
文档3: 检索增强生成(RAG)结合了检索与生成模型。
分词: ['检', '索', '增', '强', '生', '成', '(', 'R', 'A', 'G', ')', '结', '合', '了', '检', '索', '与', '生', '成', '模', '型', '。']
文档4: 向量数据库用于高效存储和检索向量数据。
分词: ['向', '量', '数', '据', '库', '用', '于', '高', '效', '存', '储', '和', '检', '索', '向', '量', '数', '据', '。']
文档5: 注意力机制让模型能够关注输入的不同部分。
分词: ['注', '意', '力', '机', '制', '让', '模', '型', '能', '够', '关', '注', '输', '入', '的', '不', '同', '部', '分', '。']
文档6: 预训练语言模型如BERT在多项任务上表现出色。
分词: ['预', '训', '练', '语', '言', '模', '型', '如', 'B', 'E', 'R', 'T', '在', '多', '项', '任', '务', '上', '表', '现', '出', '色', '。']
查询: 如何利用检索和生成模型进行问答?
分词: ['如', '何', '利', '用', '检', '索', '和', '生', '成', '模', '型', '进', '行', '问', '答', '?']1.2 路线A:TF-IDF + 余弦相似度(带归一化)
TF-IDF(词频-逆文档频率)是一种经典的稀疏向量表示方法。其思想是:一个词在当前文档中出现次数越多(TF越高),同时在所有文档中出现越少(IDF越高),则该词对该文档的代表性越强。
公式:
- 词频(TF):
- 逆文档频率(IDF):我们使用平滑形式 ,其中 是文档总数, 是包含词 的文档数。这种平滑可以避免除零错误,并对罕见词给予合理的权重。
- TF-IDF:
余弦相似度:用于衡量两个向量的方向相似性,计算公式为 。关键点:计算余弦相似度前,必须对向量做 L2 归一化(即让向量模长为 1),否则内积值会被向量长度(也就是文档长度)直接放大。
下面我们实现TF-IDF向量化,并进行带归一化的余弦相似度计算。
import numpy as np
import math
# 重新硬编码数据与分词(确保块自包含)
docs = [
"自然语言处理是人工智能的重要分支。",
"深度学习模型需要大量数据进行训练。",
"检索增强生成(RAG)结合了检索与生成模型。",
"向量数据库用于高效存储和检索向量数据。",
"注意力机制让模型能够关注输入的不同部分。",
"预训练语言模型如BERT在多项任务上表现出色。"
]
query = "如何利用检索和生成模型进行问答?"
def char_tokenize(text):
return [ch for ch in text if ch != ' ']
tokenized_docs = [char_tokenize(doc) for doc in docs]
tokenized_query = char_tokenize(query)
# 构建词表
all_tokens = []
for tokens in tokenized_docs:
all_tokens.extend(tokens)
all_tokens.extend(tokenized_query)
vocab = sorted(set(all_tokens))
word_to_idx = {word: idx for idx, word in enumerate(vocab)}
idx_to_word = {idx: word for word, idx in word_to_idx.items()}
N = len(tokenized_docs) # 文档总数
V = len(vocab) # 词表大小
# 计算文档频率 df(t)
df = np.zeros(V)
for tokens in tokenized_docs:
unique_tokens_in_doc = set(tokens)
for token in unique_tokens_in_doc:
if token in word_to_idx:
df[word_to_idx[token]] += 1
# 计算逆文档频率 idf(t),使用平滑公式: log((N+1)/(df+1)) + 1
idf = np.zeros(V)
for idx in range(V):
idf[idx] = math.log((N + 1) / (df[idx] + 1)) + 1
# 计算每个文档的 TF-IDF 向量
doc_vectors = np.zeros((N, V))
for i, tokens in enumerate(tokenized_docs):
total_terms = len(tokens)
term_freq = {}
for token in tokens:
term_freq[token] = term_freq.get(token, 0) + 1
for token, count in term_freq.items():
if token in word_to_idx:
idx = word_to_idx[token]
tf = count / total_terms
doc_vectors[i, idx] = tf * idf[idx]
# 计算查询的 TF-IDF 向量
query_vector = np.zeros(V)
total_terms_q = len(tokenized_query)
term_freq_q = {}
for token in tokenized_query:
term_freq_q[token] = term_freq_q.get(token, 0) + 1
for token, count in term_freq_q.items():
if token in word_to_idx:
idx = word_to_idx[token]
tf = count / total_terms_q
query_vector[idx] = tf * idf[idx]
# **关键步骤:L2 归一化**
def l2_normalize(vec):
norm = np.linalg.norm(vec)
return vec / norm if norm > 0 else vec
doc_vectors_norm = np.array([l2_normalize(vec) for vec in doc_vectors])
query_vector_norm = l2_normalize(query_vector)
# 计算余弦相似度 (归一化后,余弦相似度 = 点积)
cos_similarities = np.dot(doc_vectors_norm, query_vector_norm)
# 打印详细信息
print("=== TF-IDF 向量与模长(归一化前)===")
for i in range(N):
non_zero_indices = np.where(doc_vectors[i] > 0)[0]
print(f"文档{i+1} 非零项数: {len(non_zero_indices)}")
# 打印前5个非零项示例
sample_indices = non_zero_indices[:5]
sample_info = []
for idx in sample_indices:
sample_info.append(f"'{idx_to_word[idx]}': {doc_vectors[i, idx]:.4f}")
print(f" 示例项: {', '.join(sample_info)}")
print(f" 向量模长: {np.linalg.norm(doc_vectors[i]):.4f}")
print(f"查询向量模长(归一化前): {np.linalg.norm(query_vector):.4f}")
print("\n=== 余弦相似度排名(使用归一化向量)===")
# 按相似度降序排列
sorted_indices = np.argsort(cos_similarities)[::-1]
for rank, idx in enumerate(sorted_indices[:3], 1): # 取 top-3
print(f"第{rank}名: 文档{idx+1}, 相似度 = {cos_similarities[idx]:.4f}")
print(f" 原文: {docs[idx]}")=== TF-IDF 向量与模长(归一化前)===
文档1 非零项数: 17
示例项: '。': 0.0588, '人': 0.1325, '分': 0.1087, '处': 0.1325, '工': 0.1325
向量模长: 0.4999
文档2 非零项数: 17
示例项: '。': 0.0588, '习': 0.1325, '型': 0.0786, '大': 0.1325, '学': 0.1325
向量模长: 0.4766
文档3 非零项数: 18
示例项: 'A': 0.1024, 'G': 0.1024, 'R': 0.0840, '。': 0.0455, '与': 0.1024
向量模长: 0.5115
文档4 非零项数: 15
示例项: '。': 0.0526, '于': 0.1186, '储': 0.1186, '向': 0.2371, '和': 0.1186
向量模长: 0.5512
文档5 非零项数: 19
示例项: '。': 0.0500, '不': 0.1126, '入': 0.1126, '关': 0.1126, '分': 0.0924
向量模长: 0.4899
文档6 非零项数: 23
示例项: 'B': 0.0979, 'E': 0.0979, 'R': 0.0803, 'T': 0.0979, '。': 0.0435
向量模长: 0.4299
查询向量模长(归一化前): 0.5907
=== 余弦相似度排名(使用归一化向量)===
第1名: 文档3, 相似度 = 0.3528
原文: 检索增强生成(RAG)结合了检索与生成模型。
第2名: 文档2, 相似度 = 0.1792
原文: 深度学习模型需要大量数据进行训练。
第3名: 文档4, 相似度 = 0.1715
原文: 向量数据库用于高效存储和检索向量数据。结果分析:查询是“如何利用检索和生成模型进行问答?”,核心词是“检索”“生成”“模型”“问答”。
- 文档3(讲 RAG)同时命中“检、索、生、成、模、型”6 个字,相似度 0.3528,排第一是合理的。
- 文档2 与查询只重叠“模、型、进、行”4 个字,相似度 0.1792;文档4 重叠“检、索、用、和”4 个字,相似度 0.1715。两者分数贴着,差距只有 0.0077——这说明字符级 TF-IDF 分不清”深度学习模型”和”向量检索”哪个更贴题,它只认字面重合。中文实战里这个短板要靠分词 + 词级权重,或者干脆换稠密向量来补。
- 其余文档(文档1、5、6)与查询字面重合很少,相似度接近 0;其中文档1 与查询零字面重合,文档6 与查询还共享“模、型、如”几个字。本例归一化后 top-3 为文档3、文档2、文档4,文档6 并未进入前 3。作为一般性隐患(下面 2.4 展开):若固定返回 top-k 且相似度阈值为 0,零分堆里的不相关文档仍会被凑数召回——这是固定 top-k 的通用问题,不能安到本例未进榜的文档6 身上。
1.3 路线B:布尔词重叠计数(无归一化对照)
为了暴露“长文档因为词多而占优”的偏差,我们实现一个简单的布尔模型:只统计查询词在文档中是否出现(出现则计1分),不计算TF,也不使用IDF和归一化。相似度分数就是重叠词的数量。
import numpy as np
# 重新硬编码数据与分词
docs = [
"自然语言处理是人工智能的重要分支。",
"深度学习模型需要大量数据进行训练。",
"检索增强生成(RAG)结合了检索与生成模型。",
"向量数据库用于高效存储和检索向量数据。",
"注意力机制让模型能够关注输入的不同部分。",
"预训练语言模型如BERT在多项任务上表现出色。"
]
query = "如何利用检索和生成模型进行问答?"
def char_tokenize(text):
return [ch for ch in text if ch != ' ']
tokenized_docs = [char_tokenize(doc) for doc in docs]
tokenized_query = char_tokenize(query)
# 将查询词转为集合
query_word_set = set(tokenized_query)
# 计算布尔重叠计数
overlap_scores = []
for i, tokens in enumerate(tokenized_docs):
doc_word_set = set(tokens)
overlap = len(query_word_set.intersection(doc_word_set))
overlap_scores.append((i, overlap, len(tokens))) # 存储索引、重叠数、文档长度
# 按重叠数降序排序
overlap_scores.sort(key=lambda x: x[1], reverse=True)
print("=== 布尔词重叠计数排名(无TF-IDF,无归一化)===")
print("排名规则:查询词在文档中出现即计1分,分数相同则按文档长度降序(模拟长文档优势)")
for rank, (idx, score, doc_len) in enumerate(overlap_scores[:3], 1):
print(f"第{rank}名: 文档{idx+1}, 重叠词数 = {score}, 文档长度 = {doc_len}")
print(f" 原文: {docs[idx]}")
# 打印具体的重叠词
doc_word_set = set(tokenized_docs[idx])
overlapping_words = sorted(query_word_set.intersection(doc_word_set))
print(f" 重叠词: {overlapping_words}")=== 布尔词重叠计数排名(无TF-IDF,无归一化)===
排名规则:查询词在文档中出现即计1分,分数相同则按文档长度降序(模拟长文档优势)
第1名: 文档3, 重叠词数 = 6, 文档长度 = 22
原文: 检索增强生成(RAG)结合了检索与生成模型。
重叠词: ['型', '成', '检', '模', '生', '索']
第2名: 文档2, 重叠词数 = 4, 文档长度 = 17
原文: 深度学习模型需要大量数据进行训练。
重叠词: ['型', '模', '行', '进']
第3名: 文档4, 重叠词数 = 4, 文档长度 = 19
原文: 向量数据库用于高效存储和检索向量数据。
重叠词: ['和', '检', '用', '索']高潮对比: 现在,我们在TF-IDF计算中故意跳过L2归一化步骤,直接使用原始TF-IDF向量的点积作为“相似度”,看看排名会发生什么变化。这将直观展示“归一化陷阱”。
import numpy as np
import math
# 数据与分词(同上,略)
docs = ["自然语言处理是人工智能的重要分支。", "深度学习模型需要大量数据进行训练。", "检索增强生成(RAG)结合了检索与生成模型。", "向量数据库用于高效存储和检索向量数据。", "注意力机制让模型能够关注输入的不同部分。", "预训练语言模型如BERT在多项任务上表现出色。"]
query = "如何利用检索和生成模型进行问答?"
def char_tokenize(text): return [ch for ch in text if ch != ' ']
tokenized_docs = [char_tokenize(doc) for doc in docs]
tokenized_query = char_tokenize(query)
all_tokens = [ch for doc in tokenized_docs for ch in doc] + tokenized_query
vocab = sorted(set(all_tokens))
word_to_idx = {w:i for i,w in enumerate(vocab)}
N, V = len(docs), len(vocab)
# 计算df和idf (平滑公式)
df = np.zeros(V)
for tokens in tokenized_docs:
for w in set(tokens):
if w in word_to_idx: df[word_to_idx[w]] += 1
idf = np.array([math.log((N+1)/(df[i]+1))+1 for i in range(V)])
# 计算文档TF-IDF向量 (不归一化)
doc_vecs = np.zeros((N, V))
for i, tokens in enumerate(tokenized_docs):
total = len(tokens)
tf_dict = {}
for w in tokens: tf_dict[w] = tf_dict.get(w,0)+1
for w, cnt in tf_dict.items():
if w in word_to_idx:
idx = word_to_idx[w]
doc_vecs[i, idx] = (cnt/total) * idf[idx]
# 计算查询TF-IDF向量 (不归一化)
query_vec = np.zeros(V)
total_q = len(tokenized_query)
tf_dict_q = {}
for w in tokenized_query: tf_dict_q[w] = tf_dict_q.get(w,0)+1
for w, cnt in tf_dict_q.items():
if w in word_to_idx:
idx = word_to_idx[w]
query_vec[idx] = (cnt/total_q) * idf[idx]
# **陷阱:直接计算点积(未归一化)**
raw_dot_products = np.dot(doc_vecs, query_vec)
print("=== 【归一化陷阱演示】直接使用TF-IDF向量点积作为分数 ===")
print("(未进行L2归一化,分数受向量长度/文档长度影响严重)")
sorted_indices_raw = np.argsort(raw_dot_products)[::-1]
for rank, idx in enumerate(sorted_indices_raw[:3], 1):
doc_len = len(tokenized_docs[idx])
vec_norm = np.linalg.norm(doc_vecs[idx])
print(f"第{rank}名: 文档{idx+1}, 点积分 = {raw_dot_products[idx]:.4f}, 文档长度 = {doc_len}, 向量模长 = {vec_norm:.4f}")
print(f" 原文: {docs[idx]}")
# 对比:使用归一化后的余弦相似度排名(代码同1.2节,此处简写)
doc_vecs_norm = np.array([v/np.linalg.norm(v) if np.linalg.norm(v)>0 else v for v in doc_vecs])
query_vec_norm = query_vec / np.linalg.norm(query_vec) if np.linalg.norm(query_vec)>0 else query_vec
cos_sim = np.dot(doc_vecs_norm, query_vec_norm)
sorted_indices_norm = np.argsort(cos_sim)[::-1]
print("\n=== 对比:使用L2归一化后的余弦相似度排名 ===")
for rank, idx in enumerate(sorted_indices_norm[:3], 1):
print(f"第{rank}名: 文档{idx+1}, 余弦相似度 = {cos_sim[idx]:.4f}")
print(f" 原文: {docs[idx]}")=== 【归一化陷阱演示】直接使用TF-IDF向量点积作为分数 ===
(未进行L2归一化,分数受向量长度/文档长度影响严重)
第1名: 文档3, 点积分 = 0.1066, 文档长度 = 22, 向量模长 = 0.5115
原文: 检索增强生成(RAG)结合了检索与生成模型。
第2名: 文档4, 点积分 = 0.0558, 文档长度 = 19, 向量模长 = 0.5512
原文: 向量数据库用于高效存储和检索向量数据。
第3名: 文档2, 点积分 = 0.0504, 文档长度 = 17, 向量模长 = 0.4766
原文: 深度学习模型需要大量数据进行训练。
=== 对比:使用L2归一化后的余弦相似度排名 ===
第1名: 文档3, 余弦相似度 = 0.3528
原文: 检索增强生成(RAG)结合了检索与生成模型。
第2名: 文档2, 余弦相似度 = 0.1792
原文: 深度学习模型需要大量数据进行训练。
第3名: 文档4, 余弦相似度 = 0.1715
原文: 向量数据库用于高效存储和检索向量数据。陷阱分析(两组排名对照着看,变化只发生在第 2、3 名,但这已经够了):
- 未归一化的点积:文档3 = 0.1066、文档4 = 0.0558、文档2 = 0.0504。文档4 的向量模长 0.5512 是六条里最大的(长度 19,与查询重叠 4 个字),它靠”模长大”压过了重叠同为 4 个字、但更短的文档2(模长 0.4766)。
- 归一化后的余弦:文档3 = 0.3528、文档2 = 0.1792、文档4 = 0.1715。文档2 反超到第二——因为此时比的是方向,不再比长度。
- 为什么 TF-IDF 自己没解决:TF 除以文档长度确实压了词频,但向量的模长取决于有多少个非零维度、各维度权重多大,长度不同的文档模长仍然不齐。显式 L2 归一化才是把尺度对齐的那一步,这就是本节要观众看到的”差距只有 0.0077、但排名已经换了位”的地方。
二、检索层必须过的六道关
通过上面的微例子,我们直观体验了检索的核心计算过程与一个关键陷阱。现在,我们将其扩展到真实项目场景,系统阐述检索层必须考虑的六个关键方面。
2.1 稀疏检索 vs 稠密检索
- 稀疏检索(如TF-IDF, BM25):基于词袋模型,进行精确的字面匹配。
- 优点:术语命中精准(如产品型号、专有名词)、可解释性强、无需训练、计算效率高。
- 缺点:无法处理同义词、语义漂移(如“苹果”公司 vs 水果)、词汇鸿沟问题。
- 稠密检索(如Sentence-BERT, DPR):使用深度学习模型将文本映射为低维稠密向量(embedding),通过向量相似度(如余弦)进行检索。
- 优点:能捕捉语义相似性,对同义改写、语义关联有更好的鲁棒性。
- 缺点:可能丢失精确的字面匹配信息;需要训练或调用预训练模型;计算和存储开销更大。
- 工程实践:很少非此即彼。常见的混合检索(Hybrid Search)结合两者优点:先分别进行稀疏和稠密检索,然后对结果进行融合(如加权分数、倒数排名融合RRF)。此外,还可以用重排模型(Re-ranker)对初步检索出的top-k结果进行更精细的语义相关性打分,进一步提升精度。
2.2 归一化陷阱(本节的硬核点)
我们已经用实验证明了其重要性。总结如下:
- 余弦相似度的数学定义要求比较的是向量的方向,而非大小。L2归一化是将向量缩放到单位球面上的必要操作。
- TF-IDF本身不具备长度不变性。长文档有更多词,TF值可能更小(分母大),但非零维度多,累加后的向量模长仍然可能更大。
- 别指望索引替你归一化:向量库里的”内积索引”算的就是原始内积,它并不知道你要的是余弦;想用余弦,归一化必须自己做(或在写入时就把向量存成单位向量)。混淆两者的后果是:离线测试用余弦、线上检索用内积,两套分数根本不可比。
- 陷阱的变体:即使使用稠密向量,不同来源的 embedding 模型(或同一模型不同层)输出的向量尺度也可能不一致,直接比较同样会出问题。解法仍是统一归一化,或统一改用内积并保证同一模型来源。
2.3 分块策略
检索的单位往往是文档“块”(Chunk),而非整个文档。分块策略直接决定召回片段的质量。
- 块太大:包含过多无关信息,噪声多,导致召回不精准,影响后续生成模型聚焦关键信息。
- 块太小:语义不完整,可能将一个完整的答案切断,导致模型无法理解或需要跨多个块拼接,增加复杂度。
- 常用策略:
- 按语义边界切分:优先按段落、标题、章节等自然边界划分。
- 固定长度重叠窗口:使用滑动窗口按固定token数切分,并设置重叠区域(如重叠10%),避免答案被切在块边界。
- 智能分块:利用模型判断句子间的语义连贯性进行切分。
- 存储元数据:每个块都应附带来源、章节、页码等元数据,便于追溯和过滤。
下面这段不是「假设」,是按标点真的切开一段 153 字的样本、再按两种参数切块数出来的(脚本口径:按句拼接、块边界落在句子中间即记一次「切断」):
样本总字数: 153|句子数: 6|各句字数: [23, 18, 26, 34, 24, 28]
平均句长: 25.50 字
块长40/重叠10: 块数=5|平均块内句数=1.20|被切断句子数=4
块长80/重叠20: 块数=3|平均块内句数=2.00|被切断句子数=2读数:
- 块长 40 时平均每块只装得下 1.2 句、有 4 个句子被切开——一个块里放不下一个完整论述,答案很容易”一半在这个块、一半在下个块”。
- 块长 80 时块数降到 3、平均块内句数升到 2、被切断只剩 2 个,语义更完整,但单块噪声也更多。
- 这组数说明分块不是拍个数字:块长要对照你语料的平均句长来定。平均句长 25 字、块长 40 字,等于每块刚过一句半,注定切碎。
结论:块大更保语义完整、噪声更多;块小更精准、需要下游有跨块整合能力。选哪个取决于你的下游是”生成模型能读几块”和”对精度/召回的取舍”。
2.4 top-k 与阈值
- 固定top-k的弊端:无论查询与文档的相关性如何,都返回k条结果。对于完全不相关的查询,返回的结果可能是随机或低质量的,这会污染后续生成步骤。
- 最佳实践:
- 设置相似度阈值:只返回相似度超过某个阈值的结果。例如,
if similarity < 0.7: return []。 - 动态k值:根据阈值以上的结果数量动态调整返回条数。
- 显式处理空结果:当没有足够相关的结果时,系统应明确返回“未找到相关信息”,而不是用不相关的内容硬凑答案,这可以防止模型产生幻觉(Hallucination)。
- 设置相似度阈值:只返回相似度超过某个阈值的结果。例如,
2.5 评估检索质量
不能等到整个RAG系统上线后才评估检索层。需要独立的评估指标:
- 有标注数据时:
- Recall@k:在前k个召回结果中,至少出现一个相关文档的比例。这是检索系统最核心的指标。
- Mean Reciprocal Rank (MRR):对所有查询,计算第一个相关文档排名的倒数,然后取平均。衡量系统将相关文档排在前面的能力。
- 无标注数据时:
- 人工抽检:随机抽取20-50个查询,人工判断其top-k召回结果的相关性。这是快速发现系统性问题的有效方法(例如,是否所有查询都倾向于召回某类长文档?)。
- A/B测试:在线对比不同检索策略(如不同分块、不同模型)的点击率、停留时间等业务指标。
2.6 索引与更新
- 全量重建 vs 增量插入:
- 全量重建:文档集合更新后,重新计算所有向量的索引。简单可靠,但耗时耗资源。
- 增量插入:向现有索引中添加新向量的。效率高,但需索引支持(如Faiss的
add方法),并可能影响索引的平衡性和检索效率,需要定期合并或重建。
- 删除与更新:向量索引通常不支持直接删除或更新某条向量。常见做法是标记删除,并在查询时过滤掉;或者定期重建。更新可视为“删除旧+插入新”。
- 向量库选型核心:
- 过滤能力:能否在检索时高效地根据元数据(如日期、类别)进行过滤?这是业务查询的常见需求。
- 召回率与延迟:在百万/千万级向量上,近似最近邻搜索(ANN)的召回率(与精确搜索的对比)和查询延迟(QPS)是关键。
- 易用性与生态:语言支持、客户端、监控工具等。
- 误区:不要过分追求“向量维度高”,维度高不一定代表效果好,且会增加计算和存储成本。应选择与任务匹配的embedding模型。
三、逻辑映射段:微例 → 真实项目
让我们明确本节实战内容的边界,以及扩展到真实项目时会发生的变化。
属于本节(检索层核心):
- 文档清洗、分词、分块。
- 文本向量化(稀疏TF-IDF或稠密embedding)。
- 索引建立与管理(包括归一化处理)。
- 查询向量化与相似度计算(余弦、点积等)。
- top-k召回与阈值过滤。
- 检索质量的离线评估(Recall@k, MRR, 人工抽检)。
不属于本节(明确交棒):
- 将召回的多条文本片段拼接成一个完整的提示(Prompt)。
- 使用重排模型对初步结果进行精排。
- 将提示输入大语言模型(LLM)并生成最终答案。
- 让模型在生成答案时引用来源(Citation)。
- 对生成答案的事实正确性进行校验。
- 以上内容均属于后续「RAG知识库问答」一节。
从微例到真实项目的演变:
- 数据规模:从6条到上万/百万条 → 需要引入近似最近邻(ANN)索引,如HNSW(Hierarchical Navigable Small World)、IVF(Inverted File Index)等,以在可接受的时间内完成海量向量的检索。不能再使用线性扫描。
- 向量化方法:从TF-IDF到稠密向量(例如使用Sentence-BERT或OpenAI的Embedding API) → 相似度计算的核心(余弦)和归一化纪律不变,但向量语义更丰富。
- 查询复杂度:从单轮查询到多轮对话 → 需要查询改写(Query Rewriting)。例如,用户问“它有什么优点?”,需要结合对话历史将“它”补全为具体的实体。这是检索层面对对话场景的第一道坎。
- 系统架构:从单机脚本到在线服务 → 需要考虑并发、缓存、降级、监控等工程问题。
一句话结论:当RAG系统回答效果不佳时,不要首先责怪生成模型。根据经验,八成问题出在检索层,而其中分块策略不当、忘记向量归一化、缺少相似度阈值又是最常出现的三个问题。扎实的检索是高质量RAG的基石。
四、把「分块」写成一个可复跑的统计口径
上面那组数字是自己数出来的,做法很简单,你在自己语料上照做一遍就能定块长:
- 按标点(
。、?、!、换行)把语料切成句子,记录每句字数。 - 设块长
chunk与重叠overlap,步长取chunk - overlap,从 0 开始每步开一个块。 - 块边界落在某句内部(句首位置 < 边界 < 句尾位置),就算这一句被切断。
- 统计三个数:块数(
len(starts))、平均块内句数(总句数 / 块数的倒数关系)、被切断句子数。
三个数的用法:
- 被切断句子数直接对应”答案被切两半”的风险,越低越好;
- 平均块内句数低于 1.5 说明块装不下完整论述,往上调块长;
- 块数决定索引规模与召回成本,块数是块长的反向指标——别只看块长,要同时看块数涨了多少。
在实际项目里这套统计可以先用脚本跑一遍再定参数,不必等上线后才发现”召回总是半句话”。具体的分块实现可参考成熟的文本分割组件,但参数必须用你自己语料的这三个数来选。
📝 动手练一练
- 概念辨析:稀疏检索(如BM25)和稠密检索(如基于BERT的向量检索)各自最擅长处理哪种类型的查询?在什么情况下应该考虑使用混合检索?
👉 点击查看参考答案
- 稀疏检索(BM25)擅长:精确术语匹配、专有名词、产品型号、代码标识符等查询。例如,查询“Python中的
@dataclass装饰器”或“iPhone 15 Pro Max的屏幕尺寸”。 - 稠密检索擅长:语义相似性、同义改写、概念性查询。例如,查询“如何让电脑运行更快?”可能匹配到关于“系统优化”、“清理垃圾文件”、“升级硬件”的文档。
- 应考虑混合检索的情况:
- 查询中同时包含需要精确匹配的关键词和需要语义理解的描述性语言。
- 领域内既有大量标准术语,又存在丰富的同义表达。
- 对召回率和准确率都有较高要求,单一方法无法满足。 混合检索可以通过加权分数或结果融合(如倒数排名融合RRF)来结合两者的优势。
- 手算验证:假设我们有3篇文档,词表为
['苹果', '公司', '水果', '好吃']。文档1:“苹果公司”,文档2:“苹果好吃”,文档3:“水果好吃”。查询为“苹果”。使用布尔模型(词出现计1分),请手算查询与每个文档的重叠分数。如果使用原始TF-IDF点积(不归一化),且假设IDF权重相同,你认为文档1和文档2的分数关系会怎样?这揭示了什么陷阱?
👉 点击查看参考答案
布尔模型:
- 文档1词集:{‘苹果’, ‘公司’},与查询{‘苹果’}重叠数 = 1。
- 文档2词集:{‘苹果’, ‘好吃’},重叠数 = 1。
- 文档3词集:{‘水果’, ‘好吃’},重叠数 = 0。 排名:文档1和文档2并列第一(分数1),文档3最后(分数0)。
原始TF-IDF点积(不归一化)分析: 假设IDF权重相同,设为1。TF为词频/文档长度。
- 文档1向量:苹果(1/2=0.5), 公司(0.5), 水果(0), 好吃(0)。模长 ≈ =0.707。
- 文档2向量:苹果(0.5), 公司(0), 水果(0), 好吃(0.5)。模长 ≈ 0.707。
- 查询向量:苹果(1/1=1), 公司(0), 水果(0), 好吃(0)。模长=1。
- 点积分: 文档1: 0.51 + 0.50 + 00 + 00 = 0.5 文档2: 0.51 + 00 + 00 + 0.50 = 0.5 两者分数相同。
陷阱揭示:在这个简单例子中,由于两个文档长度相同,未归一化的点积分没有产生偏差。但设想文档2变成“苹果非常非常好吃”(长度5),则其TF值变为:苹果(1/5=0.2), 好吃(1/5=0.2)。点积分变为0.2,低于文档1的0.5。这反直觉! 查询只关心“苹果”,文档2虽然更长稀释了TF,但核心信息“苹果”依然存在,其相关性不应低于文档1(后者多了无关词“公司”)。这就是长度归一化陷阱:未归一化的点积惩罚了长文档,即使它包含了查询词。而余弦相似度(经过L2归一化)能消除长度影响,公平地比较方向。计算余弦相似度后,两者与查询的夹角可能非常接近,更能反映“都包含‘苹果’”这一事实。
本章小结
本节我们深入探讨了语义检索与向量库实战的核心内容,聚焦于RAG系统中的检索层。我们从最小闭环示例出发,实现了TF-IDF与布尔检索,并通过对比实验,强烈验证了计算余弦相似度前进行向量L2归一化的必要性,这是实践中极易忽略却影响重大的陷阱。
我们进一步系统阐述了检索层必须考虑的六个关键方面:稀疏与稠密检索的对比与混合策略、分块策略的制定与影响、top-k与阈值的设置、检索质量的评估方法以及索引的更新机制。最后,我们明确了本节实战的边界,并展望了从微例扩展到真实项目时面临的挑战与演变。
📋 行动清单
在开始构建你自己的检索系统或诊断现有RAG系统问题时,请务必检查以下事项:
- 确认向量归一化:无论是TF-IDF还是稠密向量,在计算余弦相似度或构建向量索引前,是否都进行了L2归一化?
- 设计并验证分块策略:根据你的文档类型(技术文档、对话记录、长文章)设计分块大小和重叠区域,并抽样检查分块后的语义完整性。
- 设置相似度阈值:不要总是返回固定数量的结果。为你的应用设定一个合理的相似度下限,对不相关的查询返回空结果,避免垃圾信息进入生成阶段。
—— 小象教研组