【Python】Python+sentence-transformers框架实现相似文本识别

第一章:文本相似度与语义表示概述

在深入 sentence-transformers 框架之前,我们首先需要对文本相似度计算及其背后的核心概念——语义表示,有一个清晰且全面的理解。这构成了后续所有讨论的基础。

1.1 什么是文本相似度?

1.1.1 定义与重要性

文本相似度(Text Similarity)是指衡量两段文本(可以是词、短语、句子、段落或整个文档)在意义或内容上相近程度的指标。这种相近可以是字面上的(lexical similarity),也可以是语义上的(semantic similarity)。

  • 字面相似度:关注文本串之间共享的字符序列或词汇。例如,“今天天气很好”和“今天天气真好”具有较高的字面相似度。
  • 语义相似度:关注文本所表达的核心意义是否相近,即使它们的用词和句法结构可能完全不同。例如,“苹果公司发布了新款手机”和“iPhone制造商推出了新一代设备”在语义上是高度相似的,尽管它们的字面重叠度不高。

在自然语言处理(NLP)领域,准确地计算文本相似度至关重要,因为它构成了许多高级应用的核心技术。能够理解并量化文本之间的关系,是机器理解人类语言的关键一步。随着信息爆炸式增长,快速有效地从海量文本数据中提取有价值信息、识别相关内容、避免冗余,都离不开文本相似度计算。

1.1.2 不同层级的文本相似度

文本相似度的衡量可以发生在不同的粒度层级:

  1. 词汇层相似度 (Lexical Similarity):

    • 关注单词本身。例如,通过比较两个单词的编辑距离(如Levenshtein距离),或者判断它们是否为同义词(使用WordNet等词典)。
    • 简单直接,但无法处理一词多义和多词一义(同义词)的问题。例如,“bank”(银行)和“bank”(河岸)字面相同但意义不同。
  2. 句法层相似度 (Syntactic Similarity):

    • 关注句子的结构和语法组成。例如,比较两句话的句法树(parse tree)的相似性。
    • 如果两个句子有相似的语法结构,即使填充的词汇不同,也可能在某种程度上相关。例如,“猫追老鼠”和“狗追兔子”在主谓宾结构上是相似的。
    • 句法分析本身比较复杂,且句法相似不完全等同于语义相似。
  3. 语义层相似度 (Semantic Similarity):

    • 这是最具挑战性也最有价值的层面,关注文本所表达的深层含义。
    • 理想的语义相似度计算应该能够理解上下文、识别潜在含义、处理比喻、反讽等复杂语言现象。例如,理解“今晚月色真美”在特定文化语境下可能表达了爱慕之情,而不仅仅是描述天气。
    • sentence-transformers 主要致力于解决句子和段落级别的语义相似度问题。
  4. 文档层相似度 (Document Similarity):

    • 衡量整个文档之间的内容相关性。这通常涉及到对文档中多个句子或段落语义的综合考量。
    • 可以是基于文档中共享的主题、概念或实体来判断。

不同的应用场景可能侧重于不同层级的相似度。例如,拼写检查可能更关注词汇层相似度,而智能问答系统则高度依赖语义层相似度。

1.1.3 文本相似度的应用场景

文本相似度计算技术在现实世界中有非常广泛的应用,以下是一些典型的例子:

  1. 信息检索 (Information Retrieval) / 搜索引擎 (Search Engines):

    • 用户输入查询(query),搜索引擎需要在海量文档库中找出与查询语义最相关的文档。例如,搜索“如何学习Python”时,返回的应该是关于Python学习教程、资源或路径规划的网页,而不是仅仅包含“如何”、“学习”、“Python”这些词的无关页面。
    • sentence-transformers 可以将查询和文档都编码为向量,通过计算向量间的余弦相似度来排序搜索结果。
  2. 智能问答 (Question Answering, QA) / 聊天机器人 (Chatbots):

    • 将用户提出的问题与知识库中预设的问题-答案对(FAQ)进行匹配。
    • 在多轮对话中,理解用户当前意图与历史对话内容的相关性。
    • 例如,用户问“我的订单到哪里了?”,系统需要找到知识库中关于“查询订单状态”或“物流跟踪”的相似问题及其答案。
  3. 抄袭检测 (Plagiarism Detection) / 重复内容识别 (Duplicate Content Detection):

    • 检测学术论文、新闻稿件、代码、网页内容等是否存在抄袭或高度重复。
    • 这需要对文本进行深度语义比较,而不仅仅是字面匹配,以识别改写、同义词替换等抄袭手段。
  4. 推荐系统 (Recommendation Systems):

    • 基于用户阅读过的新闻、观看过的视频、购买过的商品的文本描述,推荐语义上相似的其他内容。
    • 例如,如果用户喜欢阅读关于“人工智能在医疗领域的应用”的文章,系统可以推荐其他关于AI医疗、医学影像分析、智能诊断等主题的相似文章。
  5. 文本聚类 (Text Clustering) / 主题建模 (Topic Modeling):

    • 将大量无标签文本自动分组,使得同一组内的文本在主题或内容上彼此相似,不同组间的文本差异较大。
    • 例如,对新闻报道进行聚类,可以自动发现当前的热点事件和话题。
  6. 文本摘要 (Text Summarization):

    • 在抽取式摘要中,可以通过计算句子与原文核心内容的相似度来挑选代表性的句子组成摘要。
  7. 机器翻译评估 (Machine Translation Evaluation):

    • 比较机器翻译的译文与人工参考译文之间的语义相似度,作为翻译质量的一个指标(尽管BLEU等基于N-gram的指标更常用,但语义相似度提供了有益的补充)。
  8. 情感分析 (Sentiment Analysis) / 观点挖掘 (Opinion Mining):

    • 虽然主要任务是判断情感极性,但在某些高级应用中,可能需要比较不同评论在情感表达或观点侧重点上的相似性。

理解这些应用场景有助于我们认识到为什么高质量的文本语义表示和相似度计算如此重要,这也是 sentence-transformers 这类工具应运而生的驱动力。

1.2 传统文本表示方法的局限性

在深度学习方法(尤其是基于Transformer的模型)兴起之前,NLP领域依赖于一些经典的文本表示方法。虽然这些方法在特定时期和特定任务上取得了一定的成功,但它们在捕捉深层语义信息方面存在显著的局限性。

1.2.1 词袋模型 (Bag-of-Words, BoW)

  • 原理: 词袋模型是最简单且最基础的文本表示方法之一。它将一段文本(句子或文档)看作是一个无序的词汇集合(一个“袋子”),忽略其语法和词序,只关注每个词在文本中出现的频率。
  • 表示: 通常,一个文档会被表示成一个向量,向量的维度是整个语料库中所有不重复词汇的数量(词典大小)。向量中的每一个元素对应词典中的一个词,其值可以是:
    • 布尔值: 如果词在文档中出现,则为1,否则为0。
    • 词频 (Term Frequency, TF): 词在文档中出现的次数。
  • 示例:
    • 句子1: “the cat sat on the mat”
    • 句子2: “the dog ate the cat”
    • 假设词典: {“the”, “cat”, “sat”, “on”, “mat”, “dog”, “ate”}
    • 句子1的BoW向量 (TF): [2, 1, 1, 1, 1, 0, 0] (对应词典顺序)
    • 句子2的BoW向量 (TF): [2, 1, 0, 0, 0, 1, 1]
  • 局限性:
    1. 忽略词序: “人咬狗” 和 “狗咬人” 在BoW表示下可能非常相似甚至相同(如果只看词频),但它们的语义完全相反。
    2. 无法捕捉语义: BoW无法理解词语之间的语义关系。例如,“苹果”和“香蕉”都是水果,但在BoW中它们是完全独立的维度,无法体现这种相似性。它也无法处理同义词(“漂亮”和“美丽”)和多义词(“bank”)。
    3. 维度灾难与稀疏性: 当语料库非常大时,词典也会非常庞大,导致每个文本的向量维度极高。而对于单个文本而言,它通常只包含词典中一小部分词汇,因此其向量表示会非常稀疏(大部分元素为0)。这给后续的计算和存储带来挑战。
    4. 未考虑词的重要性: 像 “the”, “a”, “is” 这样的停用词(stop words)通常出现频率很高,但对表达文本核心意义的贡献较小。单纯的词频表示会给予这些词过高的权重。

1.2.2 TF-IDF (Term Frequency-Inverse Document Frequency)

  • 原理: TF-IDF 是一种试图改进BoW中未考虑词重要性问题的统计方法。它认为一个词的重要性与其在当前文档中出现的频率(TF)成正比,与其在整个语料库中出现的文档频率(IDF)成反比。
    • TF (Term Frequency, 词频): 某个词在当前文档中出现的频率。
      [ \text{TF}(t, d) = \frac{\text{词 } t \text{ 在文档 } d \text{ 中出现的次数}}{\text{文档 } d \text{ 中的总词数}} ]
      (也有其他计算方式,如直接使用原始计数,或进行对数平滑等)
    • IDF (Inverse Document Frequency, 逆文档频率): 衡量一个词在整个语料库中的普遍程度。如果一个词在很多文档中都出现,则其IDF值较低,说明它区分不同文档的能力较弱。
      [ \text{IDF}(t, D) = \log \frac{\text{语料库 } D \text{ 中的总文档数}}{1 + \text{包含词 } t \text{ 的文档数}} ]
      (分母加1是为了避免词t未在任何文档中出现(虽然不太可能发生)或只在一个文档中出现时导致除以零或log(1)=0的情况)
    • TF-IDF 值:
      [ \text{TF-IDF}(t, d, D) = \text{TF}(t, d) \times \text{IDF}(t, D) ]
  • 表示: 与BoW类似,每个文档仍然表示为一个向量,但向量中每个元素的值是对应词的TF-IDF权重,而不是简单的词频。
  • 优点:
    • 相比纯粹的词频,TF-IDF能够更好地评估一个词对于特定文档的重要性,降低了常见词的权重,提升了稀有但重要的词的权重。
  • 局限性:
    1. 仍然是词袋模型: TF-IDF继承了BoW的大部分局限性,如忽略词序、无法直接捕捉语义相似性(例如,“car” 和 “automobile” 仍被视为不同的词,除非它们在语料中经常共同出现于相似上下文中,但这只是间接影响)。
    2. 依赖大规模语料库: IDF的计算需要一个相对稳定和有代表性的语料库。对于新词或在小语料库中,IDF的估计可能不准确。
    3. 语义鸿沟: 词汇之间的深层语义关系(如上下位关系、反义关系)依然无法被有效表示。

1.2.3 N-gram 模型 (N-gram Models)

  • 原理: 为了部分解决BoW忽略词序的问题,N-gram模型考虑了文本中连续的N个词(或字符)组成的序列。
    • Unigram (1-gram): 单个词,等同于BoW的单位。
    • Bigram (2-gram): 连续的两个词。例如,“the cat sat” -> “the cat”, “cat sat”。
    • Trigram (3-gram): 连续的三个词。例如,“the cat sat” -> “the cat sat”。
  • 表示: 可以将N-gram作为特征,构建类似于BoW的向量表示,其中向量的每个维度对应一个特定的N-gram,其值为该N-gram在文本中出现的频率或TF-IDF值。
  • 优点:
    • 捕捉局部词序: N-gram能够捕捉到一定程度的局部上下文信息和词序关系。例如,"New York"作为一个bigram,比单独的"New"和"York"更能表达特定含义。
    • 对于某些任务(如语言建模、机器翻译的早期阶段),N-gram非常有用。
  • 局限性:
    1. 数据稀疏性问题加剧: 随着N的增大,可能的N-gram组合数量会爆炸式增长,导致特征向量维度更高,数据稀疏性问题更加严重。很多N-gram可能在训练语料中从未出现过。
    2. 无法捕捉长距离依赖: N-gram只能捕捉固定窗口N内的词序关系,对于句子中相隔较远的词之间的语义依赖(如 “The man who lives by the river … is happy.” 中 “man” 和 “is” 的关系)则无能为力。
    3. 语义理解仍然有限: 虽然考虑了局部词序,但N-gram本身并不直接理解这些词组的深层语义。例如,“good movie” 和 “great film” 是语义相似的bigram,但模型本身不知道这一点,除非通过其他方式学习。
    4. N值的选择: N值的选择是一个权衡。N太小,捕捉的上下文有限;N太大,稀疏性问题严重。

1.2.4 传统方法存在的共性问题总结

上述传统文本表示方法(BoW, TF-IDF, N-grams)虽然在NLP发展历程中扮演了重要角色,但它们共同面临一些难以克服的挑战,尤其是在追求深层语义理解的现代NLP任务中:

  1. 语义鸿沟 (Semantic Gap):

    • 核心问题在于它们都是基于离散的、符号化的词汇单元进行表示,无法直接度量或表达词与词、句与句之间的真实语义相似度。
    • 例如,“king” 和 “queen” 在语义上高度相关,但如果仅基于词袋或N-gram,它们的表示向量可能除了上下文共现之外没有任何直接的相似性。
  2. 高维与稀疏性 (High Dimensionality and Sparsity):

    • 当词汇表或N-gram集合非常大时,生成的向量维度非常高,而每个文本向量中非零元素却很少,导致数据稀疏。这不仅增加了计算和存储成本,也可能影响某些机器学习算法的性能。
  3. 缺乏对词序和句法结构的有效建模 (Lack of Effective Modeling of Word Order and Syntactic Structure):

    • BoW完全忽略词序。N-gram虽然考虑了局部词序,但对于长距离依赖和复杂的句法结构则力不从心。
  4. 无法处理未登录词 (Out-of-Vocabulary, OOV) / 新词:

    • 对于在构建词典时未出现过的词,这些方法通常难以处理,或者只能将其忽略或赋予一个特殊的UNK(unknown)标记,丢失了其潜在信息。

这些局限性促使研究者们探索新的文本表示方法,能够将词语、句子乃至文档映射到低维、稠密的向量空间中,并且使得语义上相似的文本在向量空间中的距离也相近。这就是词嵌入(Word Embeddings)和后续句子/文档嵌入(Sentence/Document Embeddings)技术发展的动力。

1.3 词嵌入与文档嵌入思想的演进

为了克服传统文本表示方法的局限性,尤其是语义鸿沟问题,研究者们提出了分布式语义表示(Distributed Semantic Representations)的思想,其中最著名的成果就是词嵌入(Word Embeddings)。

1.3.1 Word2Vec (Skip-gram, CBOW)

  • 提出者与时间: 由Google的Tomas Mikolov团队在2013年提出。

  • 核心思想 (Distributional Hypothesis): “一个词的意义取决于它经常共同出现的词” (You shall know a word by the company it keeps - J.R. Firth)。Word2Vec基于这一思想,通过训练神经网络来学习词的向量表示(词嵌入)。

  • 主要模型架构:

    1. CBOW (Continuous Bag-of-Words):

      • 根据上下文词来预测当前词。
      • 输入是目标词周围的上下文词的词嵌入(通常是简单相加或平均),输出是目标词。
      • 模型学习调整上下文词嵌入和目标词的生成概率。
      • 例如,对于句子 “the quick brown fox jumps over the lazy dog”,上下文窗口为2,预测 “fox”,则上下文词为 “quick”, “brown”, “jumps”, “over”。
        【Python】Python+sentence-transformers框架实现相似文本识别_第1张图片
        (图片释义: CBOW模型结构示意图,输入层是上下文词的one-hot编码,通过共享的嵌入矩阵W得到词嵌入,然后将这些嵌入(例如求和或平均)作为隐层,最后通过输出权重矩阵W’预测中心词的one-hot编码。训练目标是最大化给定上下文时中心词的概率。)
    2. Skip-gram:

      • 根据当前词来预测其上下文词。
      • 输入是目标词的词嵌入,输出是其周围上下文词。
      • 对于一个中心词,模型会预测其在指定窗口大小内的每个上下文词的概率。
      • 例如,对于中心词 “fox”,模型会尝试预测 “quick”, “brown”, “jumps”, “over”。
        【Python】Python+sentence-transformers框架实现相似文本识别_第2张图片
        (图片释义: Skip-gram模型结构示意图,输入层是中心词的one-hot编码,通过嵌入矩阵W得到词嵌入作为隐层,然后通过输出权重矩阵W’分别预测每个上下文词的one-hot编码。训练目标是最大化给定中心词时其上下文词出现的概率。)
  • 训练技巧:

    • Hierarchical Softmax: 一种高效计算Softmax的方法,将输出层的词汇组织成一个霍夫曼树,将预测特定词的问题转化为一系列二分类问题。
    • Negative Sampling: 另一种高效的训练方法。对于每个正样本(中心词和真实上下文词),随机采样一些负样本(中心词和非上下文词),模型的目标是区分正样本和负样本。这比计算完整Softmax要快得多。
  • 特点与贡献:

    • 稠密向量表示: Word2Vec生成的是低维(通常几十到几百维)的稠密向量,相比BoW/TF-IDF的稀疏高维向量,更易于计算和存储。
    • 语义相似性: 学习到的词嵌入能够捕捉词汇间的语义和句法关系。例如,vector('king') - vector('man') + vector('woman') 的结果在向量空间中非常接近 vector('queen')
    • 无监督学习: Word2Vec可以从大规模无标签文本语料中学习词嵌入,无需人工标注数据。
  • 局限性:

    • 无法处理未登录词 (OOV): 对于训练语料中未出现过的词,Word2Vec无法为其生成词嵌入。
    • 无法处理一词多义: 每个词只有一个固定的词嵌入向量,无法区分同一个词在不同上下文中的不同含义(例如 “bank”)。
    • 对词序不敏感(模型本身): Word2Vec模型本身学习的是词的静态表示,单个词嵌入不包含其在具体句子中位置的信息,尽管它通过上下文学习。

1.3.2 GloVe (Global Vectors for Word Representation)

  • 提出者与时间: 由斯坦福大学的Jeffrey Pennington, Richard Socher, Christopher D. Manning在2014年提出。
  • 核心思想: GloVe试图结合两类方法的优点:
    1. 全局矩阵分解方法 (Global Matrix Factorization): 如潜在语义分析 (LSA),直接对词-文档共现矩阵或词-词共现矩阵进行分解,能有效利用全局统计信息。
    2. 局部上下文窗口方法 (Local Context Window): 如Word2Vec (Skip-gram, CBOW),主要关注局部上下文词的关系。
      GloVe认为词-词共现矩阵的统计信息是词嵌入学习的关键。它直接对全局的词-词共现矩阵进行建模,优化目标函数使得词向量的点积与它们在语料库中共同出现的概率的对数相关。
  • 目标函数:
    [ J = \sum_{i,j=1}^{V} f(X_{ij}) (w_i^T \tilde{w}_j + b_i + \tilde{b}j - \log X{ij})^2 ]
    其中:
    • (V) 是词汇表大小。
    • (X_{ij}) 是词 (i) 和词 (j) 在语料库中共同出现的次数(共现矩阵的元素)。
    • (w_i) 是词 (i) 的主词向量 (word vector)。
    • (\tilde{w}_j) 是词 (j) 的上下文词向量 (context word vector)。最终一个词的表示通常是 (w_i + \tilde{w}_j)。
    • (b_i, \tilde{b}_j) 是偏置项。
    • (f(X_{ij})) 是一个权重函数,用于控制不同频次共现对的权重,避免给高频共现对过大的权重,同时也给低频共现对一定的权重。一个常用的 (f(x)) 是:
      [ f(x) = \begin{cases} (x/x_{\max})^\alpha & \text{if } x < x_{\max} \ 1 & \text{otherwise} \end{cases} ]
      通常 (\alpha = 0.75), (x_{\max} = 100)。
  • 特点:
    • 利用全局统计信息: 直接从整个语料库的词-词共现统计中学习词向量。
    • 训练速度快: 相较于依赖神经网络逐窗口迭代的Word2Vec,GloVe在某些实现下训练速度可能更快。
    • 性能优异: 在词语类比、词语相似度等任务上表现良好,与Word2Vec性能相当或略优。
  • 局限性:
    • 与Word2Vec类似,也面临OOV问题和无法处理一词多义的问题。
    • 对共现矩阵的构建比较敏感。

1.3.3 FastText

  • 提出者与时间: 由Facebook AI Research (FAIR) 的Piotr Bojanowski等人于2016年提出。
  • 核心思想: FastText对Word2Vec进行了扩展,主要目的是解决OOV问题并更好地表示形态丰富的语言中的词汇。它将每个词视为一个字符n-gram包 (bag of character n-grams)。
    • 例如,对于词 “apple” 和 n=3,其字符n-grams可能是 “” (通常会加上特殊边界符号表示词首<和词尾>)。
    • 一个词的向量表示是其所有字符n-gram向量的和(或平均)。
  • 模型架构: 类似于Word2Vec的Skip-gram或CBOW,但输入/输出层操作的是字符n-gram的向量。
  • 优点:
    1. 处理未登录词 (OOV): 对于训练时未见过的词,只要其字符n-gram在训练语料中出现过,FastText就能为其构建一个词向量。例如,如果 “applepie” 未登录,但 “apple”, “pie” 以及相关的字符n-gram出现过,FastText可以组合它们的n-gram向量来估计 “applepie” 的向量。
    2. 捕捉形态信息: 由于基于字符n-gram,FastText能够更好地学习词缀(前缀、后缀)等形态学信息,这对于形态复杂的语言(如德语、土耳其语)尤其有效。例如,“unbreakable” 的向量会与其子词 “un-”, “break”, “-able” 的向量相关。
    3. 训练速度快且高效: 实现上进行了优化。
  • 应用: 除了生成词嵌入,FastText也提供了一个非常高效的文本分类工具。
  • 局限性:
    • 生成的词向量维度可能因字符n-gram数量较多而变大(如果直接存储所有n-gram向量)。
    • 虽然改善了OOV,但对于完全没有共享字符n-gram的罕见词或拼写错误,效果依然有限。
    • 仍然无法很好地解决一词多义问题。

1.3.4 从词嵌入到句嵌入的挑战

上述词嵌入方法(Word2Vec, GloVe, FastText)主要关注单个词的表示。然而,在许多NLP任务中,我们需要的是整个句子或段落的向量表示(句嵌入或文档嵌入)。如何从高质量的词嵌入有效地得到高质量的句嵌入,是一个重要且有挑战性的问题:

  1. 简单平均/加权平均 (Simple/Weighted Averaging of Word Embeddings):

    • 最直接的方法是将句子中所有词(或去除停用词后)的词嵌入向量进行求和或求平均。
    • 可以进一步使用TF-IDF权重对词嵌入进行加权平均,赋予重要词更大的权重。
    • 优点: 实现简单,计算快速。
    • 缺点:
      • 忽略词序和句法结构: “A打了B” 和 “B打了A” 的平均词嵌入可能非常相似。
      • 语义合成问题: 简单的线性组合难以捕捉复杂的语义合成现象,例如否定、反讽、条件句等。一个句子的意思往往不等于其组成词意思的简单叠加。
      • 通用性不足: 对于所有句子都采用同一种平均方式,可能无法适应不同句子的特性。
  2. SIF (Smooth Inverse Frequency) 权重:

    • Sanjeev Arora等人在论文 “A Simple but Tough-to-Beat Baseline for Sentence Embeddings” 中提出的一种加权平均方法。
    • 它给每个词向量赋予一个权重 (a / (a + p(w))),其中 (p(w)) 是词 (w) 的估计频率,(a) 是一个平滑参数(通常很小,如0.001)。
    • 然后对加权平均后的句子向量进行一次处理:减去它在所有句子向量集合上的第一个主成分(Common Component Removal)。这一步旨在移除与句法相关但与语义内容关系不大的共性成分(例如,与高频功能词相关的方向)。
    • 优点: 简单且在某些基准测试上效果出奇地好,甚至能超过一些复杂的神经网络模型。
    • 缺点: 仍然是基于词袋的思想,对词序和复杂语义的捕捉能力有限。
  3. 基于RNN/LSTM/GRU的方法:

    • 使用循环神经网络(RNN)及其变体(如LSTM, GRU)来处理词序列。网络的最后一个隐藏状态,或者所有时间步隐藏状态的池化(如平均池化、最大池化),可以作为句子的向量表示。
    • 优点: 能够捕捉词序信息和长距离依赖。
    • 缺点:
      • RNN的顺序处理特性使得并行计算困难,训练速度相对较慢。
      • 对于非常长的句子,仍然可能存在梯度消失/爆炸问题(尽管LSTM/GRU有所缓解)。
      • 信息瓶颈:将整个句子的信息压缩到最后一个隐藏状态可能导致信息损失。
  4. 基于CNN的方法:

    • 使用一维卷积神经网络(1D CNN)在词嵌入序列上进行卷积操作,提取局部n-gram特征,然后通过池化层(如最大池化)得到句子表示。
    • 优点: 能够有效捕捉局部特征(类似N-gram但作用于嵌入空间),并行计算效率高。
    • 缺点: CNN的感受野有限,对于捕捉全局或长距离的语义依赖不如RNN或Transformer直接。

这些从词嵌入到句嵌入的方法各有优劣,但在Transformer模型出现之前,它们是主流的句表示学习技术。然而,它们在处理上下文依赖、复杂语义和长距离关系方面仍有提升空间。Transformer架构的出现,为解决这些问题提供了强大的新工具。

1.4 Transformer 模型与预训练语言模型

Transformer模型的提出是自然语言处理领域的一大突破,它彻底改变了序列建模的方式,并为后续大规模预训练语言模型(Pre-trained Language Models, PLMs)的成功奠定了基础。

1.4.1 Transformer 架构简介

  • 提出者与时间: 由Google团队在2017年的论文 “Attention Is All You Need” 中提出,最初用于机器翻译任务。

  • 核心思想: 完全摒弃了传统的循环(Recurrence, 如RNN)和卷积(Convolution, 如CNN)结构,仅依赖自注意力机制 (Self-Attention Mechanism) 来捕捉输入序列内部的依赖关系以及输入输出序列之间的对齐关系。

  • 主要组成部分:

    1. 编码器 (Encoder): 由N个相同的层堆叠而成。每层包含两个主要的子层:

      • 多头自注意力层 (Multi-Head Self-Attention Layer): 允许模型在编码序列中每个位置的表示时,同时关注序列中所有其他位置的信息,并根据相关性赋予不同权重。
      • 位置前馈网络 (Position-wise Feed-Forward Network): 一个简单的全连接前馈网络,独立地应用于每个位置。
        每个子层后面都跟着一个残差连接 (Residual Connection) 和层归一化 (Layer Normalization)。
    2. 解码器 (Decoder): 也由N个相同的层堆叠而成。每层除了编码器层中的两个子层外,还插入了第三个子层:

      • 掩码多头自注意力层 (Masked Multi-Head Self-Attention Layer): 用于解码器自身,确保在预测当前位置时只能关注到已生成的部分,不能看到未来的信息。
      • 编码器-解码器注意力层 (Encoder-Decoder Attention Layer): 允许解码器的每个位置关注编码器输出序列中的所有位置,实现输入和输出之间的对齐。
        同样,每个子层后有残差连接和层归一化。
    3. 输入嵌入 (Input Embedding):

      • 词嵌入 (Token Embedding): 将输入的词(token)转换为向量。
      • 位置编码 (Positional Encoding): 由于Transformer没有循环结构,无法天然捕捉序列顺序。因此,需要向输入嵌入中加入位置编码,以提供关于词在序列中位置的信息。原始Transformer使用正弦和余弦函数生成位置编码。
  • 自注意力机制 (Self-Attention) 详解:
    自注意力机制是Transformer的核心。对于输入序列中的每个词,它会计算三个向量:

    • 查询向量 (Query, Q): 代表当前词,用于去查询其他词。
    • 键向量 (Key, K): 代表序列中的其他词(包括自身),用于被查询。
    • 值向量 (Value, V): 代表序列中的其他词(包括自身)的内容。
      注意力权重的计算过程如下:
    1. 打分 (Score): 计算一个Query向量与所有Key向量的点积(或其他相似度函数)。
      [ \text{score}(Q, K_i) = Q \cdot K_i ]
    2. 缩放 (Scale): 将点积结果除以 (\sqrt{d_k})((d_k) 是键向量的维度),以防止梯度过小。
      [ \frac{Q \cdot K_i}{\sqrt{d_k}} ]
    3. 归一化 (Softmax): 对缩放后的分数应用Softmax函数,得到注意力权重,表示当前Query应该对各个Value赋予多大的注意力。
      [ \alpha_i = \text{softmax}\left(\frac{Q \cdot K_i}{\sqrt{d_k}}\right) ]
    4. 加权求和 (Weighted Sum): 将注意力权重与对应的Value向量相乘并求和,得到该Query位置的输出表示。
      [ \text{Attention}(Q, K, V) = \sum_i \alpha_i V_i ]
      多头注意力 (Multi-Head Attention): Transformer并非只执行一次注意力计算,而是将Q, K, V向量通过不同的线性变换投影到多个“头”(head),并行地执行多次注意力计算。每个头学习不同的注意力模式(例如,一个头可能关注句法关系,另一个头关注语义相关性)。然后将所有头的输出拼接起来并通过一个线性变换得到最终结果。这增强了模型从不同表示子空间中共同关注信息的能力。
  • 优点:

    1. 并行计算能力强: 由于自注意力机制可以直接计算序列中任意两个位置之间的关系,不像RNN那样需要顺序处理,因此非常适合并行计算,大大加快了训练速度。
    2. 有效捕捉长距离依赖: 注意力机制可以直接建立序列中远距离词之间的联系,路径长度为O(1),克服了RNN难以捕捉长距离依赖的问题。
    3. 模型表达能力强: 多头注意力机制和深层结构使得Transformer具有强大的特征提取和表示学习能力。

1.4.2 BERT (Bidirectional Encoder Representations from Transformers)

  • 提出者与时间: 由Google AI Language团队的Jacob Devlin等人在2018年底提出。

  • 核心思想: BERT利用Transformer的编码器部分,通过设计巧妙的预训练任务,在大规模无标签文本语料上进行训练,从而学习到深度的双向语言表示。这些预训练好的模型可以作为“基础模型”,通过在其上添加少量特定任务层并进行微调(fine-tuning),就能在广泛的NLP任务上取得SOTA(State-of-the-Art)或接近SOTA的性能。

  • 架构: BERT本质上是一个多层双向Transformer编码器堆栈。常见的有 BERT-Base (12层, 768维隐藏单元, 12个注意力头, 约1.1亿参数) 和 BERT-Large (24层, 1024维隐藏单元, 16个注意力头, 约3.4亿参数)。

  • 输入表示: BERT的输入是一个或两个句子序列,每个序列的开头会添加一个特殊的 [CLS] 标记,句子之间用 [SEP] 标记分隔。输入给模型的是三种嵌入的和:

    1. 词嵌入 (Token Embeddings): 使用WordPiece分词方法将词切分为子词单元,然后查找对应的嵌入。
    2. 片段嵌入 (Segment Embeddings): 用于区分输入中的不同句子(例如,在问答任务中区分问题和段落)。第一个句子所有词的片段嵌入为A,第二个句子为B。
    3. 位置嵌入 (Position Embeddings): 与Transformer的位置编码不同,BERT的位置嵌入是学习得到的,而不是固定的。
  • 预训练任务 (Pre-training Tasks): BERT的强大能力主要归功于其两个创新的预训练任务:

    1. MLM (Masked Language Model, 掩码语言模型):

      • 动机: 传统的从左到右或从右到左的语言模型是单向的,无法充分利用上下文信息。为了实现双向表示学习,BERT采用了MLM。
      • 做法: 在输入句子中随机选择15%的词进行掩码操作:
        • 80%的概率用特殊的 [MASK] 标记替换。
        • 10%的概率用一个随机词替换。
        • 10%的概率保持原词不变(为了使模型不仅学习 [MASK],也学习对真实词的表示)。
      • 目标: 模型需要根据被掩码词周围的上下文(双向信息)来预测这些被掩码的原始词。这迫使模型学习词与词之间的深层语义和句法关系。
    2. NSP (Next Sentence Prediction, 下一句预测):

      • 动机: 许多NLP任务(如问答、自然语言推断)需要理解句子间的关系,而MLM主要关注词级别的表示。
      • 做法: 对于输入的句子对(A, B),有50%的概率B是A的真实下一句(IsNext),另外50%的概率B是从语料库中随机选择的一个句子(NotNext)。
      • 目标: 模型需要预测句子B是否是句子A的下一句。这个任务使得 [CLS] 标记的最终隐藏状态能够聚合整个输入序列对的表示,用于句子级别的预测。
  • 如何用于获取句子表示 (How BERT is used for sentence representation for downstream tasks):

    • [CLS] 标记的输出: 对于句子(对)分类任务(如情感分析、NLI),通常取 [CLS] 标记对应的最后一层Transformer的隐藏状态输出,然后在其上接一个简单的分类器(如Softmax层)进行微调。NSP任务的设计使得 [CLS] 的表示能够概括整个输入序列的信息。
    • 词向量序列的池化 (Pooling Strategies): 也可以取所有词(或非填充词)的最后一层隐藏状态序列,然后进行池化操作(如平均池化或最大池化)来得到整个句子的固定长度表示。
    • 直接使用词向量: 对于序列标注任务(如命名实体识别),可以直接使用每个词对应的最后一层隐藏状态。
  • BERT 的影响: BERT的出现极大地推动了NLP领域的发展,开启了大规模预训练语言模型的新时代。它证明了通过在大规模数据上进行无监督预训练,然后针对特定任务进行微调的范式,可以显著提升模型性能并减少对大量标注数据的依赖。

1.4.3 其他重要的预训练模型 (Other important PLMs)

BERT之后,涌现了大量基于Transformer的优秀预训练语言模型,它们在BERT的基础上进行了各种改进和创新:

  1. RoBERTa (Robustly Optimized BERT Pretraining Approach):

    • 由Facebook AI提出,发现BERT可能预训练不足,并通过改进预训练策略(如使用更大的数据集、更大的批次、更长的训练时间、移除NSP任务、动态掩码等)获得了比BERT更好的性能。
  2. XLNet:

    • 由CMU和Google Brain提出,是一种自回归预训练模型(AR, Autoregressive),但通过排列语言模型(Permutation Language Modeling)的方式实现了双向上下文的学习。它不像BERT那样使用 [MASK] 标记,避免了预训练和微调之间的不一致性。
  3. ALBERT (A Lite BERT for Self-supervised Learning of Language Representations):

    • 由Google Research提出,旨在减少BERT的参数量,提高训练和推理效率。主要通过两种方式:
      • 词嵌入参数化分解: 将大的词嵌入矩阵分解为两个小的矩阵。
      • 跨层参数共享: 不同Transformer层之间共享参数。
    • ALBERT在参数量远小于BERT的情况下,在多个NLP任务上取得了与BERT相当甚至更好的性能。
  4. ELECTRA (Efficiently Learning an Encoder that Classifies Token Replacements Accurately):

    • 由Google Research和Stanford提出,引入了一种新的预训练任务——替换词检测 (Replaced Token Detection, RTD)。它使用一个小型的生成器网络来替换输入序列中的一些词,然后训练一个判别器网络来判断每个词是被替换过的还是原始的。这种方式比MLM更具样本效率,因为判别器需要对序列中的每个词进行预测,而不是只对被掩码的词进行预测。
  5. DistilBERT:

    • 由Hugging Face团队提出,通过知识蒸馏技术将BERT模型压缩成一个更小、更快的版本,同时保留了BERT大部分的性能。适合对推理速度和模型大小有要求的场景。
  6. T5 (Text-to-Text Transfer Transformer):

    • 由Google提出,将所有NLP任务都统一为“文本到文本”(text-to-text)的格式。例如,翻译任务的输入是 “translate English to German: That is good.”,输出是 “Das ist gut.”;摘要任务的输入是 “summarize: [article text]”,输出是摘要。

这些模型以及更多其他的PLMs,都在不断推动自然语言理解能力的边界。sentence-transformers 框架正是构建在这些强大的预训练模型之上,通过特定的微调策略,使其更擅长生成高质量的句子嵌入。

1.5 sentence-transformers 框架的诞生与核心价值

尽管像BERT这样的预训练语言模型在许多NLP任务中表现出色,但直接使用它们(例如,通过平均其词嵌入或使用 [CLS] 输出)来获取句子嵌入以进行语义相似度比较或无监督任务(如聚类、信息检索)时,效果往往不尽如人意,有时甚至不如简单的GloVe词嵌入平均。

1.5.1 为什么需要 sentence-transformers

  • BERT/RoBERTa的原始输出不适合直接用于语义相似性:

    • Nils Reimers和Iryna Gurevych在其论文 “Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks”(sentence-transformers框架的奠基性工作)中指出,未经微调的BERT输出的句子嵌入在向量空间中的分布并不理想。相似的句子在嵌入空间中可能并不靠近,导致余弦相似度等度量无法有效衡量语义相似性。
    • [CLS] 输出主要针对分类任务进行优化,而平均池化策略也可能丢失过多信息或引入噪声。
    • 这是因为BERT的预训练目标(MLM和NSP)与生成语义上一致的句子嵌入的目标并不完全对齐。
  • 计算效率问题:

    • 如果需要比较大量的句子对(例如,在大规模语料库中查找最相似的句子),使用原始BERT进行两两推理(将两个句子拼接后输入BERT)的计算成本非常高昂。假设有N个句子,需要进行 N*(N-1)/2 次BERT推理,复杂度是O(N^2)。
    • 理想的情况是,能够为每个句子独立生成一个高质量的固定长度嵌入向量,然后通过计算这些向量之间的余弦相似度(或其他距离度量)来快速比较,复杂度可以降至O(N)(生成所有嵌入)+ O(1)(单对比较)或使用ANN索引进行高效检索。

sentence-transformers 框架的出现,正是为了解决上述问题,使得基于Transformer的模型能够高效地生成用于语义相似度任务的高质量句子嵌入。

1.5.2 核心思想:孪生网络 (Siamese Networks) 和三元组网络 (Triplet Networks) 微调

sentence-transformers 的核心在于它采用了特定的网络结构和损失函数对预训练的Transformer模型(如BERT, RoBERTa)进行微调,使其输出的句子嵌入更适合语义相似性度量。

  1. 孪生网络 (Siamese Network) 结构:

    • 使用两个或多个共享权重的BERT(或其他Transformer)网络。
    • 对于一个句子对 (sentence_A, sentence_B),它们分别通过同一个BERT网络(权重共享)得到各自的句子嵌入 uv
    • 然后,这两个嵌入向量 uv 被输入到一个特定的目标函数中进行优化。
    • 池化策略: 在BERT的词向量序列输出之后,会接一个池化层(通常是平均池化 MEAN-Pooling,有时也用 MAX-PoolingCLS-Pooling,但MEAN-Pooling 在SBERT论文中表现较好)来得到固定长度的句子嵌入。
      【Python】Python+sentence-transformers框架实现相似文本识别_第3张图片
      (图片释义: SBERT (Sentence-BERT) 架构图。两个句子Sentence A和Sentence B分别通过同一个BERT模型(权重共享),然后经过池化操作(Pooling)得到句子嵌入u和v。这两个嵌入可以用于各种目标函数,如计算余弦相似度并与真实标签比较。)
  2. 微调的目标函数 (Objective Functions):
    根据下游任务的不同,可以选择不同的目标函数进行微调:

    • 分类目标 (Classification Objective):
      • 适用于自然语言推断(NLI)等任务,如SNLI、MultiNLI数据集。
      • 将句子嵌入 uv,以及它们的差的绝对值 |u-v| 拼接起来,然后通过一个Softmax分类器来预测它们之间的关系(如蕴含、矛盾、中立)。
      • 公式: ( o = \text{softmax}(W_t (u, v, |u-v|)) )
    • 回归目标 (Regression Objective):
      • 适用于语义文本相似度(STS)等任务,如STS benchmark数据集。
      • 计算句子嵌入 uv 之间的余弦相似度,然后使用均方误差(MSE)损失函数来最小化预测相似度与真实人工标注相似度(0-1或0-5之间的分数)之间的差异。
    • 三元组目标 (Triplet Objective):
      • 适用于需要区分相似和不相似样本的场景,例如确保一个锚点句子 a 与一个正例句子 p (相似) 的距离小于它与一个反例句子 n (不相似) 的距离,并且两者之间至少有一个边界 epsilon
      • 损失函数: ( \max(0, ||u_a - u_p|| - ||u_a - u_n|| + \epsilon) )
      • 这种损失函数能够很好地优化嵌入空间的结构,使得相似的句子聚集在一起,不相似的句子被推开。

通过在这些特定任务和目标函数上进行微调,sentence-transformers 使得基础的Transformer模型学会生成语义上更有意义、在向量空间中分布更合理的句子嵌入。

1.5.3 sentence-transformers 的优势

  1. 高质量的句子嵌入 (High-Quality Sentence Embeddings):

    • 经过特定微调后,生成的句子嵌入能够更好地捕捉语义信息,使得语义相似的句子在向量空间中距离更近。
    • 在多个语义相似度基准测试(如STS benchmark)上取得了SOTA或接近SOTA的性能。
  2. 易用性 (Ease of Use):

    • 提供了非常简洁直观的API。加载预训练模型、编码句子、计算相似度等操作都只需要几行代码。
    • 屏蔽了底层Transformer模型和微调过程的复杂性,用户可以直接使用效果优良的预训练句子嵌入模型。
  3. 丰富的预训练模型 (Variety of Pre-trained Models):

    • sentence-transformers 提供了大量针对不同语言(英语、中文、德语、多语言等)、不同任务(语义搜索、释义挖掘、NLI等)和不同性能/速度权衡(如基于BERT-large的高性能模型,基于DistilBERT的轻量级模型)的预训练句子嵌入模型。
    • 这些模型可以直接从Hugging Face Model Hub下载使用。
  4. 计算效率高 (Computationally Efficient for Similarity Comparison):

    • 一旦为句子生成了嵌入向量,就可以使用高效的向量相似度计算方法(如余弦相似度)或近似最近邻搜索库(如FAISS, Annoy)进行大规模的相似性比较和检索,避免了原始BERT两两比较的低效。
  5. 灵活性与可扩展性 (Flexibility and Extensibility):

    • 支持用户使用自己的数据和任务对现有模型进行进一步微调,以适应特定领域或提升特定性能。
    • 可以轻松地将 sentence-transformers 集成到更复杂的NLP应用流程中。
  6. 支持多种任务:

    • 除了核心的句子相似度计算,还可以方便地应用于语义搜索、聚类、释义挖掘、零样本分类等多种下游任务。

第二章:快速入门 sentence-transformers

2.1 安装 sentence-transformers

sentence-transformers 框架依赖于 PyTorch 和 Hugging Face Transformers 库。因此,在安装 sentence-transformers 之前,建议先根据您的环境(CPU 或 GPU,CUDA 版本)安装 PyTorch。

2.1.1 安装 PyTorch

您可以访问 PyTorch 官方网站 (https://pytorch.org/get-started/locally/) 获取适合您系统的安装命令。

例如,如果您的系统支持 CUDA 11.8,可以使用 pip 安装 PyTorch 的命令可能如下(请务必访问官网确认最新命令):

pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118

pip: Python的包安装器。

install: pip的安装命令。

torch: PyTorch核心库。

torchvision: 包含流行的数据集、模型架构和计算机视觉的图像转换工具。

torchaudio: 包含音频处理相关的数据集、模型和工具。

–index-url https://download.pytorch.org/whl/cu118: 指定从PyTorch官方的CUDA 11.8版本的wheel文件索引下载。如果您的CUDA版本不同,或者您只想安装CPU版本,请修改此命令。

如果只需要 CPU 版本,命令通常更简单:

pip install torch torchvision torchaudio

pip: Python的包安装器。

install: pip的安装命令。

torch: PyTorch核心库。

torchvision: 包含流行的数据集、模型架构和计算机视觉的图像转换工具。

torchaudio: 包含音频处理相关的数据集、模型和工具。这里没有指定CUDA版本,pip会尝试安装与您系统兼容的CPU版本或默认CUDA版本。

2.1.2 安装 sentence-transformers

一旦 PyTorch 安装完成,就可以通过 pip 直接安装 sentence-transformers

pip install -U sentence-transformers

pip: Python的包安装器。

install: pip的安装命令。

-U: (或 --upgrade) 表示如果已安装,则升级到最新版本。

sentence-transformers: sentence-transformers 库的名称。

这条命令会自动处理并安装其依赖项,包括 Hugging Face Transformers 等。

2.1.3 验证安装

安装完成后,您可以打开 Python 解释器并尝试导入该库来验证安装是否成功:

try:
    from sentence_transformers import SentenceTransformer # 尝试从 sentence_transformers 包中导入 SentenceTransformer 类
    print("SentenceTransformers SUCESSFULLY imported!") # 如果导入成功,打印成功消息
except ImportError:
    print("SentenceTransformers FAILED to import. Please check your installation.") # 如果导入失败,打印失败消息
    print("Ensure you have PyTorch installed correctly first (see https://pytorch.org/).") # 提示用户检查PyTorch安装
    print("Then, install sentence-transformers using: pip install -U sentence-transformers") # 提示用户安装sentence-transformers的命令

如果看到 “SentenceTransformers SUCESSFULLY imported!” 的输出,说明安装成功。

2.2 sentence-transformers 的核心组件:SentenceTransformer

sentence-transformers 框架的核心是 SentenceTransformer 类。这个类负责加载预训练的句子嵌入模型,并将输入的文本句子转换为高质量的数值向量(嵌入)。

2.2.1 加载预训练模型

SentenceTransformer 类的构造函数接收一个模型名称或模型路径作为参数。该框架集成了大量在不同数据集和任务上预训练好的模型,这些模型托管在 Hugging Face Model Hub 上。

您可以在 sentence-transformers 的官方文档或 Hugging Face Model Hub (https://huggingface.co/models?library=sentence-transformers) 上找到可用的预训练模型列表及其特性。

一些常用的预训练模型包括:

  • 'all-MiniLM-L6-v2': 一个非常流行且高效的模型,性能良好,速度快,嵌入维度为384。非常适合作为通用的起点。
  • 'all-mpnet-base-v2': 目前(截至我知识更新时)在语义文本相似度任务上表现最佳的通用模型之一,嵌入维度为768。性能优于 all-MiniLM-L6-v2,但速度稍慢。
  • 'paraphrase-multilingual-MiniLM-L12-v2': 支持多种语言(超过50种)的释义识别模型。
  • 'clip-ViT-B-32': 一个多模态模型,可以为图像和文本生成嵌入,使得可以计算图文相似度。
  • 特定语言模型,例如中文模型:
    • 'shibing624/text2vec-base-chinese' (由shibing624贡献)
    • 'DMetaSoul/sbert-chinese-general-v2' (由DMetaSoul贡献)
    • 以及HuggingFace Hub上其他用户贡献的针对特定中文任务微调的模型。

代码示例:加载一个预训练模型

from sentence_transformers import SentenceTransformer # 从 sentence_transformers 包中导入 SentenceTransformer 类

# 模型名称可以从 Hugging Face Model Hub 或 sentence-transformers 文档中找到
# 第一次加载模型时,它会自动从网上下载并缓存到本地
# 默认缓存路径通常是 ~/.cache/torch/sentence_transformers/
# 或者 ~/.cache/huggingface/hub/ 对于一些新模型

# 加载一个通用的英文模型 'all-MiniLM-L6-v2'
model_name_en = 'all-MiniLM-L6-v2' # 定义英文模型的名称
try:
    model_en = SentenceTransformer(model_name_en) # 初始化 SentenceTransformer 类,传入模型名称以加载模型
    print(f"成功加载英文模型: {
     
     model_name_en}") # 打印成功加载的消息
except Exception as e:
    print(f"加载英文模型 {
     
     model_name_en} 失败: {
     
     e}") # 如果加载失败,打印错误信息

# 加载一个常用的中文模型 'shibing624/text2vec-base-chinese'
# 请确保你的环境中可以访问 Hugging Face Hub,或者模型已经被下载到缓存中
model_name_zh = 'shibing624/text2vec-base-chinese' # 定义中文模型的名称
try:
    model_zh = SentenceTransformer(model_name_zh) # 初始化 SentenceTransformer 类,传入模型名称以加载模型
    print(f"成功加载中文模型: {
     
     model_name_zh}") # 打印成功加载的消息
except Exception as e:
    print(f"加载中文模型 {
     
     model_name_zh} 失败: {
     
     e}") # 如果加载失败,打印错误信息
    print("请确保您可以访问Hugging Face Hub,或者该模型已下载。") # 提示可能的原因
    print("对于一些社区模型,可能需要transformers库版本较新。尝试 `pip install -U transformers`") # 提示更新transformers库

# 你也可以指定设备(例如,如果你有GPU并想使用它)
# model = SentenceTransformer('all-MiniLM-L6-v2', device='cuda') # 'cuda' 表示使用第一个可用的GPU
# model = SentenceTransformer('all-MiniLM-L6-v2', device='cpu') # 明确指定使用CPU

# 检查模型使用的设备
if 'model_en' in locals() and model_en is not None: # 检查 model_en 是否已成功加载
    print(f"英文模型 '{
     
     model_name_en}' 运行在设备: {
     
     model_en.device}") # 打印模型运行的设备
if 'model_zh' in locals() and model_zh is not None: # 检查 model_zh 是否已成功加载
    print(f"中文模型 '{
     
     model_name_zh}' 运行在设备: {
     
     model_zh.device}") # 打印模型运行的设备

代码解释:

  • from sentence_transformers import SentenceTransformer: 导入核心类。
  • model_name_en = 'all-MiniLM-L6-v2': 这是一个字符串,指定了要加载的预训练模型的名称。这个名称对应于 Hugging Face Model Hub 上的一个模型标识符。
  • model_en = SentenceTransformer(model_name_en): 创建 SentenceTransformer 类的实例。在实例化时,框架会自动处理以下事务:
    1. 检查本地缓存: 查看该模型是否已经被下载到本地缓存目录。
    2. 下载模型: 如果模型不在缓存中,它会从 Hugging Face Model Hub 下载模型文件(包括配置文件、模型权重、词汇表等)。
    3. 加载模型架构和权重: 根据下载的配置文件构建底层的 Transformer 模型(如BERT, RoBERTa等)并加载预训练的权重。
    4. 加载池化层: sentence-transformers 的模型通常在 Transformer 层之上还有一个池化层(如平均池化),用于将词级别输出转换为句子级别嵌入。这个池化配置也会被加载。
  • 缓存机制: 模型下载后会缓存在本地,默认路径在类Unix系统上是 ~/.cache/torch/sentence_transformers/~/.cache/huggingface/hub/ (对于通过 transformers 库加载的模型)。这意味着后续再次加载同一个模型时,会直接从本地缓存读取,速度会快很多。
  • device 参数: SentenceTransformer 的构造函数可以接受一个 device 参数,用于指定模型应该加载到哪个设备上运行(例如 'cpu', 'cuda', 'cuda:0')。如果未指定,它通常会自动检测可用的GPU,否则使用CPU。
  • 错误处理: 使用 try-except 块来捕获模型加载过程中可能发生的错误(例如网络问题、模型不存在、依赖库版本不兼容等)。

2.2.2 编码句子:获取句子嵌入

一旦 SentenceTransformer 对象被成功加载,就可以使用其 encode() 方法将单个句子或句子列表转换为它们的嵌入向量。

encode() 方法的关键参数:

  • sentences (str or List[str]): 需要编码的单个句子(字符串)或句子列表(字符串列表)。
  • batch_size (int, optional, defaults to 32): 如果输入是句子列表,编码过程会分批进行。此参数控制每批处理的句子数量。对于GPU,选择一个合适的批次大小可以充分利用其并行计算能力,提高效率。
  • show_progress_bar (bool, optional, defaults to None): 是否显示编码过程的进度条。如果为 None 且输入的句子数量较多(例如超过10个),则会自动显示。
  • output_value (str, optional, defaults to 'sentence_embedding'): 指定输出值的类型。
    • 'sentence_embedding': 返回句子嵌入(默认行为)。
    • 'token_embeddings': 返回每个词(token)的上下文嵌入。
  • convert_to_numpy (bool, optional, defaults to True): 如果为 True,返回的嵌入是 NumPy 数组。如果为 False,返回的是 PyTorch 张量 (torch.Tensor)。
  • convert_to_tensor (bool, optional, defaults to False): 如果为 True,返回 PyTorch 张量。如果 convert_to_numpy 也是 True,则此参数被忽略。
  • device (str, optional, defaults to None): 指定用于编码的设备(例如 'cuda', 'cpu')。如果模型已加载到特定设备,则通常不需要再次指定,除非想临时切换。
  • normalize_embeddings (bool, optional, defaults to False): 如果为 True,返回的句子嵌入会被归一化(L2范数归一化),使得它们的长度为1。这对于使用余弦相似度进行比较非常有用,因为归一化后的向量点积等价于余弦相似度。许多预训练的 sentence-transformers 模型在训练时就期望嵌入被归一化后使用。

代码示例:编码单个句子和句子列表

from sentence_transformers import SentenceTransformer # 导入 SentenceTransformer 类
import numpy as np # 导入 numpy 用于后续操作

# 确保模型已加载 (我们使用之前加载的英文模型 'all-MiniLM-L6-v2' 为例)
# 如果没有,请先运行2.2.1中的加载代码
model_name_en = 'all-MiniLM-L6-v2' # 定义模型名称
try:
    model = SentenceTransformer(model_name_en) # 加载模型
    print(f"模型 {
     
     model_name_en} 加载成功。") # 打印成功消息
except Exception as e:
    print(f"加载模型 {
     
     model_name_en} 失败: {
     
     e}") # 打印失败消息
    model = None # 将模型设为None,以便后续检查

if model: # 只有当模型成功加载后才执行编码操作
    # 1. 编码单个句子
    sentence_single = "This is an example sentence." # 定义一个英文句子
    embedding_single = model.encode(sentence_single) # 使用模型的encode方法获取句子的嵌入向量
                                                    # 默认返回numpy数组

    print(f"\n编码单个句子: '{
     
     sentence_single}'") # 打印提示信息
    print(f"嵌入向量 (前5个维度): {
     
     embedding_single[:5]}") # 打印嵌入向量的前5个维度
    print(f"嵌入向量的形状: {
     
     embedding_single.shape}") # 打印嵌入向量的形状 (对于 'all-MiniLM-L6-v2' 应该是 (384,))
    print(f"嵌入向量的数据类型: {
     
     type(embedding_single)}") # 打印嵌入向量的数据类型 (应该是 numpy.ndarray)

    # 2. 编码句子列表
    sentences_list = [
        "The weather is sunny today.",
        "I enjoy reading books about science fiction.",
        "Sentence embeddings are useful for semantic search."
    ] # 定义一个英文句子列表
    embeddings_list = model.encode(sentences_list) # 使用模型的encode方法获取句子列表的嵌入向量
                                                  # 返回一个numpy数组的列表,或者一个二维numpy数组

    print(f"\n编码句子列表:") # 打印提示信息
    for i, sentence in enumerate(sentences_list): # 遍历句子列表和它们的索引
        print(f"句子 {
     
     i+1}: '{
     
     sentence}'") # 打印当前句子
        print(f"  嵌入向量 (前5个维度): {
     
     embeddings_list[i, :5]}") # 打印对应嵌入向量的前5个维度
    print(f"所有嵌入向量的形状: {
     
     embeddings_list.shape}") # 打印所有嵌入向量的形状 (应该是 (3, 384) 对于3个句子和384维嵌入)
    print(f"所有嵌入向量的数据类型: {
     
     type(embeddings_list)}") # 打印嵌入向量的数据类型 (应该是 numpy.ndarray)

    # 3. 编码时返回 PyTorch 张量
    embedding_tensor = model.encode(sentence_single, convert_to_tensor=True) # 编码单个句子,并指定返回 PyTorch 张量
    print(f"\n编码单个句子 (返回 PyTorch 张量): '{
     
     sentence_single}'") # 打印提示信息
    print(f"嵌入张量 (前5个维度): {
     
     embedding_tensor[:5]}") # 打印嵌入张量的前5个维度
    print(f"嵌入张量的形状: {
     
     embedding_tensor.shape}") # 打印嵌入张量的形状
    print(f"嵌入张量的数据类型: {
     
     type(embedding_tensor)}") # 打印嵌入张量的数据类型 (应该是 torch.Tensor)
    print(f"嵌入张量所在的设备: {
     
     embedding_tensor.device}") # 打印嵌入张量所在的设备

    # 4. 编码时对嵌入进行归一化
    # 许多sentence-transformer模型在计算余弦相似度之前期望嵌入是归一化的
    # 'all-MiniLM-L6-v2' 在其文档中通常建议使用归一化嵌入
    embedding_normalized = model.encode(sentence_single, normalize_embeddings=True) # 编码单个句子,并进行归一化
    print(f"\n编码单个句子 (归一化嵌入): '{
     
     sentence_single}'") # 打印提示信息
    print(f"归一化嵌入 (前5个维度): {
     
     embedding_normalized[:5]}") # 打印归一化嵌入的前5个维度
    # 验证归一化 (L2 范数应该接近 1)
    l2_norm = np.linalg.norm(embedding_normalized) # 使用numpy计算L2范数
    print(f"归一化嵌入的L2范数: {
     
     l2_norm}") # 打印L2范数,它应该非常接近1

    # 5. 编码中文句子 (需要加载中文模型)
    model_name_zh = 'shibing624/text2vec-base-chinese' # 定义中文模型名称
    try:
        model_zh = SentenceTransformer(model_name_zh) # 加载中文模型
        print(f"\n中文模型 {
     
     model_name_zh} 加载成功。") # 打印成功消息

        chinese_sentences = [
            "今天天气怎么样?",
            "我喜欢看科幻小说。",
            "这是一个中文句子的例子。"
        ] # 定义一个中文句子列表
        embeddings_zh = model_zh.encode(chinese_sentences, show_progress_bar=True) # 使用中文模型编码句子列表,并显示进度条

        print(f"\n编码中文句子列表:") # 打印提示信息
        for i, sentence in enumerate(chinese_sentences): # 遍历中文句子列表
            print(f"句子 {
     
     i+1}: '{
     
     sentence}'") # 打印当前句子
            print(f"  嵌入向量 (前5个维度): {
     
     embeddings_zh[i, :5]}") # 打印嵌入向量的前5个维度
        print(f"所有中文嵌入向量的形状: {
     
     embeddings_zh.shape}") # 打印嵌入向量的形状 ('shibing624/text2vec-base-chinese' 通常是768维)

    except Exception as e:
        print(f"处理中文模型 {
     
     model_name_zh} 时发生错误: {
     
     e}") # 打印错误信息

else:
    print("模型未能成功加载,跳过编码示例。") # 如果模型加载失败,打印此消息

代码解释:

  • model.encode(sentence_single): 将单个字符串输入 encode 方法,返回该句子的嵌入向量。
  • model.encode(sentences_list): 将字符串列表输入 encode 方法,返回一个包含所有句子嵌入的二维数组(或张量)。每一行对应一个句子的嵌入。
  • embedding_single.shape: 对于 all-MiniLM-L6-v2 模型,单个句子的嵌入维度是384,所以形状是 (384,)。对于 shibing624/text2vec-base-chinese,维度通常是768。
  • embeddings_list.shape: 如果有 N 个句子,嵌入维度是 D,则形状是 (N, D)
  • convert_to_tensor=True: 使 encode 方法返回 torch.Tensor 而不是 numpy.ndarray。这在需要将嵌入直接用于 PyTorch 后续计算(如自定义损失函数、模型层)时很有用。
  • normalize_embeddings=True: 在返回嵌入之前对其进行L2归一化。归一化后的向量长度为1。
    • L2范数计算 np.linalg.norm(embedding_normalized) 用于验证归一化效果。对于精确归一化的向量,其L2范数应为1.0。由于浮点数精度问题,实际值可能非常接近1,例如0.999999…。
  • show_progress_bar=True: 当处理大量句子时,显示一个进度条,方便用户了解编码进度。
  • 模型与语言匹配: 非常重要的一点是,要使用与待处理文本语言相匹配的预训练模型。用英文模型去编码中文句子(反之亦然)通常不会得到有意义的嵌入。如果需要处理多种语言,可以考虑使用多语言模型(如 'paraphrase-multilingual-MiniLM-L12-v2')或针对每种语言使用特定的模型。

2.2.3 获取词元嵌入 (Token Embeddings)

除了句子级别的嵌入,encode() 方法还可以返回句子中每个词元(token)的上下文嵌入。这在需要更细粒度分析或将句子嵌入与其他词级别信息结合时可能有用。

from sentence_transformers import SentenceTransformer # 导入 SentenceTransformer 类

# 假设 model 已经加载 (例如 model = SentenceTransformer('all-MiniLM-L6-v2'))
model_name_en = 'all-MiniLM-L6-v2' # 定义模型名称
try:
    model = SentenceTransformer(model_name_en) # 加载模型
    print(f"模型 {
     
     model_name_en} 加载成功。") # 打印成功信息
except Exception as e:
    print(f"加载模型 {
     
     model_name_en} 失败: {
     
     e}") # 打印失败信息
    model = None # 将模型设为None

if model: # 只有当模型成功加载后才执行
    sentence = "This is a short sentence." # 定义一个示例句子
    
    # 获取词元嵌入,不进行归一化,返回numpy数组
    token_embeddings_numpy = model.encode(sentence, output_value='token_embeddings', convert_to_numpy=True, normalize_embeddings=False) # 编码句子,指定输出词元嵌入

    print(f"\n句子: '{
     
     sentence}'") # 打印原始句子
    print(f"词元嵌入的形状 (numpy): {
     
     token_embeddings_numpy.shape}") # 打印词元嵌入的形状
    # 形状通常是 (sequence_length, embedding_dimension)
    # sequence_length 包括特殊标记,如 [CLS] 和 [SEP] (对于BERT类模型底座)
    # 或者取决于模型的分词器如何处理

    # 为了更好地理解,我们可以查看模型的分词器如何处理这个句子
    tokens = model.tokenizer.tokenize(sentence) # 使用模型内部的分词器对句子进行分词
    print(f"模型分词结果: {
     
     tokens}") # 打印分词结果
    # 注意: sentence_transformers 模型通常在其架构中包含了底层的Hugging Face Transformers模型。
    # 我们可以访问其第一个模块(通常是底层的Transformer模型)的tokenizer
    # 或者直接访问 model.tokenizer (如果 sentence_transformers 的版本提供了直接访问)
    # 如果模型是 SentenceTransformer('bert-base-uncased') 这种直接指定transformers模型的情况,
    # 则 model[0].tokenizer 是获取分词器的方式。
    # 对于 'all-MiniLM-L6-v2' 这类已封装好的SBERT模型,其内部结构可能稍有不同,
    # 但通常 model.tokenizer 应该是可用的,或者 model._first_module().tokenizer
    
    # 打印每个词元及其嵌入的前几个维度 (示例)
    # 这里的tokens数量可能与token_embeddings_numpy.shape[0]不完全对应,
    # 因为sentence_transformers的encode输出的token_embeddings可能已经处理过特殊标记
    # 或者直接对应于输入给pooling层的序列。
    # 更准确的做法是依赖于token_embeddings_numpy.shape[0]作为序列长度。
    
    # sentence_transformers的'token_embeddings'通常返回的是作用于pooling层之前的序列表示
    # 其长度与输入给pooling层的序列长度一致。

    # 示例:假设我们想知道原始词如何对应到这些嵌入
    # 这是一个更复杂的问题,涉及到分词对齐。
    # 对于简单展示,我们只看嵌入的维度。
    print("词元嵌入 (每个词元一行,显示前3维):") # 打印提示信息
    for i in range(token_embeddings_numpy.shape[0]): # 遍历每个词元的嵌入
        # 我们无法直接将这里的索引i映射回原始单词,因为分词和特殊标记的存在
        # 这里的token_embeddings_numpy的行数是经过模型内部处理后的序列长度
        print(f"  Token {
     
     i}: {
     
     token_embeddings_numpy[i, :3]}") # 打印第i个词元嵌入的前3个维度

    # 获取词元嵌入,进行归一化,返回torch.Tensor
    token_embeddings_tensor_normalized = model.encode(
        sentence,
        output_value='token_embeddings', # 指定输出类型为词元嵌入
        convert_to_tensor=True, # 指定返回 PyTorch 张量
        normalize_embeddings=True # 指定对嵌入进行归一化
    ) # 编码句子,获取归一化的词元嵌入张量

    print(f"\n词元嵌入的形状 (torch.Tensor, 归一化): {
     
     token_embeddings_tensor_normalized.shape}") # 打印词元嵌入张量的形状
    l2_norm_token0 = torch.linalg.norm(token_embeddings_tensor_normalized[0]) # 计算第一个词元嵌入的L2范数
    print(f"第一个词元嵌入的L2范数 (归一化后): {
     
     l2_norm_token0.item()}") # 打印其L2范数,应接近1

else:
    print("模型未能成功加载,跳过词元嵌入示例。") # 如果模型加载失败,打印此消息

代码解释:

  • output_value='token_embeddings': 这个参数告诉 encode 方法返回每个输入词元(经过模型分词器处理后)的上下文嵌入,而不是聚合后的句子嵌入。
  • token_embeddings_numpy.shape: 返回的词元嵌入是一个二维数组,形状为 (sequence_length, embedding_dimension)
    • sequence_length: 指的是模型内部处理的序列长度,这通常包括由分词器产生的子词(subwords)以及模型可能添加的特殊标记(如BERT的[CLS][SEP],尽管sentence-transformers的池化层可能会在这些特殊标记上操作或忽略它们)。
    • embedding_dimension: 与句子嵌入的维度相同。
  • model.tokenizer.tokenize(sentence): 可以用来查看模型是如何对输入句子进行分词的。注意,encode 方法内部会进行分词,这里的 tokenize 只是为了演示。实际的 sequence_length 取决于 encode 方法内部的完整处理流程,包括添加特殊标记。
  • 词元与原始词的对应: 将词元嵌入精确地映射回原始句子中的词可能需要一些额外的对齐工作,特别是当一个词被分解为多个子词时。对于某些应用,这种细粒度的对应可能很重要。
  • normalize_embeddings=True 同样可以应用于词元嵌入,对每个词元的嵌入向量进行L2归一化。

2.3 计算句子相似度

获取句子嵌入后,最常见的应用之一就是计算它们之间的语义相似度。余弦相似度 (Cosine Similarity) 是衡量向量空间中两个向量方向相似性的常用指标,特别适用于 sentence-transformers 生成的归一化嵌入。

2.3.1 余弦相似度 (Cosine Similarity)

对于两个向量 (A) 和 (B),它们的余弦相似度计算公式为:
[ \text{CosineSimilarity}(A, B) = \frac{A \cdot B}{||A|| \cdot ||B||} = \frac{\sum_{i=1}^{n} A_i B_i}{\sqrt{\sum_{i=1}^{n} A_i^2} \cdot \sqrt{\sum_{i=1}^{n} B_i^2}} ]
其中:

  • (A \cdot B) 是向量 A 和 B 的点积。
  • (||A||) 和 (||B||) 分别是向量 A 和 B 的L2范数(欧几里得长度)。

余弦相似度的取值范围在 -1 到 1 之间:

  • 1: 表示两个向量方向完全相同(语义极度相似)。
  • 0: 表示两个向量正交(语义不相关或关系不明确)。
  • -1: 表示两个向量方向完全相反(语义完全相反,但在实践中,句子嵌入通常使用非负相似度,或者模型训练时避免生成语义相反但方向也相反的向量)。

如果向量 (A) 和 (B) 已经被L2归一化 (即 (||A|| = 1) 且 (||B|| = 1)),那么余弦相似度就简化为它们的点积:
[ \text{CosineSimilarity}(A, B) = A \cdot B \quad (\text{if } ||A||=1 \text{ and } ||B||=1) ]
这就是为什么在使用 sentence-transformers 时,通常建议对嵌入进行归一化(例如,通过 model.encode(..., normalize_embeddings=True) 或手动归一化),然后直接计算点积来获得余弦相似度,这样计算更高效。

2.3.2 使用 sentence_transformers.util.cos_sim

sentence-transformers 库提供了一个方便的工具函数 util.cos_sim(a, b) 来计算两个嵌入或两组嵌入之间的余弦相似度。

  • 输入 ab 可以是单个嵌入向量(一维数组/张量)或嵌入矩阵(二维数组/张量,每行一个嵌入)。
  • 它会自动处理归一化(如果输入向量未归一化,它内部计算时会考虑范数)。
  • 返回一个包含余弦相似度分数的矩阵或标量。

代码示例:计算余弦相似度

from sentence_transformers import SentenceTransformer, util # 导入 SentenceTransformer 和 util 模块
import torch # 导入 torch 用于创建张量示例

# 假设模型已加载 (例如 model = SentenceTransformer('all-MiniLM-L6-v2'))
model_name_en = 'all-MiniLM-L6-v2' # 定义模型名称
try:
    model = SentenceTransformer(model_name_en) # 加载模型
    print(f"模型 {
     
     model_name_en} 加载成功。") # 打印成功消息
except Exception as e:
    print(f"加载模型 {
     
     model_name_en} 失败: {
     
     e}") # 打印失败消息
    model = None # 将模型设为None

if model: # 只有当模型成功加载后才执行
    sentences1 = [
        "The cat sits on the mat.",
        "A dog plays in the garden."
    ] # 定义第一组句子
    sentences2 = [
        "A feline is resting on a rug.", # 与 sentences1[0] 语义相似
        "The weather is nice today.",    # 与 sentences1[0] 和 sentences1[1] 语义不相似
        "A canine is having fun outdoors." # 与 sentences1[1] 语义相似
    ] # 定义第二组句子

    # 编码所有句子,建议进行归一化以获得准确的余弦相似度(虽然util.cos_sim可以处理未归一化的)
    # 许多 sentence-transformers 模型期望嵌入被归一化
    embeddings1 = model.encode(sentences1, convert_to_tensor=True, normalize_embeddings=True) # 编码第一组句子,返回归一化张量
    embeddings2 = model.encode(sentences2, convert_to_tensor=True, normalize_embeddings=True) # 编码第二组句子,返回归一化张量

    print(f"\nEmbeddings1 shape: {
     
     embeddings1.shape}") # 打印第一组嵌入的形状
    print(f"Embeddings2 shape: {
     
     embeddings2.shape}") # 打印第二组嵌入的形状

    # 1. 计算两组句子嵌入之间的所有对的余弦相似度
    # util.cos_sim(embeddings1, embeddings2) 会返回一个 (len(sentences1) x len(sentences2)) 的矩阵
    cosine_scores_matrix = util.cos_sim(embeddings1, embeddings2) # 使用 util.cos_sim 计算余弦相似度矩阵

    print("\n所有句子对之间的余弦相似度矩阵:") # 打印提示信息
    print(cosine_scores_matrix) # 打印相似度矩阵
    # cosine_scores_matrix[i][j] 是 sentences1[i] 和 sentences2[j] 之间的相似度

    # 打印更易读的结果
    for i in range(len(sentences1)): # 遍历第一组句子的索引
        for j in range(len(sentences2)): # 遍历第二组句子的索引
            print(f"相似度 ['{
     
     sentences1[i]}' vs '{
     
     sentences2[j]}']: {
     
     cosine_scores_matrix[i][j]:.4f}") # 打印特定句子对的相似度,保留4位小数

    # 2. 计算单个句子与一组句子的相似度
    query_sentence = "A pet is on a carpet." # 定义一个查询句子
    query_embedding = model.encode(query_sentence, convert_to_tensor=True, normalize_embeddings=True) # 编码查询句子

    # query_embedding 的形状是 (1, embedding_dim) 或者 (embedding_dim)
    # 如果是 (embedding_dim),util.cos_sim 会自动处理
    # 如果想确保是 (1, embedding_dim) 可以用 query_embedding.unsqueeze(0)
    
    # 计算 query_embedding 与 embeddings2 (一组嵌入) 的相似度
    # util.cos_sim(query_embedding, embeddings2) 会返回一个 (1 x len(sentences2)) 的矩阵
    cosine_scores_query_vs_set = util.cos_sim(query_embedding, embeddings2) # 计算查询嵌入与第二组嵌入的相似度

    print(f"\n查询句子 '{
     
     query_sentence}' 与第二组句子的相似度:") # 打印提示信息
    for j in range(len(sentences2)): # 遍历第二组句子的索引
        print(f"  vs '{
     
     sentences2[j]}': {
     
     cosine_scores_query_vs_set[0][j]:.4f}") # 打印查询句子与当前句子的相似度

    # 3. 如果手动处理NumPy数组并且嵌入已归一化,可以直接用点积
    embeddings1_np = embeddings1.cpu().numpy() # 将PyTorch张量转换为NumPy数组 (如果之前是GPU张量,需要先移到CPU)
    embeddings2_np = embeddings2.cpu().numpy() # 将PyTorch张量转换为NumPy数组

    # 假设 embeddings1_np 和 embeddings2_np 已经是L2归一化的
    # 计算第一个句子 (embeddings1_np[0]) 与 sentences2 中所有句子的相似度
    manual_scores = np.dot(embeddings1_np[0], embeddings2_np.T) # 使用NumPy的点积计算相似度
                                                                # embeddings2_np.T 是转置,使得可以进行矩阵乘法得到所有对的相似度
    print(f"\n手动计算 (点积) '{
     
     sentences1[0]}' 与第二组句子的相似度 (假设已归一化):") # 打印提示信息
    for j in range(len(sentences2)): # 遍历第二组句子的索引
        print(f"  vs '{
     
     sentences2[j]}': {
     
     manual_scores[j]:.4f}") # 打印相似度分数

    # 验证 util.cos_sim 是否与手动点积(对于归一化向量)结果一致
    print(f"  (util.cos_sim 第0行: {
     
     cosine_scores_matrix[0].cpu().numpy()})") # 打印使用util.cos_sim得到的结果,用于比较

else:
    print("模型未能成功加载,跳过相似度计算示例。") # 如果模型加载失败,打印此消息

代码解释:

  • from sentence_transformers import util: 导入包含 cos_sim 和其他实用函数的 util 模块。
  • model.encode(..., convert_to_tensor=True, normalize_embeddings=True): 推荐在编码时就进行归一化并获取张量,这样后续使用 util.cos_sim 或直接点积更方便。
  • cosine_scores_matrix = util.cos_sim(embeddings1, embeddings2):
    • 如果 embeddings1 是形状为 (N, D) 的矩阵,embeddings2 是形状为 (M, D) 的矩阵,则 cosine_scores_matrix 是一个形状为 (N, M) 的 PyTorch 张量。
    • cosine_scores_matrix[i, j] 存储的是 embeddings1 中第 i 个句子与 embeddings2 中第 j 个句子之间的余弦相似度。
  • 手动计算点积: np.dot(embeddings1_np[0], embeddings2_np.T)
    • 这里 embeddings1_np[0] 是一个形状为 (D,) 的向量。
    • embeddings2_np.T 是形状为 (M, D) 的矩阵 embeddings2_np 的转置,变为 (D, M)
    • 点积结果是一个形状为 (M,) 的向量,包含了 sentences1[0]sentences2 中每个句子的相似度。
    • 这验证了对于归一化向量,点积确实等于余弦相似度。
  • 结果解读: 观察输出的相似度分数。语义上相似的句子对(如 “The cat sits on the mat.” vs “A feline is resting on a rug.”)应该具有较高的余弦相似度(接近1.0),而语义不相关的句子对(如 “The cat sits on the mat.” vs “The weather is nice today.”)应该具有较低的相似度(接近0或更小,但通常大于0,因为句子嵌入往往在某个锥形区域内)。

2.3.3 其他相似度/距离度量

虽然余弦相似度是最常用的,但根据具体应用和嵌入的特性,有时也会考虑其他度量:

  1. 欧几里得距离 (Euclidean Distance):
    [ D(A, B) = \sqrt{\sum_{i=1}^{n} (A_i - B_i)^2} ]
    衡量向量空间中两个点的直线距离。距离越小,表示越相似。
    与余弦相似度不同,欧几里得距离受向量长度的影响。对于L2归一化的向量,欧几里得距离 (D) 和余弦相似度 (S) 之间存在关系:(D^2 = 2(1 - S))。

  2. 曼哈顿距离 (Manhattan Distance / L1 Distance):
    [ D(A, B) = \sum_{i=1}^{n} |A_i - B_i| ]
    衡量向量在各个维度上差的绝对值之和。

  3. 点积 (Dot Product):
    如前所述,对于归一化向量,点积等价于余弦相似度。对于未归一化的向量,点积会同时考虑向量的方向和长度。如果向量长度差异很大,点积可能会被长度主导。

sentence_transformers.util 模块也提供了计算这些距离的函数,例如:

  • util.pytorch_cos_sim (与 util.cos_sim 类似,但明确是PyTorch实现)
  • util.dot_score (计算点积)
  • 对于欧几里得距离或曼哈顿距离,可以直接使用 PyTorch 或 NumPy 的函数计算,例如 torch.cdistscipy.spatial.distance.cdist

代码示例:使用其他距离度量 (以欧几里得距离为例)

from sentence_transformers import SentenceTransformer, util # 导入所需模块
import torch # 导入torch
import numpy as np # 导入numpy

# 假设 model 和 embeddings1, embeddings2 已按之前的方式准备好
# embeddings1 = model.encode(sentences1, convert_to_tensor=True, normalize_embeddings=True)
# embeddings2 = model.encode(sentences2, convert_to_tensor=True, normalize_embeddings=True)
model_name_en = 'all-MiniLM-L6-v2' # 定义模型名称
try:
    model = SentenceTransformer(model_name_en) # 加载模型
    print(f"模型 {
     
     model_name_en} 加载成功。") # 打印成功消息

    sentences1 = ["The cat sits on the mat."] # 定义第一组句子 (单个句子)
    sentences2 = [ # 定义第二组句子
        "A feline is resting on a rug.",
        "The weather is nice today."
    ]
    # 编码并确保是PyTorch张量
    embeddings1 = model.encode(sentences1, convert_to_tensor=True) # 编码句子,返回张量
    embeddings2 = model.encode(sentences2, convert_to_tensor=True) # 编码句子,返回张量

    # 计算欧几里得距离 (使用 PyTorch)
    # torch.cdist 计算两组点之间的成对距离
    # p=2.0 表示 L2 范数 (欧几里得距离)
    euclidean_distances = torch.cdist(embeddings1, embeddings2, p=2.0) # 计算欧几里得距离矩阵

    print("\n欧几里得距离矩阵:") # 打印提示信息
    print(euclidean_distances) # 打印距离矩阵
    # euclidean_distances[i][j] 是 embeddings1[i] 和 embeddings2[j] 之间的欧几里得距离
    # 距离越小,越相似

    for i in range(embeddings1.shape[0]): # 遍历第一组嵌入
        for j in range(embeddings2.shape[0]): # 遍历第二组嵌入
            print(f"欧氏距离 ['{
     
     sentences1[i]}' vs '{
     
     sentences2[j]}']: {
     
     euclidean_distances[i][j]:.4f}") # 打印距离

    # 如果嵌入已经归一化,欧氏距离和余弦相似度有关系
    # D^2 = 2 * (1 - cos_sim)  => D = sqrt(2 * (1 - cos_sim))
    # cos_sim = 1 - (D^2 / 2)
    embeddings1_norm = model.encode(sentences1, convert_to_tensor=True, normalize_embeddings=True) # 编码并归一化
    embeddings2_norm = model.encode(sentences2, convert_to_tensor=True, normalize_embeddings=True) # 编码并归一化

    cos_sim_scores = util.cos_sim(embeddings1_norm, embeddings2_norm) # 计算归一化嵌入的余弦相似度
    euclidean_dist_norm = torch.cdist(embeddings1_norm, embeddings2_norm, p=2.0) # 计算归一化嵌入的欧氏距离

    print("\n对于归一化嵌入:") # 打印提示信息
    print(f"句子1: '{
     
     sentences1[0]}'") # 打印句子1
    print(f"句子2: '{
     
     sentences2[0]}'") # 打印句子2
    print(f"  余弦相似度: {
     
     cos_sim_scores[0,0]:.4f}") # 打印余弦相似度
    print(f"  欧几里得距离: {
     
     euclidean_dist_norm[0,0]:.4f}") # 打印欧氏距离
    # 验证关系: D = sqrt(2*(1-S))
    derived_D = torch.sqrt(2 * (1 - cos_sim_scores[0,0])) # 根据余弦相似度推导欧氏距离
    print(f"  从余弦相似度推导的欧氏距离: {
     
     derived_D:.4f}") # 打印推导结果

    print(f"句子1: '{
     
     sentences1[0]}'") # 打印句子1
    print(f"句子2: '{
     
     sentences2[1]}'") # 打印句子2
    print(f"  余弦相似度: {
     
     cos_sim_scores[0,1]:.4f}") # 打印余弦相似度
    print(f"  欧几里得距离: {
     
     euclidean_dist_norm[0,1]:.4f}") # 打印欧氏距离
    derived_D_2 = torch.sqrt(2 * (1 - cos_sim_scores[0,1])) # 根据余弦相似度推导欧氏距离
    print(f"  从余弦相似度推导的欧氏距离: {
     
     derived_D_2:.4f}") # 打印推导结果


except Exception as e:
    print(f"运行快速入门示例时发生错误: {
     
     e}") # 打印错误信息

选择哪个度量?

  • 余弦相似度: 是 sentence-transformers 和许多语义相似度任务中最常用的。它对向量的绝对大小不敏感,只关注方向,这通常更符合语义相似性的直觉。特别是当嵌入被归一化后,计算非常高效(点积)。对于大多数 sentence-transformers 预训练模型,推荐使用余弦相似度,并确保嵌入在使用前已L2归一化。
  • 欧几里得距离: 当向量的长度(范数)也携带重要信息时,欧几里得距离可能更合适。但在句子嵌入的上下文中,长度通常被归一化掉了。如果使用未归一化的嵌入,并且希望长度差异影响相似性判断,则可以考虑。
  • 点积: 对于未归一化的向量,点积同时受方向和长度影响。对于归一化向量,它等同于余弦相似度。

在实践中,强烈建议遵循所使用的 sentence-transformers 预训练模型的特定建议。大多数模型都是针对优化余弦相似度进行训练的。

2.4 语义搜索/信息检索示例

一个常见的 sentence-transformers 应用场景是语义搜索。给定一个查询(query)和一组文档(corpus),目标是找出语料库中与查询语义最相关的文档。

基本流程:

  1. 编码语料库: 将语料库中的所有文档(或句子、段落)编码为嵌入向量,并存储它们。这一步通常只需要做一次(除非语料库更新)。
  2. 编码查询: 当用户输入查询时,将查询也编码为嵌入向量。
  3. 计算相似度: 计算查询嵌入与语料库中所有文档嵌入之间的余弦相似度。
  4. 排序和返回结果: 根据相似度得分对语料库中的文档进行降序排序,返回得分最高的Top-K个文档作为搜索结果。

代码示例:简单的语义搜索

from sentence_transformers import SentenceTransformer, util # 导入所需模块
import torch # 导入torch

# 假设模型已加载 (例如 model = SentenceTransformer('all-MiniLM-L6-v2'))
model_name_en = 'all-MiniLM-L6-v2' # 定义模型名称
try:
    model = SentenceTransformer(model_name_en) # 加载模型
    print(f"模型 {
     
     model_name_en} 加载成功。\n") # 打印成功消息
except Exception as e:
    print(f"加载模型 {
     
     model_name_en} 失败: {
     
     e}") # 打印失败消息
    model = None # 将模型设为None

if model: # 只有当模型成功加载后才执行
    # 1. 定义我们的文档语料库 (一些句子)
    corpus_sentences = [
        "A man is eating food.",
        "A man is eating a piece of bread.",
        "The girl is carrying a baby.",
        "A man is riding a horse.",
        "A woman is playing violin.",
        "Two men pushed carts through the woods.",
        "A man is riding a white horse on an enclosed ground.",
        "A monkey is playing drums.",
        "A cheetah is running behind its prey."
    ] # 定义文档语料库

    print("正在编码语料库句子...") # 打印提示信息
    # 编码语料库,推荐使用 convert_to_tensor=True 和 normalize_embeddings=True
    # 如果语料库非常大,可能需要分批处理或考虑持久化存储嵌入
    corpus_embeddings = model.encode(corpus_sentences, convert_to_tensor=True, normalize_embeddings=True, show_progress_bar=True) # 编码语料库
    print(f"语料库嵌入形状: {
     
     corpus_embeddings.shape}") # 打印语料库嵌入的形状

    # 2. 定义用户查询
    user_query = "A man is riding an animal." # 定义用户查询

    print(f"\n用户查询: '{
     
     user_query}'") # 打印用户查询

    # 3. 编码查询
    # 对于单个查询,可以不指定 batch_size,show_progress_bar 通常也不会显示
    query_embedding = model.encode(user_query, convert_to_tensor=True, normalize_embeddings=True) # 编码用户查询
    print(f"查询嵌入形状: {
     
     query_embedding.shape}") # 打印查询嵌入的形状

    # 4. 计算查询与所有语料库文档的余弦相似度
    # util.cos_sim(query_embedding, corpus_embeddings) 返回形状 (1, len(corpus_sentences))
    # 我们取第0行得到每个文档的相似度分数
    cosine_scores = util.cos_sim(query_embedding, corpus_embeddings)[0] # 计算余弦相似度,并取第一行

    # 5. 查找得分最高的 Top-K 个结果
    top_k = 5 # 定义返回结果的数量
    
    # 使用 torch.topk 找到最高得分的索引和值
    # cosine_scores 是一维张量
    # largest=True 表示找最大的k个,sorted=True 表示结果按得分排序
    top_results = torch.topk(cosine_scores, k=min(top_k, len(corpus_sentences)), largest=True, sorted=True) # 找到top-k结果

    print(f"\n'{
     
     user_query}' 的 Top-{
     
     top_k} 相似结果:") # 打印提示信息
    # top_results.values 是得分张量
    # top_results.indices 是对应原始语料库中的索引张量
    for score, idx in zip(top_results.values, top_results.indices): # 遍历得分和索引
        corpus_idx = idx.item() # 将索引张量转换为整数
        sentence = corpus_sentences[corpus_idx] # 获取对应的句子
        similarity_score = score.item() # 将得分张量转换为浮点数
        print(f"  - 得分: {
     
     similarity_score:.4f} - 句子: '{
     
     sentence}' (索引: {
     
     corpus_idx})") # 打印结果

    # 另一个查询示例
    user_query_2 = "Someone is playing an instrument." # 定义第二个用户查询
    print(f"\n用户查询: '{
     
     user_query_2}'") # 打印用户查询
    query_embedding_2 = model.encode(user_query_2, convert_to_tensor=True, normalize_embeddings=True) # 编码查询
    cosine_scores_2 = util.cos_sim(query_embedding_2, corpus_embeddings)[0] # 计算相似度
    top_results_2 = torch.topk(cosine_scores_2, k=min(top_k, len(corpus_sentences)), largest=True, sorted=True) # 找到top-k结果

    print(f"\n'{
     
     user_query_2}' 的 Top-{
     
     top_k} 相似结果:") # 打印提示信息
    for score, idx in zip(top_results_2.values, top_results_2.indices): # 遍历结果
        print(f"  - 得分: {
     
     score.item():.4f} - 句子: '{
     
     corpus_sentences[idx.item()]}' (索引: {
     
     idx.item()})") # 打印结果

else:
    print("模型未能成功加载,跳过语义搜索示例。") # 如果模型加载失败,打印此消息

代码解释:

  • corpus_sentences: 代表我们的文档集合。在实际应用中,这可能来自文件、数据库或其他来源。
  • corpus_embeddings = model.encode(...): 对整个语料库进行编码。对于大型语料库,这一步可能耗时较长,其结果(嵌入)通常会被保存下来以便复用。
  • query_embedding = model.encode(...): 对用户查询进行编码。
  • cosine_scores = util.cos_sim(query_embedding, corpus_embeddings)[0]: 计算查询嵌入与语料库中所有文档嵌入的相似度。[0] 是因为 query_embedding 是单个嵌入,cos_sim 返回的是一个 (1, num_corpus_docs) 的矩阵,我们只需要第一行。
  • torch.topk(cosine_scores, k=..., largest=True, sorted=True): 这是一个非常方便的 PyTorch 函数,用于从张量中找到最大(或最小)的 k 个值及其对应的索引。
    • k=min(top_k, len(corpus_sentences)):确保 k 不超过语料库大小。
    • largest=True: 表示我们想要得分最高的(最相似的)。
    • sorted=True: 表示返回的结果会按照得分从高到低排序。
    • 它返回一个命名元组,包含 values (得分) 和 indices (在 cosine_scores 张量中的索引,这也对应于 corpus_sentences 列表中的索引)。
  • 结果展示: 遍历 top_results,打印出每个匹配文档的相似度得分和原文。

这个简单的例子展示了 sentence-transformers 在语义搜索方面的强大能力。对于非常大的语料库(百万级甚至更多文档),直接计算查询与所有文档的余弦相似度可能会变得低效。在这种情况下,通常会结合使用近似最近邻 (Approximate Nearest Neighbor, ANN) 搜索技术(如FAISS, Annoy, ScaNN, HNSWlib等库)来加速检索过程。我们会在后续章节中更详细地探讨这一点。

2.5 选择合适的预训练模型

sentence-transformers 提供了大量预训练模型,选择哪个模型取决于多个因素:

  1. 语言 (Language):

    • 英语: 有最多的选择,如 'all-mpnet-base-v2' (高性能), 'all-MiniLM-L6-v2' (速度与性能平衡), 'multi-qa-MiniLM-L6-cos-v1' (针对问答/搜索优化)。
    • 中文: 如 'shibing624/text2vec-base-chinese', 'DMetaSoul/sbert-chinese-general-v2', Hugging Face Hub上还有更多由社区贡献的中文模型,例如针对特定任务(如金融、法律)微调的模型。
    • 多语言 (Multilingual): 如果需要处理多种语言或进行跨语言任务,可以选择多语言模型,如 'paraphrase-multilingual-MiniLM-L12-v2', 'paraphrase-multilingual-mpnet-base-v2'。这些模型通常在一种或多种语言的混合语料上训练,或者在翻译对上训练。
  2. 任务类型 (Task Type):

    • 通用语义相似度 (General Semantic Similarity) / 释义识别 (Paraphrase Identification): 模型如 'all-mpnet-base-v2', 'all-MiniLM-L6-v2', 'stsb-roberta-large' 在STS (Semantic Textual Similarity) 基准上表现良好。
    • 语义搜索 (Semantic Search) / 问答 (Question Answering): 有些模型专门针对非对称搜索任务(短查询 vs 长文档)进行了优化,例如 'multi-qa-mpnet-base-dot-v1' (使用点积), 'multi-qa-MiniLM-L6-cos-v1' (使用余弦相似度)。这些模型通常在问答数据集上进行微调。
    • 聚类 (Clustering): 通用语义相似度模型通常也适用于聚类。
    • 跨语言任务 (Cross-Lingual Tasks): 需要使用多语言模型。
  3. 性能需求 (Performance Requirements):

    • 最高准确率: 通常基于更大Transformer模型(如BERT-large, RoBERTa-large, MPNet-base)的 sentence-transformers 模型(如 'all-mpnet-base-v2', 'stsb-roberta-large')提供最佳的语义表示质量,但计算成本也更高(编码速度慢,模型更大)。
    • 速度与性能的平衡: 基于中小型Transformer模型(如BERT-base, MiniLM, DistilBERT)的模型(如 'all-MiniLM-L6-v2', 'all-distilroberta-v1')提供了较好的折中。它们比大型模型快得多,模型尺寸也小得多,同时在许多任务上仍能保持相当不错的性能。
    • 极致速度/轻量级: 对于资源非常受限的环境(如移动端、边缘设备)或需要极快推理的场景,可以考虑更小的模型,如基于TinyBERT或通过知识蒸馏得到的极小型号,但这通常以牺牲一些准确性为代价。
  4. 嵌入维度 (Embedding Dimension):

    • sentence-transformers 模型的嵌入维度各不相同,常见的有384 (如MiniLM系列), 512, 768 (如BERT-base, RoBERTa-base, MPNet-base), 1024 (如BERT-large, RoBERTa-large)。
    • 更高维度的嵌入通常能编码更丰富的信息,但也意味着更大的存储需求和可能稍慢的相似度计算(尽管现代硬件对此不敏感)。
    • 如果存储或带宽是瓶颈,可以选择维度较低的模型。
  5. 对称 vs 非对称语义搜索 (Symmetric vs. Asymmetric Semantic Search):

    • 对称搜索: 查询和文档的长度和性质相似(例如,查找重复问题,比较两个短描述)。通用语义相似度模型适用。
    • 非对称搜索: 查询通常很短,而文档可能很长(例如,用几个关键词搜索一篇完整文章)。专门为QA或信息检索微调的模型(如 multi-qa-... 系列)在这种情况下可能表现更好。

在哪里查找和比较模型?

  • sentence-transformers 官方文档 - 预训练模型: (https://www.sbert.net/docs/pretrained_models.html) 这是最重要的资源,列出了官方推荐的各种模型及其在关键基准(如STS benchmark)上的性能得分、速度、大小和适用场景。
  • Hugging Face Model Hub: (https://huggingface.co/models?library=sentence-transformers) 可以筛选出所有兼容 sentence-transformers 的模型,包括社区贡献的许多模型。每个模型页面通常有其详细描述、训练数据、评估结果和使用示例。
  • 基准测试论文: 阅读相关的学术论文(如SBERT论文及其后续研究)可以了解不同模型架构和训练方法在标准NLP任务上的表现。

经验法则:

  • 如果不确定从哪里开始,对于英文,'all-MiniLM-L6-v2' 是一个很好的通用起点,因为它快速且效果不错。如果需要更高质量,可以尝试 'all-mpnet-base-v2'
  • 对于中文,可以从 'shibing624/text2vec-base-chinese' 或 Hugging Face Hub 上其他高下载量/高评价的中文句子模型开始。
  • 对于多语言需求,'paraphrase-multilingual-MiniLM-L12-v2' 是一个流行的选择。
  • 始终在自己的特定任务和数据上评估几个候选模型的性能,不要仅仅依赖通用基准的得分。一个在通用STS任务上表现好的模型,不一定在你独特的领域或特定格式的数据上表现最佳。

第三章:深入理解池化策略与模型结构

sentence-transformers 的核心是将预训练的 Transformer 模型(如 BERT, RoBERTa)的词级别输出转换为一个固定大小的句子级别嵌入向量。这个转换过程由池化层 (Pooling Layer) 完成。选择合适的池化策略对于生成高质量的句子嵌入至关重要。

3.1 为什么需要池化?

底层的 Transformer 模型(如BERT)在处理输入文本后,会为序列中的每个输入词元(token)生成一个上下文相关的嵌入向量。例如,对于一个包含 (L) 个词元的句子,BERT 的最后一层会输出一个形状为 ((L, D)) 的矩阵,其中 (D) 是隐藏层维度(例如,BERT-base 为 768)。

然而,对于许多下游任务,如句子相似度比较、句子分类、聚类等,我们需要一个单一的、固定长度的向量来代表整个句子的语义。池化层的作用就是将这个 ((L, D)) 的词元嵌入序列聚合(aggregate)或池化(pool)成一个 ((1, D)) 或 ((D,)) 的句子嵌入向量。

3.2 sentence-transformers 中常见的池化策略

sentence-transformers 框架在其模型定义中显式地包含了池化层。当你加载一个预训练的 sentence-transformers 模型时,其池化配置也一同被加载。这些模型通常是在特定池化策略下进行微调的。

以下是一些在 sentence-transformers 中常用的池化模块(它们都继承自 torch.nn.Module):

3.2.1 平均池化 (Mean Pooling)

  • 模块: sentence_transformers.models.Pooling (当 pooling_mode_mean_tokens=True)
  • 原理: 计算句子中所有词元(token)的上下文嵌入向量的元素级平均值。
    [ \text{SentenceEmb} = \frac{1}{L} \sum_{i=1}^{L} \text{token_emb}_i ]
    其中,(\text{token_emb}_i) 是第 (i) 个词元的嵌入向量,(L) 是序列的有效长度(通常会考虑注意力掩码 attention_mask 来忽略填充词元 [PAD])。
  • 实现细节:
    • Pooling 模块在计算平均值时会正确处理填充词元。它使用词元的注意力掩码 (attention_mask) 来确保只有真实的词元参与平均计算。
    • 具体来说,它将不需要参与平均的词元(如填充词元)的嵌入乘以0,然后对所有词元的嵌入求和,再除以序列中真实词元的数量。
  • 优点:
    • 简单直观,计算高效。
    • 考虑了句子中所有词元的信息。
    • 在许多 sentence-transformers 的预训练模型(如 all-MiniLM-L6-v2, all-mpnet-base-v2)中被证明是非常有效的默认策略,尤其是在 SBERT 论文中被广泛推荐。
  • 缺点:
    • 可能会“稀释”重要词元的作用,因为所有词元被同等对待(在平均意义上)。
    • 对于非常长的句子,平均操作可能导致信息损失。

代码示例:理解 Pooling 模块的平均池化

from sentence_transformers import SentenceTransformer, models # 导入所需模块
import to

你可能感兴趣的:(python,开发语言)