一、简介
在检索增强生成(RAG)系统中,相似度计算 是连接用户查询与知识库文档的核心环节。它的目标是从海量向量中快速找到与查询语义最相似的文档片段,为后续的大语言模型生成提供高质量上下文。相似度计算的准确性直接决定了检索结果的相关性,进而影响最终答案的质量。
1.1 核心概念
在RAG中,相似度计算是指衡量两个向量在数学空间中的接近程度。经过嵌入模型转换后,文本(查询和文档块)都变成了高维向量。相似度计算就是计算这些向量之间的距离或角度,距离越近(角度越小)表示语义越相似。
1.2 核心作用
- 找到最相关的文档:根据用户问题,从成千上万的文档块中找出语义最匹配的几个。
- 排序检索结果:将检索到的文档按相关性排序。
- 阈值过滤:排除相关性太低的文档,避免引入噪音。
1.3 相似度计算的基本流程
bash
[用户问题]
↓
1. 嵌入模型 → [查询向量]
↓
2. 归一化 → [单位向量]
↓
3. 向量数据库检索
(如: 计算 单位向量 · 所有文档向量)
↓
4. 获取 Top-K 相似度得分
(得分 = 余弦相似度/点积值)
↓
5. 将对应的文档块作为上下文,返回给 LLM
当用户提出问题时,系统实时执行以下步骤:
- 查询向量化
- 使用同一个嵌入模型将用户问题转换成向量。
- 注意:必须使用和文档向量化时完全相同的模型,否则向量无法在同一空间比较。
- 查询向量归一化
- 对用户问题向量进行同样的L2归一化处理。
- 相似度计算
- 将用户问题向量与向量数据库中的文档向量进行匹配计算。
- 计算方法:根据使用的索引类型,可能进行精确计算(暴力遍历)或近似计算(ANN搜索)。
- 排序与过滤
- 根据相似度得分从高到低排序,选出Top-K(如最相似的5个)结果。
- 有时会设置一个相似度阈值,低于该阈值的结果即使数量够也被丢弃。
- 送入LLM
- 将筛选出的相似文本块作为上下文,连同用户问题一起交给大语言模型生成答案。
二、常见相似度计算方法
2.1 余弦相似度(Cosine Similarity)
最常用、最推荐的相似度计算方法。
-
原理:计算两个向量之间的夹角余弦值,关注的是方向而不是长度。
-
公式:
cosine_similarity(A, B) = (A·B) / (||A|| × ||B||)- A·B 是向量的点积
- ||A|| 是向量A的模长(L2范数)
-
特性:
- 取值范围:[-1, 1](归一化后为 [0, 1])
- 1:完全相同方向
- 0:正交(不相关)
- -1:完全相反方向(通常不会出现,因为文本嵌入很少负相关)
- 不受文本长度影响
-
适用场景:
- 所有 Embedding 模型(BGE、m3e、OpenAI Embedding)
- 99% 的中文 RAG 场景
-
示例:
pythonimport numpy as np def cosine_similarity(vec1, vec2): """计算两个向量的余弦相似度""" dot_product = np.dot(vec1, vec2) norm1 = np.linalg.norm(vec1) norm2 = np.linalg.norm(vec2) if norm1 == 0 or norm2 == 0: return 0 return dot_product / (norm1 * norm2) # 示例 vec1 = [0.1, 0.3, 0.5, 0.2] vec2 = [0.15, 0.25, 0.55, 0.18] similarity = cosine_similarity(vec1, vec2) print(f"余弦相似度: {similarity:.4f}")
2.2 点积相似度(Dot Product)
-
原理:直接计算两个向量的点积。
-
公式:
dot_product(A, B) = Σ(A_i × B_i) -
特性:
- 取值范围:无固定范围,取决于向量长度
- 如果向量已经归一化(长度为1),点积 = 余弦相似度
- 对向量长度敏感,长向量可能获得更高分数
-
适用场景:
- 当向量已经归一化时(很多嵌入模型默认归一化)
- 在某些向量数据库(如FAISS)中,点积是默认的相似度度量
-
示例:
pythondef dot_product_similarity(vec1, vec2): """计算点积相似度""" return np.dot(vec1, vec2) # 如果向量已归一化 vec1_normalized = vec1 / np.linalg.norm(vec1) vec2_normalized = vec2 / np.linalg.norm(vec2) dot_sim = dot_product_similarity(vec1_normalized, vec2_normalized) # 此时 dot_sim 等于余弦相似度
2.3 欧氏距离(Euclidean Distance)
-
原理:计算向量在空间中的直线距离。
-
公式:
euclidean_distance(A, B) = √(Σ(A_i - B_i)²) -
特性:
- 受向量长度影响大
- 取值范围:[0, +∞)
- 0:完全相同
- 越大:差异越大
- 需要转换为相似度:similarity = 1 / (1 + distance) 或 similarity = -distance
-
适用场景:
- 在一些特定聚类算法或对绝对位置敏感的检索中会用到,但在语义搜索中不如余弦相似度直观。
-
示例:
pythondef euclidean_distance(vec1, vec2): """计算欧氏距离""" return np.sqrt(np.sum((np.array(vec1) - np.array(vec2)) ** 2)) def euclidean_similarity(vec1, vec2): """将欧氏距离转换为相似度""" dist = euclidean_distance(vec1, vec2) return 1 / (1 + dist) # 转换为0-1之间的相似度 # 示例 dist = euclidean_distance(vec1, vec2) sim = euclidean_similarity(vec1, vec2) print(f"欧氏距离: {dist:.4f}, 相似度: {sim:.4f}")
2.4 方法对比总结
对于大多数RAG应用,余弦相似度是最佳选择,因为它不受向量长度影响,只关注语义方向。
| 方法 | 关注点 | 取值范围 | 计算复杂度 | 适用场景 |
|---|---|---|---|---|
| 余弦相似度 | 方向 | [-1, 1] | O(n) | 最常用,对文本长度不敏感 |
| 点积 | 方向和长度 | 无固定 | O(n) | 向量已归一化时等同于余弦 |
| 欧氏距离 | 绝对距离 | [0, +∞) | O(n) | 向量长度有意义时 |
三、LangChain中的相似度计算
3.1 向量数据库中的相似度
在LangChain中,相似度计算通常由向量数据库(Vector Store)在底层完成。我们只需要指定检索参数:
python
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings
# 初始化
embeddings = OpenAIEmbeddings()
vectorstore = Chroma.from_documents(documents, embeddings)
# 检索(使用默认相似度)
docs = vectorstore.similarity_search("你的问题", k=3)
# 返回带分数的检索
docs_with_scores = vectorstore.similarity_search_with_score("你的问题", k=3)
for doc, score in docs_with_scores:
print(f"文档: {doc.page_content[:50]}...")
print(f"相似度分数: {score}")
print()
3.2 不同向量数据库的相似度度量
不同向量数据库使用的默认相似度度量可能不同:
| 向量数据库 | 默认相似度 | 可配置的度量 |
|---|---|---|
| Chroma | 欧氏距离 (L2) | 余弦、内积、L2 |
| FAISS | 内积 (IP) 或 L2 | IP、L2 |
| Milvus | 取决于索引类型 | 余弦、IP、L2、汉明等 |
| Pinecone | 余弦 | 余弦、点积、欧氏 |
| Qdrant | 余弦 | 余弦、点积、欧氏 |
| Weaviate | 余弦 | 余弦、点积、L2 |
3.3 配置相似度度量
python
# Chroma 配置余弦相似度
vectorstore = Chroma.from_documents(
documents=docs,
embedding=embeddings,
collection_metadata={"hnsw:space": "cosine"} # 可选: l2, ip, cosine
)
# FAISS 配置内积(等同于余弦如果向量已归一化)
import faiss
from langchain_community.vectorstores import FAISS
# 确保向量已归一化
normalized_vectors = [v / np.linalg.norm(v) for v in vectors]
index = faiss.IndexFlatIP(len(normalized_vectors[0]))
index.add(np.array(normalized_vectors).astype('float32'))
3.4 手动计算相似度
有时可能需要手动计算相似度,例如在检索后进行重新排序:
python
from langchain_openai import OpenAIEmbeddings
import numpy as np
embeddings = OpenAIEmbeddings()
# 嵌入查询和文档
query = "深度学习有哪些应用?"
query_vec = embeddings.embed_query(query)
doc_texts = [
"深度学习在图像识别领域取得了巨大成功...",
"机器学习是人工智能的一个分支...",
"神经网络是深度学习的基础..."
]
doc_vecs = embeddings.embed_documents(doc_texts)
# 手动计算余弦相似度
def cosine_similarity(vec1, vec2):
vec1 = np.array(vec1)
vec2 = np.array(vec2)
return np.dot(vec1, vec2) / (np.linalg.norm(vec1) * np.linalg.norm(vec2))
# 计算所有相似度
similarities = [cosine_similarity(query_vec, doc_vec) for doc_vec in doc_vecs]
# 排序
top_indices = np.argsort(similarities)[::-1]
for idx in top_indices:
print(f"相似度: {similarities[idx]:.4f} - {doc_texts[idx][:50]}...")
四、相似度计算的优化策略
4.1 向量归一化
余弦相似度计算需要对向量进行归一化。如果预先归一化,就可以用点积代替,大幅提升计算速度。
python
# 预先归一化所有文档向量
def normalize_vectors(vectors):
vectors = np.array(vectors)
norms = np.linalg.norm(vectors, axis=1, keepdims=True)
norms[norms == 0] = 1
return vectors / norms
# 存储归一化后的向量
normalized_doc_vecs = normalize_vectors(doc_vecs)
# 查询时也归一化查询向量
query_vec_normalized = query_vec / np.linalg.norm(query_vec)
# 现在可以用点积代替余弦
similarities = np.dot(normalized_doc_vecs, query_vec_normalized)
4.2 近似最近邻搜索(ANN)
当文档数量很大(百万级以上)时,精确计算所有相似度太慢。使用近似最近邻(ANN)算法可以大幅提升检索速度。
python
from langchain_community.vectorstores import FAISS
from langchain_openai import OpenAIEmbeddings
# FAISS 支持多种索引类型
vectorstore = FAISS.from_documents(
documents=docs,
embedding=embeddings
)
# 默认使用精确搜索(IndexFlatIP或IndexFlatL2)
# 切换到近似搜索
vectorstore.index = faiss.IndexIVFFlat(
vectorstore.index, # 量化器
128, # 聚类中心数
16 # 搜索时检查的聚类数
)
# 训练索引(需要先有向量)
vectorstore.index.train(np.array(doc_vecs).astype('float32'))
vectorstore.index.add(np.array(doc_vecs).astype('float32'))
# 检索(现在使用近似搜索)
docs = vectorstore.similarity_search(query, k=5)
常见的ANN算法:
- HNSW(Hierarchical Navigable Small World):目前最流行
- IVF(Inverted File Index):基于聚类
- PQ(Product Quantization):量化压缩
4.3 混合搜索(Hybrid Search)
结合向量相似度和关键词匹配(如BM25),可以取得更好的检索效果。
python
from langchain.retrievers import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever
from langchain_community.vectorstores import Chroma
# 向量检索器
vectorstore = Chroma.from_documents(docs, embeddings)
vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 5})
# 关键词检索器
keyword_retriever = BM25Retriever.from_documents(docs)
keyword_retriever.k = 5
# 组合检索器
ensemble_retriever = EnsembleRetriever(
retrievers=[vector_retriever, keyword_retriever],
weights=[0.7, 0.3] # 向量权重70%,关键词30%
)
# 检索
results = ensemble_retriever.get_relevant_documents("深度学习应用")
4.4 重新排序(Reranking)
初次检索可能返回一些噪音,可以使用更精确的模型(如交叉编码器)对top-k结果重新排序。
python
from langchain.retrievers import ContextualCompressionRetriever
from langchain.retrievers.document_compressors import CrossEncoderReranker
from langchain_community.cross_encoders import HuggingFaceCrossEncoder
# 初始化交叉编码器(用于重新排序)
reranker = CrossEncoderReranker(
model=HuggingFaceCrossEncoder(model_name="BAAI/bge-reranker-base"),
top_n=3 # 只保留最相关的3个
)
# 创建压缩检索器(先检索再重排)
compression_retriever = ContextualCompressionRetriever(
base_compressor=reranker,
base_retriever=vector_retriever
)
# 检索(自动重排)
docs = compression_retriever.get_relevant_documents("查询")
4.5 多向量检索(Multi-Vector Retrieval)
对于同一文档块,可以存储多个向量(如不同粒度的嵌入),检索时综合评分。
python
from langchain.retrievers.multi_vector import MultiVectorRetriever
from langchain.storage import InMemoryStore
from langchain_community.vectorstores import Chroma
# 创建多向量检索器
vectorstore = Chroma(collection_name="embeddings", embedding_function=embeddings)
store = InMemoryStore()
retriever = MultiVectorRetriever(
vectorstore=vectorstore,
docstore=store,
id_key="doc_id"
)
# 为每个文档添加多个向量(如摘要、关键句、全文)
doc_ids = ["doc1", "doc2"]
summary_vecs = embeddings.embed_documents([doc.summary for doc in docs])
key_sentence_vecs = embeddings.embed_documents([doc.key_sentences for doc in docs])
# 将多个向量添加到vectorstore
retriever.vectorstore.add_vectors(doc_ids, summary_vecs)
retriever.vectorstore.add_vectors(doc_ids, key_sentence_vecs)
# 存储原始文档
retriever.docstore.mset(list(zip(doc_ids, docs)))
五、相似度分数解读与阈值设置
5.1 理解相似度分数
不同模型、不同度量方法产生的分数范围不同:
| 度量方法 | 分数范围 | 高分含义 | 低分含义 |
|---|---|---|---|
| 余弦相似度 | [0, 1](归一化后) | >0.7:非常相关 | <0.3:基本无关 |
| 点积(未归一化) | 无固定 | >10?取决于向量长度 | <1?取决于向量长度 |
| 欧氏距离 | [0, +∞) | <10?距离越小越相关 | >50?距离越大越不相关 |
5.2 设置相似度阈值
在检索时设置阈值,可以过滤掉不相关的文档:
python
def retrieve_with_threshold(query, threshold=0.7, k=10):
"""检索相似度超过阈值的文档"""
query_vec = embeddings.embed_query(query)
# 计算所有相似度
similarities = batch_cosine_similarity(query_vec, doc_vecs)
# 获取超过阈值的索引
relevant_indices = np.where(similarities >= threshold)[0]
# 按相似度排序
sorted_indices = relevant_indices[np.argsort(similarities[relevant_indices])[::-1]]
# 返回top-k
top_indices = sorted_indices[:k]
return [(docs[i], similarities[i]) for i in top_indices]
# 使用
results = retrieve_with_threshold("深度学习", threshold=0.65, k=5)
5.3 分数归一化
如果使用不同模型或不同批次的检索,可能需要将分数归一化到统一范围:
python
def normalize_scores(scores):
"""将分数归一化到[0,1]区间"""
scores = np.array(scores)
min_score = np.min(scores)
max_score = np.max(scores)
if max_score == min_score:
return np.ones_like(scores)
return (scores - min_score) / (max_score - min_score)
# 示例
raw_scores = [0.5, 0.7, 0.6, 0.9, 0.3]
normalized = normalize_scores(raw_scores)
print(f"原始: {raw_scores}")
print(f"归一化: {normalized}")
六、相似度计算的最佳实践
6.1 选择合适的相似度度量
| 场景 | 推荐度量 | 原因 |
|---|---|---|
| 通用文本检索 | 余弦相似度 | 不受文本长度影响,只关注语义 |
| 向量已归一化 | 点积 | 等于余弦,计算更快 |
| 需要距离解释 | 欧氏距离 | 物理意义直观 |
6.2 优化检索速度
python
# 1. 预先归一化向量
normalized_vectors = normalize_vectors(all_vectors)
# 2. 使用近似最近邻索引
index = faiss.IndexHNSWFlat(dim, 32) # HNSW索引
index.add(normalized_vectors)
# 3. 批量检索
def batch_retrieve(queries, k=5):
query_vecs = embeddings.embed_documents(queries)
query_vecs = normalize_vectors(query_vecs)
# FAISS批量搜索
distances, indices = index.search(query_vecs.astype('float32'), k)
results = []
for i in range(len(queries)):
query_results = [(docs[idx], distances[i][j]) for j, idx in enumerate(indices[i])]
results.append(query_results)
return results
6.3 处理边缘情况
python
def safe_similarity(vec1, vec2):
"""安全的相似度计算,处理零向量"""
vec1 = np.array(vec1)
vec2 = np.array(vec2)
norm1 = np.linalg.norm(vec1)
norm2 = np.linalg.norm(vec2)
if norm1 == 0 or norm2 == 0:
return 0 # 零向量相似度为0
return np.dot(vec1, vec2) / (norm1 * norm2)
6.4 相似度分数校准
不同查询的相似度分布可能不同,可以针对查询进行动态校准:
bash
def calibrated_similarity(query_vec, doc_vecs, method="zscore"):
"""对相似度分数进行校准"""
raw_sims = batch_cosine_similarity(query_vec, doc_vecs)
if method == "zscore":
# Z-score归一化
mean = np.mean(raw_sims)
std = np.std(raw_sims)
if std > 0:
calibrated = (raw_sims - mean) / std
else:
calibrated = raw_sims
elif method == "minmax":
# Min-max归一化
calibrated = normalize_scores(raw_sims)
else:
calibrated = raw_sims
return calibrated
七、完整实战:构建相似度检索系统
python
import numpy as np
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import FAISS
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import TextLoader
# 1. 准备数据
loader = TextLoader("knowledge.txt")
documents = loader.load()
text_splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
chunks = text_splitter.split_documents(documents)
# 2. 初始化嵌入模型
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
# 3. 创建向量存储(使用余弦相似度)
vectorstore = FAISS.from_documents(
documents=chunks,
embedding=embeddings,
distance_strategy="COSINE" # 使用余弦相似度
)
# 4. 自定义相似度检索函数
def advanced_similarity_search(query, k=5, min_score=0.0, rerank=True):
# 基础检索
docs_with_scores = vectorstore.similarity_search_with_score(query, k=k*2)
# 过滤低分文档
filtered = [(doc, score) for doc, score in docs_with_scores if score >= min_score]
if not filtered:
return []
# 可选:重新排序
if rerank and len(filtered) > k:
from sentence_transformers import CrossEncoder
cross_encoder = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
pairs = [[query, doc.page_content] for doc, _ in filtered]
rerank_scores = cross_encoder.predict(pairs)
# 结合原始分数和重排分数
combined = []
for i, (doc, original_score) in enumerate(filtered):
combined_score = 0.3 * original_score + 0.7 * rerank_scores[i]
combined.append((doc, combined_score))
# 按组合分数排序
combined.sort(key=lambda x: x[1], reverse=True)
results = combined[:k]
else:
results = filtered[:k]
return results
# 5. 测试
query = "机器学习的基本概念"
results = advanced_similarity_search(query, k=3, min_score=0.6)
print(f"查询:{query}\n")
for i, (doc, score) in enumerate(results):
print(f"结果 {i+1} [相似度: {score:.4f}]")
print(f"内容: {doc.page_content[:150]}...")
print(f"来源: {doc.metadata.get('source', 'unknown')}\n")
# 6. 相似度分布分析
def analyze_similarity_distribution(query):
"""分析查询与所有文档的相似度分布"""
query_vec = embeddings.embed_query(query)
# 获取所有文档向量
if hasattr(vectorstore, 'index'):
# FAISS索引
doc_vecs = vectorstore.index.reconstruct_n(0, vectorstore.index.ntotal)
similarities = batch_cosine_similarity(query_vec, doc_vecs)
else:
# 手动检索所有
all_docs = vectorstore.similarity_search_with_score(query, k=len(chunks))
similarities = [score for _, score in all_docs]
similarities = np.array(similarities)
print(f"相似度统计:")
print(f" 最小值: {np.min(similarities):.4f}")
print(f" 最大值: {np.max(similarities):.4f}")
print(f" 平均值: {np.mean(similarities):.4f}")
print(f" 中位数: {np.median(similarities):.4f}")
print(f" 标准差: {np.std(similarities):.4f}")
# 建议阈值
threshold = np.percentile(similarities, 90) # 取90分位数
print(f" 建议阈值(90分位数): {threshold:.4f}")
analyze_similarity_distribution(query)
八、常见问题与解决方案
Q1: 为什么有时候检索结果不相关?
可能原因:
- 嵌入模型不适合你的领域/语言
- 相似度阈值太低
- 文档分割不合理
- 查询表述模糊
解决方案:
- 尝试不同的嵌入模型
- 提高相似度阈值
- 优化文档分割策略
- 使用查询改写(Query Rewriting)
Q2: 如何处理大规模向量检索的性能问题?
解决方案:
- 使用ANN索引(HNSW、IVF等)
- GPU加速
- 向量量化(PQ)
- 分布式检索(分片)
Q3: 余弦相似度 vs 欧氏距离,哪个更好?
一般建议:对于归一化的文本嵌入,余弦相似度更常用。但如果你的嵌入模型没有归一化,且向量长度有意义(如包含置信度),欧氏距离可能更合适。
Q4: 相似度分数不稳定怎么办?
解决方案:
- 使用归一化(Z-score、Min-max)
- 采用相对排序而不是绝对分数
- 结合多种相似度度量(集成学习)
Q5: 如何为新领域选择相似度度量?
建议方法:
- 准备一些人工标注的相关/不相关对
- 测试不同度量的区分能力(ROC AUC)
- 选择AUC最高的度量