文本相似度计算:让机器读懂“差不多”
文本相似度计算,说白了就是让计算机判断两段文字有多“像”。你可能觉得这很简单——不就是看字重不重叠吗?但实际项目中,这事儿远比想象中复杂。我记得刚接手一个舆情分析系统时,客户要求把“今天天气真好”和“今天天气不错”识别为相似内容,但“今天天气真差”要区分开。嗯,这就不是简单的字符串匹配能搞定的了。
为什么需要文本相似度?
我在项目中遇到过不少场景需要这个技术:
- 去重:新闻聚合系统里,同一事件的不同报道需要合并
- 搜索:用户搜“手机”,结果里要有“智能手机”、“移动电话”
- 抄袭检测:学生论文换了几个词,系统能不能识别出来?
- 问答匹配:用户问“怎么退款”,知识库里有“退款流程是什么”,算不算匹配?
你想想看,这些场景如果只用精确匹配,基本就废了。所以我们需要一套更聪明的算法。
核心算法:从简单到实用
我习惯把文本相似度算法分成三个梯队,咱们一个一个来看。
第一梯队:基于字符的相似度
这是最直观的方法,适合短文本、拼写纠错等场景。
编辑距离(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;
}
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;
}
第二梯队:基于向量的相似度
这才是工业界的主流做法。把文本变成向量,然后算余弦相似度。
词袋模型 + TF-IDF
基本思路:
- 把所有文档的词收集起来,建一个词典
- 每篇文档用一个向量表示,向量的每个维度对应一个词
- 用TF-IDF给每个词加权——常见的词(如“的”、“是”)权重低,稀有的词权重高
- 计算两个向量的余弦值
#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);
}
知识体系总览
下面这张图帮你理清思路:
实战:一个完整的相似度计算工具
下面我整合一个简单的工具类,支持多种算法切换:
#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 | 依赖模型 | 语义理解、问答 | ⭐⭐⭐⭐⭐ |
总结
文本相似度计算,说白了就是找到合适的“距离”定义。字符级距离适合拼写纠错,向量级距离适合文档检索,语义级距离适合深度理解。选哪个,取决于你的业务场景和资源限制。
我个人习惯在项目初期先用Jaccard或编辑距离快速验证可行性,等业务跑通了再逐步优化。毕竟,一个能跑的简单方案,比一个永远在优化的完美方案强得多。
公众号:蓝海资料掘金营,微信deep3321