RAG 相似度计算

一、简介

在检索增强生成(RAG)系统中,相似度计算 是连接用户查询与知识库文档的核心环节。它的目标是从海量向量中快速找到与查询语义最相似的文档片段,为后续的大语言模型生成提供高质量上下文。相似度计算的准确性直接决定了检索结果的相关性,进而影响最终答案的质量。

1.1 核心概念

在RAG中,相似度计算是指衡量两个向量在数学空间中的接近程度。经过嵌入模型转换后,文本(查询和文档块)都变成了高维向量。相似度计算就是计算这些向量之间的距离或角度,距离越近(角度越小)表示语义越相似。

1.2 核心作用

  1. 找到最相关的文档:根据用户问题,从成千上万的文档块中找出语义最匹配的几个。
  2. 排序检索结果:将检索到的文档按相关性排序。
  3. 阈值过滤:排除相关性太低的文档,避免引入噪音。

1.3 相似度计算的基本流程

bash 复制代码
[用户问题] 
    ↓
1. 嵌入模型 → [查询向量]
    ↓
2. 归一化 → [单位向量]
    ↓
3. 向量数据库检索 
   (如: 计算 单位向量 · 所有文档向量)
    ↓
4. 获取 Top-K 相似度得分 
   (得分 = 余弦相似度/点积值)
    ↓
5. 将对应的文档块作为上下文,返回给 LLM

当用户提出问题时,系统实时执行以下步骤:

  1. 查询向量化
    • 使用同一个嵌入模型将用户问题转换成向量。
    • 注意:必须使用和文档向量化时完全相同的模型,否则向量无法在同一空间比较。
  2. 查询向量归一化
    • 对用户问题向量进行同样的L2归一化处理。
  3. 相似度计算
    • 将用户问题向量与向量数据库中的文档向量进行匹配计算。
    • 计算方法:根据使用的索引类型,可能进行精确计算(暴力遍历)或近似计算(ANN搜索)。
  4. 排序与过滤
    • 根据相似度得分从高到低排序,选出Top-K(如最相似的5个)结果。
    • 有时会设置一个相似度阈值,低于该阈值的结果即使数量够也被丢弃。
  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 场景
  • 示例:

    python 复制代码
    import 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)中,点积是默认的相似度度量
  • 示例:

    python 复制代码
    def 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
  • 适用场景:

    • 在一些特定聚类算法或对绝对位置敏感的检索中会用到,但在语义搜索中不如余弦相似度直观。
  • 示例:

    python 复制代码
    def 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最高的度量
相关推荐
矩阵科学9 小时前
Langchain.js 实战二:会话消息
langchain
学习是生活的调味剂12 小时前
大模型应用之使用LangChain实现RAG(二)智能客服
服务器·数据库·langchain
大模型真好玩12 小时前
一文详解2026年技术圈最火概念——Agent Engineering智能体工程
人工智能·langchain·agent
矩阵科学13 小时前
Langchain.js 教程一:快速入门
langchain
chaors13 小时前
Langchain入门到精通0x0c:middleware
人工智能·langchain·ai编程
猫头虎14 小时前
如何解决openclaw安装skills报错command not foud:clawhub问题怎么解决?
langchain·开源·prompt·github·aigc·ai编程·内容运营
问道飞鱼14 小时前
【大模型学习】LangChain 入门指南:基本概念、核心功能与简单示例
java·学习·langchain
zhojiew15 小时前
使用langchain创建agent应用并集成dynamodb实现多会话能力
langchain