
简介文本相似度计算与代码相似度检测在查重、关联分析、代码维护等场景中非常常用这套C#实现工具包从算法、界面到编译产物都覆盖到了。压缩包内共73个文件包含44个dll依赖库、6个cs源代码、4个exe可执行程序以及少量文本说明和工程配置整体体积约14.41MBdll负责依赖功能支撑cs是核心算法实现exe可直接启动并完成文本或代码相似度比对txt资源文件便于了解使用方式。项目以TextFingerPrint等策略为核心可能运用余弦相似度、编辑距离、最长公共子序列等经典算法分别从字符重叠、编辑代价与公共子结构角度衡量相似程度标签中的textcomparison、textsimilarity指明其文本比较主线unionecb或许代表组合比较策略Form1.cs等界面代码则用于输入交互与结果展示。已有411人学习下载适合开发者、算法研究者以及有文档比对、代码查重需求的使用者压缩包目录结构清晰bin目录下的编译产物便于快速验证源码便于继续改造与二次扩展。1. 计算文本相似度不是找相同为什么这版要把“相似”拆成三层来看文本相似度这件事做过的都知道它根本不是“找出相同文本”这么简单而是“在改了哪些地方之后仍然识别得出同一份逻辑”。做代码相似度检测时更明显同一个函数把变量名全部换成花名册编辑距离直接掉到 0.7 以下可它就是从一行改过来的反过来两段都引了公共日志模版的代码复制一遍注释相似度能虚高到 0.95。所以我这一版 textsimilarity 把“相似”拆成三层字符层、token 层、结构层最后用 unionecb 的分块并集策略把零散的局部命中聚合起来输出可解释的相似分数和证据片段。这条路适合做代码查重、稿件比对、日志聚类和存量数据清洗的人核心诉求是把“相似”从一个黑匣子数字变成一个能审、能查、能复现的指标。2. 算法底账字符、token、结构三层相似度与 unionecb 聚合先说一句得罪人的话只靠一个相似度算法就想通吃所有场景是文本相似度项目最常见的翻车起点。字符串层面的编辑距离、集合层面的 Jaccard、概率层面的 SimHash各有各的适用边界。这一章把账算清楚后面调参和排错才有了依据。2.1 字符层的编辑距离在代码场景里的边界字符层最经典的是 Levenshtein 距离和它的变体 Damerau-Levenshtein再实用一点的是 difflib 里的 SequenceMatcher它基于最长连续匹配子序列做比率计算对局部插入删除很敏感。这类算法适合什么短标题、标签、摘要50 字以内的重复判断效果好而且结果直观可解释。但放到代码相似度场景就有三个硬伤。第一是复杂度。Levenshtein 的经典动态规划是 O(n*m)两个 2000 行的文件比较一次在普通机器上就是秒级延迟根本扛不住批量任务。SequenceMatcher 做了优化但对长文本同样会在大段相同内容上消耗大量内存。第二是噪声敏感。代码里的换行风格、缩进、注释、空行都会让字符层的相似度出现大幅波动。同样的逻辑一个用 4 空格缩进、一个用 tab 缩进字符层面直接被扣掉好几分。第三是“改了变量名就全部错位”的问题。字符层比较的是字面内容user_name改成username从字符视角看就是一次删除加一次插入本来同一份代码被打成“部分相似”。所以我的结论是字符层只能做预筛和短文本兜底不能做代码相似度的主判决。在 textsimilarity 的管线里它只出现在两个地方——一是 50 字符以内的标题/日志消息去重二是最后给用户展示“哪几行最像”的证据片段时用 SequenceMatcher 找出具体差异区间。2.2 token 向量化与 SimHashtextsimilarity 做召回的基本盘既然字符层不靠谱下一步就是 token 化。把代码按标识符、关键字、操作符、字面量切碎得到一个 token 序列然后在这个序列上算集合相似度。最朴素的是 Jaccardset(a) 与 set(b) 的交集大小除以并集大小实现简单、效果直白。但它有一个盲区只看“有没有出现”不看“出现多少次”也不看顺序。两段都用了for、if、return的代码Jaccard 可能很高但逻辑完全不同。要处理频率和顺序就得向量化。常见做法是 TF-IDF 加余弦相似度token 做维度频率做权重。这个组合在文档去重里表现稳定但它在代码场景有一个麻烦代码的 token 分布非常不均匀self、return、print这类高频 token 会把余弦分数抬得很高真正起区分作用的函数名和业务变量反而被稀释。textsimilarity 的召回层没有直接用 TF-IDF而是用 SimHash。SimHash 的思路是给每个 token 算一个哈希值按哈希位做加权投票最后压成一个固定长度的指纹比如 64 位。比较两个文本时算指纹的海明距离距离越近说明越相似。它最大的价值是把“两两比较”从逐 token 的 O(n*m) 降成 O(1) 的位运算这在大规模全量对比时是数量级的差距。这里要泼一盆冷水SimHash 对“改词不改意”非常敏感。变量从user_age改成agemd5 哈希值完全变掉指纹也随之变化。所以 SimHash 只能当召回——先快速圈出“有可能相似”的候选对再交给后面的精排去验证。千万别拿它直接输出最终分数。2.3 unionecb 分块并集策略用“局部命中”替代“全文平均”召回做完真正的判决逻辑在 unionecb。unionecb 是我内部对这套分块比较策略的代号展开是 Union of Embedded-Chunk Blocks即“嵌入式分块并集比较”。思路不复杂把长文本切成块对每个块单独做特征嵌入和相似度计算最后把命中的块做并集合并而不是算完整个文件的一个单一分数就结束。为什么必须分块因为全文平均相似度会被内容长度稀释。举一个我实际踩过的例子一份 800 行的业务代码其中 150 行被人整体复制并改了业务字段剩下的 650 行是各自独立的部分。全文对比算出来相似度 0.31看着“不相似”但 150 行就是明晃晃的雷。如果分块比较那 150 行对应的块对会打出 0.9 以上的局部相似命中块集合合并之后结论立刻变成“存在大面积复制”。ECB 这个词借自分组密码里的 Electronic Codebook 模式每个分组独立加密、互不依赖。unionecb 也一样每个 chunk 独立计算指纹、独立寻找对方的最相似 chunk块与块之间不互相干扰。这样做的直接好处是局部改动不会拖垮整体判断被改动的块只影响它自己没改动的块仍然能命中。合并逻辑上我一般用“双向覆盖”而不是“单项命中”。也就是对 A 的每个块找到它在 B 中的最佳匹配同时对 B 的每个块也做一次反向匹配把两方向的命中块集合取并集。单向命中容易漏掉“B 比 A 长很多A 的内容只覆盖 B 的一部分”这类场景。并集合并之后输出两个数块命中覆盖率和平均最佳相似度最终分数由这两者加权合成。这时候返回给用户的就不只是一个分数还有一串命中块的预览文本这对代码查重场景的“可解释性”要求至关重要。3. 把 textcomparison 跑起来一套最小可运行的代码相似度管线原理说再多不落地都是空谈。这一章给出一套可以直接复现的最小管线使用 Python 标准库加少量正则不需要装 TensorFlow 或 PyTorch。整个管线分三步预处理、分块特征、unionecb 合并判决。3.1 代码预处理先把噪声降到最低代码相似度检测的第一步不是算相似度而是洗数据。我见过不少项目跳过这一步直接上算法结果相似度分数被注释和字符串字面量搅得一团糟。两段代码如果只在注释上不同那它们应该是 100% 相似如果只有字符串里的提示文案不同业务逻辑完全一致那至少也该有 0.95 以上。这些判断都建立在预处理把噪声清掉的前提下。import re def normalize_code(text: str) - str: # 去掉行注释// 开头直到换行同时兼容 Python 的 # 注释 text re.sub(r//[^\n]*, , text) text re.sub(r#[^\n]*, , text) # 去掉块注释/* ... */跨行匹配 text re.sub(r/\*.*?\*/, , text, flagsre.S) # 字符串字面量统一替换为固定占位符避免文案差异干扰结构相似度 text re.sub(r[^]*, STR , text) text re.sub(r\[^\]*\, STR , text) # 数字字面量归一化不同数值不应当成完全不同的代码 text re.sub(r\b\d\b, NUM , text) # 空白统一压缩为单空格 text re.sub(r\s, , text) return text.strip()这段逻辑按“从粗到细”的顺序清噪声先删注释再压字符串再归一化数字最后统一空白。这里有一个容易忽略的参数点字符串字面量的替换策略要看场景。如果是代码查重统一占位符是对的因为订单创建成功和创建订单成功不影响代码结构但如果是日志去重字符串本身就是核心信息不能替换需要在调用侧通过参数开关控制这一行。另外注意行注释的正则顺序。我同时匹配了//和#但#在 C/C 里是预处理指令在 Python 里是注释在 shell 里又是注释。如果你的语料是混合语言的建议按语言分别调预处理而不是一把梭。3.2 分块、特征与 unionecb 合并核心类的实现预处理完之后进入核心类。这个类我命名为 TextComparison职责是把两份代码文本输入进去吐出相似度分数和命中块证据。它内部实现了 token 化、重叠分块、SimHash、Jaccard 以及 unionecb 的合并逻辑。import hashlib from collections import Counter class TextComparison: def __init__(self, window_size64, step32, hash_bits64, chunk_threshold0.55): self.window_size window_size # 每个块包含的 token 数 self.step step # 滑窗步长小于窗口则重叠 self.hash_bits hash_bits # SimHash 指纹位数 self.chunk_threshold chunk_threshold # 块级相似度命中阈值 def _tokenize(self, code: str): # 统一小写后按标识符/关键字/数字切分 text normalize_code(code).lower() return re.findall(r[a-z0-9_], text) def _chunk(self, tokens): if not tokens: return [] if len(tokens) self.window_size: return [tokens] chunks [] i 0 while i self.window_size len(tokens): chunks.append(tokens[i:i self.window_size]) i self.step if i len(tokens): chunks.append(tokens[-self.window_size:]) return chunks def _simhash(self, tokens): v [0] * self.hash_bits for token in tokens: h int(hashlib.md5(token.encode(utf-8)).hexdigest()[:16], 16) for i in range(self.hash_bits): v[i] 1 if (h i) 1 else -1 return sum(1 i for i in range(self.hash_bits) if v[i] 0) def _hamming_sim(self, h1, h2): d bin(h1 ^ h2).count(1) return 1.0 - d / self.hash_bits def _jaccard(self, a, b): ca, cb Counter(a), Counter(b) inter sum((ca cb).values()) union sum((ca | cb).values()) return inter / union if union else 1.0 def _block_similarity(self, a, b): sim self._hamming_sim(self._simhash(a), self._simhash(b)) jac self._jaccard(a, b) return 0.7 * sim 0.3 * jac def compare(self, code_a, code_b): chunks_a self._chunk(self._tokenize(code_a)) chunks_b self._chunk(self._tokenize(code_b)) if not chunks_a or not chunks_b: return {similarity: 0.0, level: empty, hits: []} # 对 A 的每个块寻找 B 中的最佳匹配 best_scores [] hits [] for i, ca in enumerate(chunks_a): best 0.0 best_j -1 for j, cb in enumerate(chunks_b): s self._block_similarity(ca, cb) if s best: best s best_j j best_scores.append(best) if best self.chunk_threshold: hits.append({ a_index: i, b_index: best_j, sim: round(best, 3), a_preview: .join(ca[:8]), b_preview: .join(chunks_b[best_j][:8]) }) cover_a len(hits) / len(chunks_a) avg_best sum(best_scores) / len(best_scores) if best_scores else 0.0 similarity 0.6 * avg_best 0.4 * cover_a return { similarity: round(similarity, 3), level: hit if hits else none, hit_blocks: len(hits), total_blocks: len(chunks_a), hits: hits[:5] }这里有几个参数必须说明白。window_size决定了一块有多大64 个 token 大约相当于 8 到 12 行代码太小则分块碎片化、误报多太大则回到全文比较的稀释问题。step控制滑窗重叠度默认取窗口的一半保证边界处的相似代码不会被切成两半而漏掉。hash_bits默认 64短文本场景可以降到 32长文本 128 更稳但位运算成本随之上升。chunk_threshold0.55是一个保守起点实际项目里这个值一定要拿着标注样本重新校准后面第四章会细说。合并逻辑里我用了 0.6 和 0.4 两个权重这不是玄学拍脑袋而是从“可解释性”出发当平均最佳相似度一般但覆盖率高时说明 A 的大部分块在 B 里都能找到对应物此时应该判相似反之平均分高但只有一两个块命中说明是局部复制分数要往下压。0.6/0.4 只是初始值换语料之后同样要重新标定。3.3 用两个样本跑通并读懂输出写一个简单的调用例子。我用两段有“局部复制 变量改名 注释增加”的代码来验证管线这是代码查重里最常见的作弊形态。code_a def calc_total_price(items): # 计算订单总价 total 0 for item in items: total item.price * item.quantity if total 1000: total total * 0.9 return total code_b def get_final_amount(order_items): total 0 for product in order_items: total product.unit_price * product.amount if total 1000: total total * 0.9 return total tc TextComparison() result tc.compare(code_a, code_b) print(result[similarity]) print(result[level]) print(result[hits])这两段代码在变量名上做了全面修改calc_total_price改成get_final_amountitems改成order_items属性名也换了。字符层比较的话相似度大概率不到 0.6但 token 层归一化之后核心结构完全相同——循环、累加、打折判断、返回所有关键字和操作符顺序一致。跑这个例子的预期输出是 similarity 在 0.9 左右level 为 hithits 里能看到匹配上的块把for、total、if、return这些结构 token 展现在 preview 中。这里要提醒一句token 层的变量名归一化有一个度。我当前实现只做了小写化没有做变量名替换所以price和unit_price仍然算不同 token。如果你要抓“整段复制但变量名全改”的抄袭需要再加一层标识符归一化——把函数名、变量名按出现顺序映射为var0、var1这类占位符代价是误报率上升。这个开关建议做成语料级别的配置而不是全局默认打开。4. 参数与阈值调优分数出来之前先解决四个必调项很多人在相似度服务上线几个月后突然收到一堆误报投诉第一反应是“换个更先进的模型”。但根据我自己的血泪经验大多数线上问题不是算法不够强而是四个参数没有跟着语料调窗口大小、步长、哈希位数、块级阈值。这一章直接讲参数怎么调以及为什么调。4.1 阈值不是玄学用标注 pair 集校准先纠正一个惯性动作不要拍脑袋定“相似度大于 0.8 就算抄袭”。0.8 这个数在 A 语料上可能精确率很高在 B 语料上可能把两个独立实现全判成相似。正确做法是建一个标注集准备 100 到 300 对样本每对人工标注“相似 / 不相似”然后跑一遍算法画出一条随阈值变化的精确率-召回率曲线。我一般会挑两个维度来覆盖标注集一是“改写了变量名但逻辑相同”的正样本二是“用了同一个公共库但业务完全不同”的负样本。正样本负责验证预处理有没有把噪声清干净负样本负责验证公共代码是不是被误判。曲线画出来之后优先卡在精确率止跌回升的位置。如果精确率和召回率始终不能同时让人满意那就不是阈值问题是特征层面需要加结构信息回头调 window_size 或者加 AST 节点特征。4.2 四个必调参数window、step、hash_bits、chunk_threshold给出我在几个不同语料上调参后积累的参考区间参数默认值建议区间主要影响window_size6432~128块粒度太小碎片化误报太大局部复制被稀释step32window_size/4 ~ window_size滑窗覆盖率与计算量的取舍hash_bits6432~128区分度与冲突率短文本用 32长文本用 128chunk_threshold0.550.45~0.75决定“一个块算不算命中”直接影响精确率和召回率合成权重0.6/0.40.5/0.5 ~ 0.8/0.2平均相似度与覆盖率谁说了算window_size的设定依据是语料里函数/方法的中位长度。我做过一个实验把一批 Java 项目的函数按 token 数排序中位数在 45 到 70 之间所以 64 是合理的起点。如果你的语料是 SQL 存储过程动辄几百 token 一个过程建议直接开到 128。step则按耗时预算来收重叠越多越能抓住“块边界上的半截复制”但计算量近似翻倍。hash_bits对结论的影响往往被低估。64 位 SimHash 对中等长度代码足够但短代码几十个 token的指纹本身熵就不足64 位里可能一大半位根本没被投票海明距离容易出现假阳性。这时候降到 32 位反而更稳定。长文件反过来32 位冲突率偏高两个不相关的长文件可能因为少数高频 token 撞出海明距离接近造成虚高。chunk_threshold是唯一一个“必须拿标注集定”的参数。0.55 的意思是一个块只要与对方某个块达到 55% 相似就算命中。这个值调高到 0.7误报会明显减少但“改了三分之一”的变形抄袭会漏调到 0.45召回率上去了但模板代码容易泛滥。我一般先跑 0.5、0.6、0.7 三档看标注集上的 F1 再插值。4.3 性能预算5000 个文件全量比较的耗时与优化次序这套管线如果要跑批量任务得先算一笔复杂度账。假设每份文件平均 2000 tokenwindow_size64, step32那么每份文件大约产出 60 个块。两个文件做一次 compare块间两两比较是 60 × 60 3600 次_block_similarity每次都含两次 SimHash 和一次 Jaccard。实测单对耗时约 0.3 到 0.6 毫秒看机器和块数量浮动。真正的瓶颈在文件对的规模上。5000 个文件全量两两比较是 1250 万对按 0.5 毫秒估算单机要跑 1.7 小时左右。这里有两个尽量别省的优化第一先做 SimHash 全局召回把每份文件算一个全文指纹只对海明距离小于某个阈值的文件对做块级 compare候选对一般能压到全量的 5% 到 10%第二_chunk结果要缓存同一个文件被多个文件比较时分块和 SimHash 结果不应该每次重算。这两步做完5000 文件的批量任务能从 1.7 小时压到 10 分钟量级代价只是召回阈值的额外调校。5. textsimilarity 实战避坑记录五条血泪经验这一章把我和同事在 textsimilarity / textcomparison 落地过程中真正踩过的坑挑五条写出来每条都按“现象 → 原因 → 解决”的格式。如果你正准备把这套方案接进自己的服务建议先对号入座。5.1 全文分数被稀释改一处逻辑相似度只有 0.3现象用户报“这两段代码明明有一整块是抄的为什么你们给 0.31”核实后发现150 行复制内容夹在两段 800 行的独立代码里全文平均分确实只有 0.3 左右。原因没有分块或者分了块但最终分数只用了全文平均相似度。长文比较时局部高相似被大面积不相关内容拉平这是所有“一个分数走天下”的实现的通病。解决用 unionecb 分块并集判决。compare返回的hits列表直接给出哪些块命中了、相似度多少、预览内容是什么。上层展示时优先展示hit_blocks和最高分命中块而不是只报一个总分。这也是为什么我坚持把“覆盖率”和“平均最佳相似度”分开输出。5.2 变量改名就翻车token 化救不了的“同构不同词”现象两份代码逻辑完全一致但变量名、函数名、类名全部不同算出来相似度只有 0.5 出头漏报严重。原因token 层只做了小写化order_amount和total_money仍然是两个完全不同的 tokenSimHash 哈希值自然不同。这属于“语义同构、表面不同”的典型场景单纯的字符串或 token 比较都无解。解决两档策略。低危场景不做标识符归一化避免误报高危场景考试查重、代码抄袭判定开启标识符归一化——把所有自定义标识符按出现顺序映射为V0, V1, ...保留关键字和操作符不变。归一化之后结构相同但名字全改的代码相似度能拉到 0.9 以上。这个开关要按场景切不能全局开。5.3 空文本与超短文本的 1.0分母保护必须前置现象两个空文件、或者两行只有一个return的文件系统返回相似度 1.0直接触发告警。原因_jaccard里if union else 1.0这个保护太粗糙。两个空集合的并集为 0我直接给了 1.0这在数学上说得通但在业务上是灾难。短 token 序列的高相似同样有迷惑性一段 3 行的代码只要 token 完全重合Jaccard 和 SimHash 都会给出接近 1.0 的高分。解决在compare入口加长度下限。token 总数低于 10 的文本直接归入leveltoo_short不参与相似度判决。同时在_jaccard里区分“双方都为空”和“一方为空”只有一方为空时返回 0.0。这个坑虽然小但线上告警被“两个空文件相似度 100%”刷屏时会非常狼狈。5.4 公共模板代码虚高IDF 降权和白名单哪个先做现象大量误报来自同一框架的样板代码比如 Spring 的启动类、Flask 的应用入口。这些文件结构固定逻辑占比小但相似度计算根本不看语义直接给高分。原因SimHash 和 Jaccard 对高频 token 没有惩罚机制。main、class、def、import这些词在几乎所有代码里都出现它们拉高了块级相似度。解决先做白名单直接排除已知框架模板文件的对比适合企业内部对资产清单明确的情况再做近似 IDF 降权。降权不是非得引入 sklearn我用的方法是统计语料里所有块的 token 频率出现频率超过全量块数 20% 的 token 在 SimHash 投票时权重减半。实现上只需在_simhash的投票循环里把高频 token 的增量从 ±1 调成 ±0.5。注意白名单是“快而糙”IDF 是“慢而准”两者不冲突先加白名单止血再补 IDF。5.5 Unicode 全半角与不可见字符清洗顺序比算法更影响结果现象一份从 PDF 或者 Windows 环境转出来的代码和正常代码在字符层面看不出区别但相似度比预期低了 5 到 10 个百分点。原因全角逗号、全角括号、不换行空格、零宽字符这类不可见字符混进了 token。正则[a-z0-9_]在切分时会忽略它们但如果它们出现在字符串字面量里就会被[^]*捕获干扰并不大真正的问题是全角字符会让“两段视觉上一模一样的代码” 在字节级别完全不同最终影响的是字符层预筛而不是 token 层主判决。解决把 Unicode 规范化提到所有正则之前。unicodedata.normalize(NFKC, text)这一步能把全角字符折叠成半角把不换行空格折叠成普通空格。顺序很重要先 NFKC再删注释再替换字符串最后压缩空白。如果颠倒注释里包含全角字符会残留在代码正文里造成后续 token 化时的隐性噪声。6. 这个方案值不值得投入最后一公里交给黄金样本集算法做完、参数调完、避坑清单也背了一遍接下来最关键的一件事把这个方案固定成可回归的版本。我的习惯是维护一份“黄金 pair 集”里面每一条都是曾经在线上真实出现过的疑难案例包括误报和漏报两类。比如 5.2 里“变量改名导致漏报”的样本5.4 里“模板代码虚高导致误报”的样本全部沉淀下来每条约 30 到 50 对。每次调整算法、动参数、加预处理规则第一件事不是看新功能有没有生效而是把这份黄金集完整跑一遍对比新旧版本的相似度分数变化。分数不该动的地方动了不管新功能多诱人都要先排查回归原因再合入。具体实现上黄金集就是一组 JSON 文件每条记录包含 code_a、code_b、expected_levelhit / none、以及允许的分数波动范围。跑回归时输出一张对比表把预期和实际不一致的条目全量列出来。我自己就吃过没做回归的亏有一次为了压低模板误报把 IDF 降权调得过猛结果黄金集里 40% 的正样本掉到阈值以下而当时因为只看了误报指标下降差点以为优化成功幸亏回归脚本拦住了。最后补一句个人习惯对待相似度分数永远带着“分数是手段证据是目的”的心态。输出给用户的界面里我坚持把命中的块预览放在分数旁边因为任何算法都有盲区但证据片段能让用户自己判断这到底是不是一次有效的相似命中。这个方案不一定适合所有场景但如果你也在做代码查重或文本去重不妨先用这一套分块并集策略跑一版再根据你的语料去改预处理和阈值。希望帮到你。本文还有配套的精品资源点击获取