在数字化时代,文本数据的爆炸式增长带来了对文本相似性判断的迫切需求。无论是在内容推荐、版权保护、信息检索还是反抄袭检测等领域,准确判断文本之间的相似度都是至关重要的。传统的文本相似性判断方法,如基于词频的TF-IDF算法、余弦相似度等,虽然在某些场景下有效,但在处理大规模数据集时可能会遇到效率和精度的瓶颈。
相似度算法有很多种,可以根据不同的场景和需求选择合适的方法。一般来说,相似度算法可以分为以下几类:
随着AI的发展,除了上述传统相似度计算方法外,还可以依赖AI进行更加精确的语义化相似对比,在本篇文章中,综合实现的简易性以及实现成本,再对精度要求不是非常高的场景下,可以利用SimHash算法结合汉明距离计算文本间的相似性。
SimHash本身属于一种局部敏感hash,其主要思想是降维,将高维的特征向量转化成一个f位的指纹(fingerprint),通过算出两个指纹的汉明距离(hamming distince)来确定两篇文章的相似度,汉明距离越小,相似度越低(根据 Detecting Near-Duplicates for Web Crawling 论文中所说),一般汉明距离为3就代表两篇文章相同。
SimHash是Google在2007年发表的论文《Detecting Near-Duplicates for Web Crawling 》中提到的一种指纹生成算法或者叫指纹提取算法,被Google广泛应用在亿级的网页去重的Job中,作为locality sensitive hash(局部敏感哈希)的一种,其主要思想是降维,什么是降维? 举个通俗点的例子,一篇若干数量的文本内容,经过simhash降维后,可能仅仅得到一个长度为32或64位的二进制由01组成的字符串,这样我们再对降维后的数据进行对比远比直接对比原文要简单,使复杂的事物,能够通过降维来简化。
SimHash算法分为5个步骤:分词、hash、加权、合并、降维,具体过程如下所述:
1-5
等5
个级别的权重(如果是给定一个文本,那么特征向量可以是文本中的词,其权重可以是这个词出现的次数)。例如给定一段语句:“我是蒋固金”,分词后为:我
、是
、蒋固金
,然后为每个特征向量赋予权值:我(4)
、是(1)
、蒋固金(5)
,其中括号里的数字代表这个单词在整条语句中的重要程度,数字越大代表越重要。01
组成的n-bit
签名。比如我
的hash值Hash(我)
为100101
,蒋固金
的hash值Hash(蒋固金)
为101011
。就这样,字符串就变成了一系列数字。W = Hash * weight
,且遇到1
则hash值和权值正相乘,遇到0
则hash值和权值负相乘。例如给我
的hash值100101
加权得到:W(我) = 100101 * 4 = 4 -4 -4 4 -4
,给蒋固金
的hash值101011
加权得到:W(蒋固金) = 101011 * 5 = 5 -5 5 -5 5
,其余特征向量类似此般操作。我
的4 -4 -4 4 -4
和蒋固金
的5 -5 5 -5 5
进行累加,得到4+5 -4+-5 -4+5 4+-5 -4+5
,得到9 -9 1 -1 1
。n-bit
签名的累加结果,如果大于0
则置1
,否则置0
,从而得到该语句的simhash值,最后我们便可以根据不同语句simhash的汉明距离来判断它们的相似度。例如把上面计算出来的9 -9 1 -1 1
降维(某位大于0
记为1
,小于0
记为0
),得到的01
串为:1 0 1 0 1
,从而形成它们的simhash签名。32
位或64
位。6
,那么依据图中信息可知n
个词的哈希码如上面100110
、110000
。n
个哈希码按列相加,如果哈希码为1
,那么+weight
,反之-weight
,计算最后生成的结果,如上图所示的[13,108,-22,-5,-32,55]
。[13,108,-22,-5,-32,55]
转换成最终的哈希码[1,1,0,0,0,1]
,正取1
,负取0
。汉明距离(Hamming Distance)是一种用于衡量两个等长字符串之间差异的度量方式。它定义为在相同位置上不同的字符个数,即两个字符串对应位置字符不同的位数总和。
在Java中,HanLP
、Jieba
和IK分词器
都是常用的中文分词工具,各自有其特点和优缺点。
HanLP
HanLP
(Han Language Processing)是一款面向中文文本处理的自然语言处理工具包,由一系列模型和算法组成,包括分词、词性标注、命名实体识别等功能。
特性:
优点:
缺点:
Jieba
Jieba
分词是一个Python
实现的中文分词工具,由于其成熟和广泛应用,也有Java
的实现版本。
特性:
优点:
缺点:
Python
的原生实现,Java
版本可能在性能和功能上略有差距。IK分词器
IK分词器
是一个开源的中文分词工具,主要用于Java
平台。
特性:
优点:
缺点:
如果追求高准确度和多功能支持,HanLP
是一个不错的选择;如果对性能要求较高且需要简单易用的分词工具,可以考虑Jieba
或IK分词器
。在本节中将使用HanLP
实现文本的分词。
为了降低无关数据对分词结果的影响,在进行分词前可以对文本进行预处理,过滤一些特殊字符,示例代码如下:
java/**
* 过滤特殊字符
*/
public static String clearSpecialCharacters(String content) {
// 将内容转换为小写
content = StringUtils.lowerCase(content);
// 过滤特殊字符
String[] strings = { " ", "\n", "\r", "\t", "\\r", "\\n", "\\t",
" ", "&", "<", ">", """, "&qpos;", " " };
for (String string : strings) {
content = content.replaceAll(string, "");
}
return content;
}
增加HanLP
依赖包
xml<dependency>
<groupId>com.hankcs</groupId>
<artifactId>hanlp</artifactId>
<version>portable-1.8.4</version>
</dependency>
分词的策略在使用中可能发产生变化,可以将分词的逻辑抽象为一个接口以便于后续替换分词的实现。
javaimport java.util.function.BiConsumer;
/**
* 分词器
*/
public interface Tokenizer {
/**
* 分段
*
* @param content 内容
* @param consumer 分词 词性
*/
void segment(String content, BiConsumer<String, String> consumer);
}
根据定义的接口,完成HanLP
分词的实现。
javaimport com.hankcs.hanlp.seg.common.Term;
import com.hankcs.hanlp.tokenizer.StandardTokenizer;
import java.util.List;
import java.util.function.BiConsumer;
/**
* Hanlp分词器
*/
public class HanlpTokenizer implements Tokenizer {
@Override
public void segment(String content, BiConsumer<String, String> consumer) {
// 对内容进行分词处理
List<Term> terms = StandardTokenizer.segment(content);
for (Term term : terms) {
// 获取分词字符串
String word = term.word;
// 获取分词词性
String nature = term.nature.toString();
consumer.accept(word, nature);
}
}
}
不同的Hash
计算策略最终得到的结果也会不同,与分词逻辑一样,也可以将计算分词Hash值的逻辑抽象为接口。
java/**
* 分词Hash策略
*/
public interface WordHashStrategy {
/**
* 获取分词Hash
*
* @param word 分词
* @param nature 词性
* @param hashCount hash数量
*/
BigInteger getWordHash(String word, String nature, int hashCount);
}
接下来提供一种计算分词Hash值的实现示例
javaimport org.apache.commons.lang3.StringUtils;
import java.math.BigInteger;
/**
* 默认分词Hash策略
*/
public class DefaultWordHashStrategy implements WordHashStrategy {
/**
* 分词最小长度限制
*/
private static final int WORD_MIN_LENGTH = 3;
private static final BigInteger ILLEGAL_X = new BigInteger("-1");
private static final BigInteger BIGINT_2 = new BigInteger("2");
private static final BigInteger BIGINT_1000003 = new BigInteger("1000003");
private static final BigInteger BIGINT_NEGATIVE_2 = new BigInteger("-2");
private final int wordMinLength;
public DefaultWordHashStrategy() {
this(WORD_MIN_LENGTH);
}
public DefaultWordHashStrategy(int wordMinLength) {
this.wordMinLength = wordMinLength;
}
@Override
public BigInteger getWordHash(String word, String nature, int hashCount) {
if (StringUtils.isBlank(word)) {
// 如果分词为null,则默认hash为0
return BigInteger.ZERO;
}
// 分词补位,如果过短会导致Hash算法失败
while (word.length() < this.wordMinLength) {
word = word + word.charAt(0);
}
// 分词位运算
char[] wordArray = word.toCharArray();
BigInteger hash = BigInteger.valueOf(wordArray[0] << 7);
// 初始桶pow运算
BigInteger mask = BIGINT_2.pow(hashCount).subtract(BigInteger.ONE);
for (char item : wordArray) {
BigInteger temp = BigInteger.valueOf(item);
hash = hash.multiply(BIGINT_1000003).xor(temp).and(mask);
}
hash = hash.xor(new BigInteger(String.valueOf(word.length())));
if (hash.equals(ILLEGAL_X)) {
hash = BIGINT_NEGATIVE_2;
}
return hash;
}
}
java/**
* 使用SimHash算法生成文本的指纹信息
*
* @param content 文本内容
*/
public BigInteger simHash(String content) {
int[] hashArray = new int[this.hashCount];
// 设置分词统计量
Map<String, Integer> wordMap = new HashMap<>();
// 对内容进行分词处理
this.tokenizer.segment(content, (word, nature) -> {
// 过滤停用词
if (this.stopWordStrategy.isStopWord(word, nature)) {
return;
}
// 过滤超频词
if (wordMap.containsKey(word)) {
Integer count = wordMap.get(word);
if (count > this.wordOverCount) {
return;
}
wordMap.put(word, count + 1);
} else {
wordMap.put(word, 1);
}
// 计算单个分词的Hash值
BigInteger wordHash = this.wordHashStrategy.getWordHash(word, nature, this.hashCount);
// 设置初始权重
int weight = this.wordWeightStrategy.getWordWeight(word, nature);
for (int i = 0; i < this.hashCount; i++) {
// 向量位移
BigInteger bitMask = BigInteger.ONE.shiftLeft(i);
// 计算所有分词的向量
if (wordHash.and(bitMask).signum() != 0) {
hashArray[i] += weight;
} else {
hashArray[i] -= weight;
}
}
});
// 生成指纹,降维处理
BigInteger fingerprint = BigInteger.ZERO;
for (int i = 0; i < this.hashCount; i++) {
if (hashArray[i] >= 0) {
fingerprint = fingerprint.add(BigInteger.ONE.shiftLeft(i));
}
}
return fingerprint;
}
计算文章指纹除了上述步骤的分词与计算分词Hash值外,对分词后的数据还可以进行一些额外的处理,比如忽略一些语义上没什么意义的分词、出现频率过高的分词,针对不同的分词设置不同的权重等等。上述示例代码中已添加了相关处理逻辑,涉及到的辅助接口与实现可参考本节后续示例代码。
停用词策略
java/**
* 停用词策略
*/
public interface StopWordStrategy {
/**
* 是否停用词
*
* @param word 分词
* @param nature 词性
*/
boolean isStopWord(String word, String nature);
}
Hanlp
停用词策略实现javaimport com.hankcs.hanlp.corpus.tag.Nature;
import java.util.HashSet;
import java.util.Set;
/**
* Hanlp停用词策略
*/
public class HanlpStopWordStrategy implements StopWordStrategy {
private final Set<String> stopWordSet;
public HanlpStopWordStrategy() {
Nature[] stopNatures = {
// 去除标点符号
Nature.w, Nature.wd, Nature.wf, Nature.wj, Nature.wky, Nature.wkz,
Nature.wm, Nature.wn, Nature.wp, Nature.ws, Nature.wt, Nature.ww,
Nature.wyy, Nature.wyz,
// 去除助词
Nature.u, Nature.uzhe, Nature.ule, Nature.uguo, Nature.ude1,
Nature.ude2, Nature.ude3, Nature.usuo, Nature.udeng, Nature.uyy,
Nature.udh, Nature.uls, Nature.uzhi, Nature.ulian };
this.stopWordSet = new HashSet<>((int) (stopNatures.length / 0.75 + 1));
for (Nature nature : stopNatures) {
this.stopWordSet.add(nature.toString());
}
}
@Override
public boolean isStopWord(String word, String nature) {
// 过滤停用词性
return this.stopWordSet.contains(nature);
}
}
分词权重策略
java/**
* 分词权重策略
*/
public interface WordWeightStrategy {
/**
* 获得分词权重
*
* @param word 分词
* @param nature 词性
*/
int getWordWeight(String word, String nature);
}
java/**
* 固定分词权重策略
*/
public class FixedWordWeightStrategy implements WordWeightStrategy {
private final int wordWeight;
/**
* 默认固定分词权重1
*/
public FixedWordWeightStrategy() {
this(1);
}
public FixedWordWeightStrategy(int wordWeight) {
this.wordWeight = wordWeight;
}
@Override
public int getWordWeight(String word, String nature) {
return this.wordWeight;
}
}
java/**
* 获取相似度
*/
public double getSimilar(BigInteger one, BigInteger two) {
// 获取海明距离
double hammingDistance = this.getHammingDistance(one, two);
// 求得海明距离百分比
double scale = (1 - hammingDistance / this.hashCount) * 100;
return Double.parseDouble(String.format("%.2f", scale));
}
/**
* 获取海明距离
*/
public int getHammingDistance(BigInteger hash1, BigInteger hash2) {
// 求差集
BigInteger subtract = BigInteger.ONE.shiftLeft(this.hashCount).subtract(BigInteger.ONE);
// 求异或
BigInteger xor = hash1.xor(hash2).and(subtract);
int distance = 0;
while (xor.signum() != 0) {
distance += 1;
xor = xor.and(xor.subtract(BigInteger.ONE));
}
return distance;
}
java@Test
public void testSimHash() throws Exception {
List<String> list = new ArrayList<>();
list.add(SimHash.clearSpecialCharacters("我是蒋固金,欢迎查看我的博客"));
list.add(SimHash.clearSpecialCharacters("我是蒋固金,欢迎查看我的博"));
list.add(SimHash.clearSpecialCharacters("欢迎查看我的博客"));
list.add(SimHash.clearSpecialCharacters("我是蒋固金"));
list.add(SimHash.clearSpecialCharacters("我是"));
String original = SimHash.clearSpecialCharacters("我是蒋固金,欢迎查看我的博客");
SimHash simHash = new SimHash();
BigInteger originalSimHash = simHash.simHash(original);
// 计算相似度
for (int i = 0; i < list.size(); i++) {
BigInteger hash = simHash.simHash(list.get(i));
int distance = simHash.getHammingDistance(originalSimHash, hash);
double similar = simHash.getSimilar(originalSimHash, hash);
System.out.println("索引:" + i + ", 汉明距离:" + distance
+ ", 相似度:" + similar);
}
}
测试结果如下:
索引:0, 汉明距离:0, 相似度:100.0 索引:1, 汉明距离:8, 相似度:87.5 索引:2, 汉明距离:13, 相似度:79.69 索引:3, 汉明距离:18, 相似度:71.88 索引:4, 汉明距离:19, 相似度:70.31
上述示例仅为演示实现流程,实际使用时需要根据业务场景设置合适的参数以增加检测的准确度。
SimHash完整代码如下:
javaimport org.apache.commons.lang3.StringUtils;
import java.math.BigInteger;
import java.util.HashMap;
import java.util.Map;
public class SimHash {
/**
* 默认Hash数量
*/
private static final int DEFAULT_HASH_COUNT = 64;
/**
* 超频词最大上限
*/
private static final int WORD_OVER_COUNT = 5;
private final int hashCount;
private final int wordOverCount;
private final WordWeightStrategy wordWeightStrategy;
private final StopWordStrategy stopWordStrategy;
private final WordHashStrategy wordHashStrategy;
private final Tokenizer tokenizer;
public SimHash() {
this(DEFAULT_HASH_COUNT, WORD_OVER_COUNT, new FixedWordWeightStrategy(),
new HanlpStopWordStrategy(), new DefaultWordHashStrategy(),
new HanlpTokenizer());
}
public SimHash(int hashCount, int wordOverCount,
WordWeightStrategy wordWeightStrategy, StopWordStrategy stopWordStrategy,
WordHashStrategy wordHashStrategy, Tokenizer tokenizer) {
this.hashCount = hashCount;
this.wordOverCount = wordOverCount;
this.wordWeightStrategy = wordWeightStrategy;
this.stopWordStrategy = stopWordStrategy;
this.wordHashStrategy = wordHashStrategy;
this.tokenizer = tokenizer;
}
/**
* 使用SimHash算法生成文本的指纹信息
*
* @param content 文本内容
*/
public BigInteger simHash(String content) {
int[] hashArray = new int[this.hashCount];
// 设置分词统计量
Map<String, Integer> wordMap = new HashMap<>();
// 对内容进行分词处理
this.tokenizer.segment(content, (word, nature) -> {
// 过滤停用词
if (this.stopWordStrategy.isStopWord(word, nature)) {
return;
}
// 过滤超频词
if (wordMap.containsKey(word)) {
Integer count = wordMap.get(word);
if (count > this.wordOverCount) {
return;
}
wordMap.put(word, count + 1);
} else {
wordMap.put(word, 1);
}
// 计算单个分词的Hash值
BigInteger wordHash = this.wordHashStrategy.getWordHash(word, nature, this.hashCount);
// 设置初始权重
int weight = this.wordWeightStrategy.getWordWeight(word, nature);
for (int i = 0; i < this.hashCount; i++) {
// 向量位移
BigInteger bitMask = BigInteger.ONE.shiftLeft(i);
// 计算所有分词的向量
if (wordHash.and(bitMask).signum() != 0) {
hashArray[i] += weight;
} else {
hashArray[i] -= weight;
}
}
});
// 生成指纹,降维处理
BigInteger fingerprint = BigInteger.ZERO;
for (int i = 0; i < this.hashCount; i++) {
if (hashArray[i] >= 0) {
fingerprint = fingerprint.add(BigInteger.ONE.shiftLeft(i));
}
}
return fingerprint;
}
/**
* 过滤特殊字符
*/
public static String clearSpecialCharacters(String content) {
// 将内容转换为小写
content = StringUtils.lowerCase(content);
// 过滤特殊字符
String[] strings = { " ", "\n", "\r", "\t", "\\r", "\\n", "\\t",
" ", "&", "<", ">", """, "&qpos;", " " };
for (String string : strings) {
content = content.replaceAll(string, "");
}
return content;
}
/**
* 获取相似度
*/
public double getSimilar(BigInteger one, BigInteger two) {
// 获取海明距离
double hammingDistance = this.getHammingDistance(one, two);
// 求得海明距离百分比
double scale = (1 - hammingDistance / this.hashCount) * 100;
return Double.parseDouble(String.format("%.2f", scale));
}
/**
* 获取相似度
*/
public double getSimilar(long hash1, long hash2) {
// 获取海明距离
double hammingDistance = this.getHammingDistance(hash1, hash2);
// 求得海明距离百分比
double scale = (1 - hammingDistance / 64) * 100;
return Double.parseDouble(String.format("%.2f", scale));
}
/**
* 获取海明距离
*/
public int getHammingDistance(BigInteger hash1, BigInteger hash2) {
// 求差集
BigInteger subtract = BigInteger.ONE.shiftLeft(this.hashCount).subtract(BigInteger.ONE);
// 求异或
BigInteger xor = hash1.xor(hash2).and(subtract);
int distance = 0;
while (xor.signum() != 0) {
distance += 1;
xor = xor.and(xor.subtract(BigInteger.ONE));
}
return distance;
}
/**
* 获取海明距离
*/
public int getHammingDistance(long hash1, long hash2) {
long xor = hash1 ^ hash2;
int distance = 0;
while (xor != 0) {
distance++;
xor &= xor - 1;
}
return distance;
}
}
本文作者:蒋固金
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!