做后台开发和算法落地的同学,十有八九都遇到过这种需求:给一段文本找出最像的另一段,或者从一个库里筛出重复内容。这个能力背后的根基,就是相似度判断。今天聊聊用C++实现相似度判断这件事,包括算法选择、代码实现和工程落地,尽量把踩过的坑都摊开讲。适合正在写检索、推荐、去重模块的开发者,也适合想系统了解相似度计算原理和C++写法的初学者。
1. 相似度判断到底在判断什么
1.1 相似度的定义与生活化类比
相似度判断,本质上是把两个对象映射到一个可比较的度量空间,然后用一个数值描述它们之间的接近程度。这个数值通常落在0到1之间,1表示完全一致,0表示毫无关系,但具体范围取决于你选的算法。举个生活化的例子:有两道菜,都放了番茄和鸡蛋,只是咸淡不同,我们说它们相似度高;一道番茄炒蛋和一道红烧肉,几乎没交集,相似度就低。
工程里的对象不是菜,而是文本、图片、用户行为序列、音频片段这些数据。数据必须被转换成计算机能算的形式,最常见的就是向量。向量可以理解为高维空间里的一个点,相似度就是点与点之间的距离远近。为什么需要这个东西?因为很多业务用精确匹配根本做不了。两个网页内容可能只改了几个字,你不能拿字符串等值比较去判断它们是重复的;两个用户看过不同的商品,但偏好方向一致,你需要从行为向量里找出那种“味觉上很像”的感觉。
所以相似度判断解决的从来不是“是不是同一个”的问题,而是“有多像”的问题。这个细微的差别决定了算法选型和技术实现的走向。
1.2 哪些场景需要相似度判断
相似度判断的覆盖面比很多人想象中广,我随便列几个高频场景你就知道它多常见。
文本去重:内容社区、新闻聚合、爬虫系统都会碰到大量重复或高度相似的稿件。直接用哈希做指纹只能对付完全相同的文本,稍微改动就失效,所以要用相似度来计算内容重合率。我之前在某个项目里处理过一批技术博客,很多文章被人改了个标题和首段就发出来,靠相似度判断能轻松揪出来。
推荐系统:用户画像会表示成高维向量,商品也会表示成向量,相似度用于计算“看了这个还喜欢那个”。这里常用的不是文本相似度,而是用户行为向量的余弦相似度或协同过滤里的相似度计算。
日志异常检测:系统日志可以按时间段转换成模式向量,对比当前时间片和历史正常时间片的相似度,一旦相似度跌破阈值就告警。这个场景要求算法快,因为日志量非常大。
代码查重:学生作业、开源代码重复度检测,本质是对代码片段做特征提取后计算相似度。这比文本去重难点在于需要处理语法结构,但底层仍然离不开相似度度量。
图片查重:指纹提取后的感知哈希向量之间的汉明距离,或者深度网络特征向量的余弦距离,都是相似度判断。
这些场景有一个共同特点:原始数据不能直接计算,必须先经过特征化;特征选得好不好,直接决定相似度有没有意义。这也是后面所有内容的大前提。
2. 常见相似度算法的选择逻辑
2.1 余弦相似度:向量空间的黄金标准
余弦相似度是实战中出场率最高的算法,没有之一。它的核心是计算两个向量之间的夹角余弦值,公式是:
[ sim(a,b) = \frac{a \cdot b}{||a|| \cdot ||b||} ]
也就是向量的点积除以两个向量的模长乘积。它衡量的是方向一致性,不看向量的绝对长度。这个特性非常契合很多业务场景:文本向量里,一篇文章的长短只会影响向量模长,但主题方向往往由词项比例决定,所以余弦相似度对文本特别友好。
举个例子:向量A是[1, 2, 3],向量B是[2, 4, 6],这俩是纯倍数关系,欧氏距离会很大,但余弦相似度是1.0,因为方向完全一致。在实际文本检索里,一篇短文和一篇长文讨论同一个话题,它们词的分布比例相似,余弦值就高,这符合直觉。
实现上也非常简单,只需要一次循环累加点积和模长,没有复杂分支。我建议所有刚接触相似度的人先把余弦相似度写熟,它是最容易调试也最不容易出幺蛾子的算法。
2.2 欧氏距离与曼哈顿距离:几何视角的相似
欧氏距离就是通常说的“直线距离”,公式是:
[ d(a,b) = \sqrt{\sum_{i=1}^{n}(a_i - b_i)^2} ]
曼哈顿距离则是两个点在各个维度上的绝对差之和,城市街区的走法:
[ d(a,b) = \sum_{i=1}^{n}|a_i - b_i| ]
这两种算法跟余弦相似度最大的差别是它们考虑数值的绝对差异。使用场景很典型:如果你在比较坐标、图像像素值、传感器读数,数值本身的大小有意义,那欧氏距离就是合适的选择。比如判断两条用电曲线是否相似,幅度差异本身就可能代表异常,不能只比较趋势方向。
但要注意,欧氏距离和余弦相似度不是完全对立的。把向量归一化到单位长度后,欧氏距离和余弦相似度在排序意义上是等价的,所以有些系统会先把向量L2归一化,再统一用余弦,目的就是让两种度量保持一致。实际工程中,我常用一个简单经验:文本、推荐用余弦;数值、坐标、时序信号优先看距离。
2.3 杰卡德相似系数、汉明距离与编辑距离
除了向量,有些数据天然不是向量形态。集合类数据适合用杰卡德相似系数,公式是交集大小除以并集大小:
[ J(A,B) = \frac{|A \cap B|}{|A \cup B|} ]
比如两个用户都有哪些标签,两边共同标签占比越高,相似度越高。杰卡德对稀疏集合表现得很好,因为它无视了双方都不存在的元素。
汉明距离一般用在等长编码序列上,计算两个序列对应位置不同的个数。经典场景是图片感知哈希后得到的64位二进制指纹,比较两张图就是统计指纹比特位不同的数量。这个算法极其快,硬件层面可以并行比较位运算,所以在大规模图片去重里非常实用。
编辑距离(Levenshtein距离)解决的是字符串相似问题,通过增删改操作把一个字符串变成另一个字符串的最小次数。它对拼写错误、OCR识别结果比对很有效,但计算复杂度较高,O(m*n),长文本慎用。如果只是想快速判断两个短字符串是否“差不多”,编辑距离是个直观的选择。
2.4 怎么选:数据形态和业务目标说了算
我总结了一张选型速查表,自己平时也用:
| 数据类型 | 推荐算法 | 适合场景 | 主要缺点 |
|---|---|---|---|
| 高维稀疏向量 | 余弦相似度 | 文本、用户向量、推荐 | 对共现结构不敏感 |
| 连续数值向量 | 欧氏距离 | 坐标、图像像素、传感器 | 量纲需要归一化 |
| 集合 | 杰卡德系数 | 标签、权限、关键词集合 | 忽略元素权重 |
| 等长编码 | 汉明距离 | 图片指纹、二进制特征 | 无法处理长度变化 |
| 短字符串 | 编辑距离 | 拼写纠错、名称匹配 | 长文本计算慢 |
业务目标也很关键。你要的是排名还是绝对阈值?推荐系统只看Top K排序,余弦相似度够用;去重系统需要设一个绝对相似度阈值切断,那就要考虑算法对数值的实际分布是否稳定。有的算法对数据分布敏感,比如欧氏距离在高维空间里区分度很差,这时候余弦往往表现更好。我的建议是:先想清楚你的数据长什么样、业务对距离的语义期待是什么,再决定算法,不要一上来就套余弦。
3. C++实现的代码级拆解
3.1 从零写一个余弦相似度计算器
先来一个最直接的实现,输入是两个等长的vector ,返回余弦相似度:
#include <vector> #include <cmath> #include <stdexcept> double cosineSimilarity(const std::vector<double>& a, const std::vector<double>& b) { if (a.size() != b.size()) { throw std::invalid_argument("向量长度必须一致"); } double dot = 0.0; double normA = 0.0; double normB = 0.0; for (size_t i = 0; i < a.size(); ++i) { dot += a[i] * b[i]; normA += a[i] * a[i]; normB += b[i] * b[i]; } if (normA == 0.0 || normB == 0.0) { return 0.0; } return dot / (std::sqrt(normA) * std::sqrt(normB)); }这个函数有几个细节值得说。一是先检查长度,避免后面越界;二是用 normA 和 normB 同时累加,一遍循环就做完,不额外开数组;三是零向量直接返回0。零向量没有方向,余弦公式里分母为零,但工程里不能抛异常,否则线上会炸。具体返回什么值看语义,有的场景想当作完全不相同处理,返回0最合理。
如果向量是float,或者你对精度要求不高,也可以把double换成float,速度会快一些,尤其是批量计算时。但代价是数值稳定性稍差,超大向量点积可能溢出,可以考虑用long double做累加再转回double。
3.2 文本特征表示:从字符串到向量
算法再好,输入是字符串也没法直接算。最常见的做法是把文本转换成词频向量。下面是一个极其简化的英文分词版本:
#include <unordered_map> #include <string> #include <sstream> std::unordered_map<std::string, size_t> tokenize(const std::string& text) { std::unordered_map<std::string, size_t> freq; std::istringstream stream(text); std::string word; while (stream >> word) { ++freq[word]; } return freq; }这只是按空白切分,真实场景里要做更多清洗:去掉标点、转小写、过滤停用词、甚至做词干化。中文就复杂一些,需要分词组件,但分词后得到的词频map是一样的。
拿到map之后,怎么转成向量?通常有两种思路:第一种是固定一个全局词表,所有文档都用这个词表的下标做维度,词表大小就是向量长度,这是稠密向量;第二种是只保留非零维度,用稀疏结构存储,比如vector<pair<size_t, double>>或者unordered_map,这是稀疏向量。
对于文本场景,词表动辄几万维,但每篇文章只含有几百个词,用稠密向量存99.9%的零维度非常浪费,所以工程上基本都用稀疏表示。稀疏结构的余弦计算不需要遍历全部维度,只要对两个向量共同存在的维度做运算即可,速度可以提升几个数量级。
3.3 工程化:封装、边界处理与性能优化
写一个单独函数很容易,但放到模块里就要考虑封装。我习惯把相似度计算放到一个独立的类里,避免业务代码到处复制公式:
class SimilarityCalculator { public: enum class Metric { COSINE, EUCLIDEAN, JACCARD }; double calculate(const std::vector<double>& a, const std::vector<double>& b, Metric metric) const { switch (metric) { case Metric::COSINE: return cosineSimilarity(a, b); case Metric::EUCLIDEAN: return 1.0 / (1.0 + euclideanDistance(a, b)); // 其他指标按需扩展 default: return 0.0; } } };这里我把欧氏距离转成了相似度,用1/(1+d)做映射,将距离压到0到1区间。这么做是为了上层统一接口,但不能滥用,因为映射会改变原始距离的分布,如果要严格排序,不如直接返回距离让调用方自己比较。
边界处理是工程里的重灾区。输入向量的维度不一致要抛异常;空字符串、全零词频、只包含停用词的文本,都会产生零向量,计算前要识别。另一个容易踩的坑是浮点数相等比较,别写if (normA == 0.0),因为浮点数可能因为累计误差产生极小值,比如1e-300,它不等于0但接近0,这时计算出来的相似度可能是一个病态值。更稳妥的做法是判断绝对值小于某个极小阈值,比如normA < 1e-12。
性能优化方面,几个方向值得优先考虑。第一,用稀疏向量表示文本,不存储零值。第二,相似度计算循环里避免函数调用和动态内存分配,提前用reserve预留容量。第三,批量比较时可以用多线程并行,比如按文档ID分片,最后归并排序。第四,如果机器支持SIMD,可以用向量化指令同时计算多个维度的乘加,不过那是进阶玩法,大部分场景把算法复杂度降下来收益更高。
4. 实操记录:做一个文本相似度判断工具
4.1 需求定义与接口设计
这里我记录一个真实做过的简化版工具,模拟项目叫“文章相似度检索X”。需求很简单:给一批文档,输入一个query,返回最相似的Top N篇。数据量大概十万篇,每篇几百字,要求单次查询在100毫秒内完成。
接口我设计成三个方法:一个建索引,一个查询,一个辅助清空。
class SimilarityEngine { public: void buildIndex(const std::vector<std::string>& documents); std::vector<size_t> findTop(const std::string& query, size_t topN) const; private: std::vector<std::vector<std::pair<size_t, double>>> sparseVectors_; std::vector<std::string> rawDocs_; std::unordered_map<std::string, size_t> globalDict_; };这里用sparseVectors_保存每篇文档的非零词索引和权重,globalDict_负责词到维度ID的映射。为什么不用稠密向量?因为十万篇文档,词表可能二十万,稠密矩阵就是十万乘二十万,内存直接爆炸。稀疏向量只需要内存保存每个文档里真实出现的词,平均下来每篇几百项,内存能省九成以上。
4.2 核心流程与代码实现
整个查询流程分为四步:query预处理、转向量、逐个计算相似度、保留TopN。预处理和建索引阶段一致,才能保证词表ID对齐。
把query转换成稀疏向量的代码:
std::vector<std::pair<size_t, double>> SimilarityEngine::queryToSparse(const std::string& query) const { std::vector<std::pair<size_t, double>> result; auto freq = tokenize(query); for (const auto& [word, count] : freq) { auto it = globalDict_.find(word); if (it != globalDict_.end()) { result.emplace_back(it->second, static_cast<double>(count)); } } return result; }这里注意:query中出现的词如果不在全局词典中,就只能忽略,这也是常见问题之一。如果新词太多,说明离线建词典时语料覆盖不够,后续要考虑增量更新词典,或者采用hash trick直接给词哈希成ID。
TopN我用了一个小根堆,也就是priority_queue,只保留相似度最大的N个候选,避免全文排序。实现如下:
std::vector<size_t> SimilarityEngine::findTop(const std::string& query, size_t topN) const { auto qVec = queryToSparse(query); using Item = std::pair<double, size_t>; std::priority_queue<Item, std::vector<Item>, std::greater<Item>> pq; for (size_t i = 0; i < sparseVectors_.size(); ++i) { double sim = cosineSparse(qVec, sparseVectors_[i]); if (pq.size() < topN) { pq.emplace(sim, i); } else if (sim > pq.top().first) { pq.pop(); pq.emplace(sim, i); } } std::vector<size_t> result; while (!pq.empty()) { result.push_back(pq.top().second); pq.pop(); } std::reverse(result.begin(), result.end()); return result; }cosineSparse跟稠密版本写法不同,它只遍历两个稀疏向量中重叠的ID。常见做法是遍历维度较短的向量,并在另一个向量里二分查找;更快的做法是两个有序向量线性归并,前提是向量按ID排序。我在构建索引时保证每个向量的pair按ID升序,查询时也排序,这样归并一遍就能算完。
4.3 效果测试与结果分析
做完之后,我用三句中文测试过效果,为了绕过开源分词组件,直接按字符做了简化切分。假设库里有这么几句话:
| 编号 | 内容 |
|---|---|
| 0 | 我喜欢喝咖啡 |
| 1 | 咖啡是我每天早上的必需品 |
| 2 | 今天天气很好适合户外跑步 |
用“我喜欢咖啡”查询,得到的相似度大概如下:
| 文档 | 相似度 |
|---|---|
| 0 | 0.81 |
| 1 | 0.52 |
| 2 | 0.0 |
第0篇明显最高,第1篇有一半词重叠所以中等,第2篇没有交集所以为0。这个结果符合直觉。不过我也碰到了典型问题:第1篇和第0篇语义上都在讲咖啡,但分词方式导致共同词项有限,相似度被拉低了。改进方向是用词向量或句子向量代替简单的词频向量,但那是更重的模型,不是本篇重点。
建索引阶段,我的做法是离线把十万篇文档全部转为稀疏向量,写入内存。查询时只扫一遍稀疏向量数组,每次计算只涉及非零维度,十万次点积大概几十毫秒,满足需求。如果进一步优化,可以给每个词建倒排索引,先筛出有共同词的文档,再计算相似度,但当前线性扫描已经够用,就保持简单。
5. 常见问题与排查技巧
5.1 零向量和稀疏向量怎么办
零向量是最容易踩的坑。一篇文档分词之后,所有词都不在词典里,它就会得到一个空的稀疏向量,计算时分母为0,程序直接崩。应对方法就是在所有入口做空向量检查,返回相似度为0,并且考虑要不要在构建索引时直接过滤掉空文档。
稀疏向量的存储也值得多聊两句。你可能会想用unordered_map<size_t, double>来存每个文档的向量,这样查询时可以在哈希表里快速查找维度,但哈希表节点分散,缓存命中率低,遍历慢。通常更好的做法是使用vector<pair<size_t, double>>,按ID严格升序,两个向量用归并方式找交集。归并过程中每个元素只访问一次,对缓存友好,性能比哈希表好不少。我实测过同样数据量,有序vector比unordered_map快接近4倍。
5.2 性能瓶颈在哪里
文本相似度检索的性能瓶颈一般有两个:一个是建索引阶段的词频统计和词典构建,这是离线任务,慢一点问题不大;另一个是查询阶段的相似度计算,这是核心关注点。
如果文档总量很大,线性扫描无法满足延迟要求,就要用倒排索引。简单说,每个词项维护一个文档ID链表,query包含哪些词,就只去这几个词的链表里找候选文档,显著减少需要计算相似度的文档数量。但倒排索引也有代价,维护索引结构复杂,更新文档要同步改链表。另外,如果query里包含“的”“是”这类高频词,候选集会非常大,所以要做停用词过滤或按文档频率加权。
另一个容易忽视的性能问题是向量维度的排序。如果每个向量构建时没排序,查询归并就退化成在vector里做线性查找,那还不如直接扫稠密数组。我在实现里专门加了一个排序步骤,确保每个稀疏向量按ID升序。
5.3 精度与速度的取舍
不同项目对相似度的精度要求差异很大。推荐系统要的是相对排序,差不多的精度也能忍受;去重系统要的是绝对阈值判断,误差可能导致误杀或漏杀。
一种常见的优化方法是把double改成float,内存减半,速度接近翻倍。float的精度大约7位有效数字,对相似度0到1之间的数值已经够用。如果连float都嫌重,可以把向量权重量化成整数,用int存词频大小,虽然会丢失精度,但配合倒排索引做粗排,效果也不错。
还有一个思路是使用局部敏感哈希做近似检索。提前对向量做哈希,把相似的item以高概率映射到同一个桶,查询时只在桶内计算精确相似度。这能大幅减少计算量,但会牺牲召回率。碰到海量数据场景,这是个很好的取舍方案,小数据量则没必要引入额外复杂度。
调试时,我习惯先跑几个小用例,打印中间向量的稀疏结构和相似度数值,而不是直接看最终TopN。这样能快速发现是特征提取的问题,还是相似度计算的问题。
6. 一点个人体会
代码写得久了,我越来越觉得相似度判断的难点不在数学公式,而在特征表示。同一个向量空间下,余弦相似度只是简单的点积除法,真正决定结果的是你用什么维度去描述对象。词频和词向量的区分度天差地别,坐标特征和归一化特征也会改变距离含义。所以每次接相似度需求,我会先花最多的时间确认数据的特征方案,再写代码,不然算法再标准也只是在错误的表示里打转。
还有一个小技巧:所有相似度计算模块,一定要在接口层做日志埋点,记录query和结果分数的分布。线上出问题时,通过日志分布可以快速判断阈值是否合理、特征是否退化。我吃过一次亏,某个去重模块上线后误杀率很高,查了半天才发现是词典更新任务没跑,所有新词都映射不到维度,相似度被系统性拉低。从那以后,我每次都会在构建索引后做一轮抽样验证,拿几个已知相似和已知不相似的样本跑一遍,确认结果符合预期再发布。
相似度判断看起来简单,实际上越往深处越有意思。先从余弦相似度搭起一个最小闭环,再根据数据规模逐步加倒排索引、稀疏优化甚至近似检索,这条路走通之后,你会发现自己对“怎么度量事务之间的关系”有了更深的敏感度。