文本相似度计算:让机器读懂“差不多”

文本相似度计算,说白了就是让计算机判断两段文字有多“像”。你可能觉得这很简单——不就是看字重不重叠吗?但实际项目中,这事儿远比想象中复杂。我记得刚接手一个舆情分析系统时,客户要求把“今天天气真好”和“今天天气不错”识别为相似内容,但“今天天气真差”要区分开。嗯,这就不是简单的字符串匹配能搞定的了。

为什么需要文本相似度?

我在项目中遇到过不少场景需要这个技术:

  • 去重:新闻聚合系统里,同一事件的不同报道需要合并
  • 搜索:用户搜“手机”,结果里要有“智能手机”、“移动电话”
  • 抄袭检测:学生论文换了几个词,系统能不能识别出来?
  • 问答匹配:用户问“怎么退款”,知识库里有“退款流程是什么”,算不算匹配?

你想想看,这些场景如果只用精确匹配,基本就废了。所以我们需要一套更聪明的算法。

核心算法:从简单到实用

我习惯把文本相似度算法分成三个梯队,咱们一个一个来看。

第一梯队:基于字符的相似度

这是最直观的方法,适合短文本、拼写纠错等场景。

编辑距离(Levenshtein Distance)

这个算法计算把一个字符串变成另一个需要的最少操作次数(插入、删除、替换)。比如“abc”到“abd”只需要1次替换,距离就是1。

#include <iostream>
#include <string>
#include <vector>
#include <algorithm>

int levenshteinDistance(const std::string& s1, const std::string& s2) {
    int m = s1.size(), n = s2.size();
    std::vector<std::vector<int>> dp(m + 1, std::vector<int>(n + 1, 0));
    
    for (int i = 0; i <= m; ++i) dp[i][0] = i;
    for (int j = 0; j <= n; ++j) dp[0][j] = j;
    
    for (int i = 1; i <= m; ++i) {
        for (int j = 1; j <= n; ++j) {
            if (s1[i-1] == s2[j-1]) {
                dp[i][j] = dp[i-1][j-1];
            } else {
                dp[i][j] = 1 + std::min({dp[i-1][j],    // 删除
                                         dp[i][j-1],    // 插入
                                         dp[i-1][j-1]}); // 替换
            }
        }
    }
    return dp[m][n];
}

// 归一化到 [0, 1],1表示完全相同
double similarity(const std::string& s1, const std::string& s2) {
    int dist = levenshteinDistance(s1, s2);
    int maxLen = std::max(s1.size(), s2.size());
    return 1.0 - (double)dist / maxLen;
}
我的经验:编辑距离对短文本(比如10个字以内)效果不错,但长文本就别用了。我曾经用它处理一篇5000字的文章,结果跑了快10秒——复杂度是O(mn),长文本根本扛不住。

Jaccard相似度

这个更简单:把文本拆成集合(比如按字或按词),然后算交集除以并集。

#include <unordered_set>

double jaccardSimilarity(const std::string& s1, const std::string& s2) {
    std::unordered_set<char> set1(s1.begin(), s1.end());
    std::unordered_set<char> set2(s2.begin(), s2.end());
    
    int intersection = 0;
    for (char c : set1) {
        if (set2.count(c)) intersection++;
    }
    
    int unionSize = set1.size() + set2.size() - intersection;
    return unionSize == 0 ? 0 : (double)intersection / unionSize;
}
注意:Jaccard只关心“有没有”,不关心“出现几次”。比如“好好好好”和“好”的Jaccard相似度是100%,但语义上差别很大。我一般在做关键词匹配时才用它。

第二梯队:基于向量的相似度

这才是工业界的主流做法。把文本变成向量,然后算余弦相似度。

词袋模型 + TF-IDF

基本思路:

  1. 把所有文档的词收集起来,建一个词典
  2. 每篇文档用一个向量表示,向量的每个维度对应一个词
  3. 用TF-IDF给每个词加权——常见的词(如“的”、“是”)权重低,稀有的词权重高
  4. 计算两个向量的余弦值
#include <cmath>
#include <unordered_map>

// 简化版:假设我们已经有了TF-IDF向量
double cosineSimilarity(const std::unordered_map<int, double>& vec1,
                        const std::unordered_map<int, double>& vec2) {
    double dotProduct = 0.0, norm1 = 0.0, norm2 = 0.0;
    
    for (auto& [id, val] : vec1) {
        norm1 += val * val;
        if (vec2.count(id)) {
            dotProduct += val * vec2.at(id);
        }
    }
    
    for (auto& [id, val] : vec2) {
        norm2 += val * val;
    }
    
    if (norm1 == 0 || norm2 == 0) return 0;
    return dotProduct / (std::sqrt(norm1) * std::sqrt(norm2));
}

核心要点:余弦相似度只关心方向,不关心长度。也就是说,两篇文档即使长度差很多,只要用词比例相似,相似度就高。这在处理长短不一的文档时特别有用。

第三梯队:基于语义的相似度

说实话,前两种方法都有个硬伤——它们不懂语义。“苹果”和“水果”在词袋模型里相似度是0,但人知道它们有关系。这时候就需要Word2Vec、BERT这类模型了。

不过这些模型在C++里实现起来比较重,我一般用C++调用Python的推理服务,或者直接用现成的库如fastText。

// 伪代码:用fastText获取词向量
// 实际使用时需要链接fastText库
#include <fasttext.h>

double semanticSimilarity(const std::string& text1, const std::string& text2) {
    fasttext::FastText ft;
    ft.loadModel("cc.zh.300.bin");  // 中文预训练模型
    
    auto vec1 = ft.getSentenceVector(text1);
    auto vec2 = ft.getSentenceVector(text2);
    
    // 计算余弦相似度
    return cosineSimilarity(vec1, vec2);
}
避坑指南:我曾经在一个项目里直接用Word2Vec词向量平均作为句子向量,结果发现“我很开心”和“我不开心”的相似度极高——因为“不”和“开心”的向量被平均掉了。后来改用SIF(平滑逆频率)加权才解决这个问题。

知识体系总览

下面这张图帮你理清思路:

文本相似度计算 基于字符 编辑距离 · Jaccard · 余弦 基于向量 词袋模型 · TF-IDF · 词向量 基于语义 Word2Vec · BERT · fastText 应用场景 文本去重 · 查重 搜索引擎 · 推荐 问答系统 · 对话

实战:一个完整的相似度计算工具

下面我整合一个简单的工具类,支持多种算法切换:

#include <string>
#include <vector>
#include <memory>

class TextSimilarity {
public:
    enum Algorithm {
        LEVENSHTEIN,
        JACCARD,
        COSINE_TFIDF
    };
    
    TextSimilarity(Algorithm algo = COSINE_TFIDF) : m_algo(algo) {}
    
    double compute(const std::string& text1, const std::string& text2) {
        switch (m_algo) {
            case LEVENSHTEIN:
                return levenshteinSim(text1, text2);
            case JACCARD:
                return jaccardSim(text1, text2);
            case COSINE_TFIDF:
                return cosineTFIDFSim(text1, text2);
            default:
                return 0.0;
        }
    }
    
    void setAlgorithm(Algorithm algo) { m_algo = algo; }
    
private:
    Algorithm m_algo;
    
    double levenshteinSim(const std::string& s1, const std::string& s2) {
        // 实现见前文
        return 0.0;
    }
    
    double jaccardSim(const std::string& s1, const std::string& s2) {
        // 实现见前文
        return 0.0;
    }
    
    double cosineTFIDFSim(const std::string& s1, const std::string& s2) {
        // 需要配合分词和TF-IDF计算
        return 0.0;
    }
};

// 使用示例
int main() {
    TextSimilarity ts(TextSimilarity::JACCARD);
    
    std::string a = "今天天气真好";
    std::string b = "今天天气不错";
    std::string c = "明天要下雨";
    
    std::cout << "a vs b: " << ts.compute(a, b) << std::endl;
    std::cout << "a vs c: " << ts.compute(a, c) << std::endl;
    
    return 0;
}

性能对比与选型建议

算法 时间复杂度 适合场景 我的推荐指数
编辑距离 O(mn) 短文本、拼写纠错 ⭐⭐⭐
Jaccard O(m+n) 关键词匹配、去重 ⭐⭐⭐
TF-IDF + 余弦 O(m+n) 文档检索、分类 ⭐⭐⭐⭐
Word2Vec/BERT 依赖模型 语义理解、问答 ⭐⭐⭐⭐⭐
重要提醒:没有银弹!我见过太多人一上来就上BERT,结果发现数据量不够、推理速度太慢。我的建议是:先跑一遍TF-IDF,如果效果不满意再升级到语义模型。80%的场景下,TF-IDF已经够用了。

总结

文本相似度计算,说白了就是找到合适的“距离”定义。字符级距离适合拼写纠错,向量级距离适合文档检索,语义级距离适合深度理解。选哪个,取决于你的业务场景和资源限制。

我个人习惯在项目初期先用Jaccard或编辑距离快速验证可行性,等业务跑通了再逐步优化。毕竟,一个能跑的简单方案,比一个永远在优化的完美方案强得多。


公众号:蓝海资料掘金营,微信deep3321