局部敏感哈希LSH实战:从原理到MinHash实现与Banding参数调优
做后端开发的人迟早会碰上一个棘手问题系统里有几百万条文本、图片或者其他高维数据要在极短时间内找出“和某一条足够相似”的一批。第一反应往往是循环两两比较运气好数据量小能撑住一旦规模上来计算量就是O(n²)几百万条数据一起跑光是时间成本就让人血压升高。这时候局部敏感哈希LSH是目前实践中性价比最高的一条路。它不追求“完全精确”而是用巧妙的概率策略把“找相似”变成“找同桶”让计算量从平方级直接压到接近线性。这篇文章不讲虚的我把原理、选型、MinHashLSH的完整实现、banding参数怎么定、实战踩坑还有LSH在其他场景的用法一起讲清楚。不管你是做文本去重、近似检索、推荐召回还是想搞懂向量索引底层在干嘛都能从这里找到可以直接抄的配置和代码。1. 先看痛点相似检索为什么不能靠暴力比对1.1 两座大山时间复杂度和高维失真先说最直观的时间问题。假设你有10万条文本两两比较一次相似度需要1毫秒那么全部配对需要10万×10万/2次比较也就是50亿次换算下来是50亿毫秒大约5.7天。这还只是10万条再翻10倍变成100万条耗时要乘以100直接到好几个月。日常业务里时间窗口通常以秒或分钟计算暴力比对根本不可能。更隐蔽的是第二座大山高维空间的距离失真。很多人以为相似度算不准是算法精度问题其实是数据在高维空间本身就会“塌缩”。当特征维度很高比如文本的几千维TF-IDF向量任意两点之间的欧氏距离都会趋于接近距离最近和最远的点差别很小。换句话说即便你把O(n²)算完用传统距离公式在高维空间里的排序也不一定可靠。精确KNN在高维下近似失效这是维数灾难的理论结论不是换个强一点的机器就能解决的。所以业务上要破局思路不是把计算机变快而是绕过“逐对比较”这个动作。大部分场景要的也不是理论上的精确排名而是“和查询对象足够相似的那些点”漏掉一些边缘项、混入少量不相似项业务上通常完全可接受。LSH的设计哲学就是顺着这个方向走的用随机化手段快速砍掉大量无关候选再对剩下的候选做精确校验。1.2 LSH的直觉让相似的数据撞进同一个桶传统哈希函数比如MD5、SHA有一个特点输入哪怕只改一个字符输出就会面目全非。这对数据完整性校验是好事但对相似检索完全是灾难。局部敏感哈希反其道而行之它的目标就是让输入越相似哈希结果越容易相同。理解LSH最好的方式是想像图书分拣。你要把几千本内容相近的书放到同一个书架上如果逐字比对每一页成本太高但如果只抽几页、看几个关键段落的主题词内容相似的书大概率会落入同一批筛选结果再把它们放到一起比对就快多了。LSH做的事就是设计一组“抽几页”的规则每条规则对应一个哈希函数规则的结果就是桶编号。原始数据之间的距离越近被同一条规则分到同一个桶的概率越高。这个“概率”非常关键。LSH不是保证相似点一定同桶而是让这个事件有很高的概率发生反之不相似点也有小概率撞桶也就是误报。后续检索就变成两阶段先用LSH把“候选对”粗筛出来再对候选对做精确距离计算。候选对数量远小于全量配对计算量一下子就从平方级降下来了。2. LSH的核心原理四个参数和一个夹逼条件2.1 从“距离近”到“碰撞概率高”LSH的正式定义一般写成四个参数对任意两个数据点x和y如果它们之间的距离小于某个阈值r1那么它们哈希碰撞的概率至少是p1如果距离大于另一个阈值r2那么碰撞概率至多是p2。同时要求p1大于p2r1小于r2。翻译成人话就是距离近的大概率同桶距离远的小概率同桶。这个“近的容易碰、远的很难碰”的对照关系就是“局部敏感”四个字的本质。判断一个哈希函数族能不能当LSH用核心就看它能不能拉出一个足够大的p1和p2之间的间隔间隔越大检索越干净否则候选集里会混入大量无关数据。注意这里的概率不是针对某一个固定哈希函数说的而是针对“一族”随机选择的哈希函数。实际使用中我们往往不只用一个哈希函数而是一口气生成几十到几百个组合起来降低随机性带来的方差。每个函数独立对数据分桶多几个视角交叉验证相似对更容易被“抓住”。2.2 不同相似度度量对应不同LSH家族LSH不是单点技术而是一个方法框架。你用什么距离度量就要配对应的LSH实现不能混用。最常见的几类相似度/距离常用LSH实现典型场景Jaccard集合相似度MinHash文本去重、用户行为相似余弦相似度随机投影、SimHash向量检索、文本语义近似欧氏距离p-stable LSH随机投影到线段上分箱低维向量近邻汉明距离按比特位随机抽样基因序列、哈希签名比对为什么会一一对应因为LSH要求的性质是“碰撞概率随距离单调递减”而每种距离函数的几何性质不同哈希方案自然不同。比如MinHash针对的是集合重叠关系它用随机排列取最小值来估计Jaccard相似度SimHash针对的是高维向量的方向一致性它用随机超平面分割空间欧氏距离下的p-stable LSH则依赖稳定分布把向量投影到标量区间。因此选型的第一件事不是选“哪个LSH库”而是确认业务到底在算什么相似性。如果你算的是集合重合度却拿SimHash硬套大概率得到紊乱结果。这一点在后面章节我会反复强调。2.3 为什么两阶段检索能大幅提速LSH的实际收益不在“精度多高”而在“候选集有多小”。假设有n条数据对全部数据两两比较需要n²/2次距离计算LSH先用哈希分桶只在同一个桶内部的点之间做候选配对。如果分桶均匀每个桶里只有几条数据候选对数量会远小于全量平方级别。我做过一个粗略估算100万条数据分成1万个桶、每桶约100条数据桶内候选对总数就是50万比全量5000亿对少了6个数量级。即便还要对候选做一次精确Jaccard或余弦计算开销也是完全可接受的。桶内的点如果数量还大就说明参数没调好或者某类高频数据在“污染”分桶后面踩坑部分会专门说。3. 手写MinHash LSH文本相似检测完整实现3.1 第一步把文本变成特征集合MinHash作用于集合所以文本先要转成集合。最常用的是把文本切成shingle碎块也就是n-gram。切成词还是切成字符需要根据场景选择。英文场景切词比较自然比如“local sensitive hashing”切成单词集合中文场景因为分词本身可能有误差我通常直接切字符n-gram既省去分词依赖又能捕捉局部字面重合。比如“局部敏感哈希”切成3-gram后得到“局部敏”“部敏感”“敏感哈”“感哈希”组合成集合。n太小会混入大量无意义片段n太大则集合过于稀疏、匹配困难。文本去重场景我一般从3开始试短文本用2更稳。预处理也很重要。全角半角要统一标点要看情况保留或去掉英文要统一小写。这些细节直接影响特征集合的纯净度。我在一个项目中因为没处理全角空格导致两篇肉眼一样的内容切出来的shingle对不上当时还以为是哈希参数写错了。3.2 第二步MinHash签名生成对两个集合算Jaccard相似度最直接的办法是求交集和并集。MinHash的精妙在于它用多个随机排列取最小值的办法把集合压缩成固定长度的签名向量同时保留相似度信息。核心结论是对两个集合做同一个随机排列排在最前面的元素落在两个集合交集中的概率正好等于这两个集合的Jaccard相似度。这个结论用概率想一想就通两个集合并集里共有U个元素其中交集里有I个任意等概率排列下第一个元素来自交集的概率就是I/U。因此只要对多个随机排列重复取最小值统计两个集合最小值相等的比例就能无偏估计Jaccard相似度。实际工程里不会真的对大规模数据做几十次随机排列代价太高。常见做法是用一组随机生成的线性哈希函数来模拟排列h(x)(a*xb) mod p其中a、b随机构造p是一个大素数。把特征的指纹值代进去对每个哈希函数取集合内最小值得到的K维数组就是MinHash签名。代码里可以用统一的特征指纹h0作为基值再乘以各哈希函数的系数避免重复计算。3.3 第三步Banding把签名变成桶得到了文档签名之后还不能直接把整条签名拿去当桶键因为两个相似文档的签名不会逐位完全相同直接整体哈希等于把机会全放弃。Banding的做法是把长度K40或K128的签名切成b个band每个band包含r行签名值。对每一个band把里面的r个值拼成一个元组对这个元组做哈希得到桶编号文档只要在任何一个band中元组完全相同就被视为候选对。为什么这么设计设两文档的真实相似度是s那么某个band内r个签名值全部相同的概率是s的r次方。一个band命中的概率是s^rb个band里至少一个命中的概率就是1-(1-s^r)^b。这个函数是一条随着s增大而迅速抬升的S形曲线。相似度越高的文档对越容易在某个band上完全一致从而进入候选集相似度低的则大概率一个band都中不了。Banding技术是LSH召回效果的灵魂下一章讲参数时还会用它。3.4 可以跑起来的完整代码我写一个不依赖第三方库的Python版本文本先切成3-gram生成40维MinHash签名再用b5、r8做banding最后用真实Jaccard校验候选对。这个结构适合快速测试也方便改成你自己的数据格式。import re import random import hashlib from collections import defaultdict PRIME 4294967311 MASK (1 32) - 1 def stable_hash(text: str, salt: int) - int: content f{salt}|{text}.encode(utf-8) return int(hashlib.md5(content).hexdigest()[:8], 16) def build_shingles(text: str, n: int 3) - set: text re.sub(r\s, , text).strip().lower() return {text[i:in] for i in range(len(text) - n 1)} def minhash_signature(features: set, num_hashes: int 40, a_listNone, b_listNone): if a_list is None: a_list [random.randint(1, PRIME - 1) for _ in range(num_hashes)] b_list [random.randint(0, PRIME - 1) for _ in range(num_hashes)] sig [float(inf)] * num_hashes for feat in features: h0 stable_hash(feat, 0) for i in range(num_hashes): h (a_list[i] * h0 b_list[i]) % PRIME if h sig[i]: sig[i] h return sig def lsh_candidates(signatures: dict, b: int 5, r: int 8): buckets defaultdict(list) for doc_id, sig in signatures.items(): assert len(sig) b * r, 签名长度必须是 b*r for band_idx in range(b): band tuple(sig[band_idx*r:(band_idx1)*r]) buckets[(band_idx, band)].append(doc_id) candidates set() for doc_ids in buckets.values(): if len(doc_ids) 1: sorted_ids sorted(doc_ids) for i in range(len(sorted_ids)): for j in range(i 1, len(sorted_ids)): candidates.add((sorted_ids[i], sorted_ids[j])) return candidates def jaccard(set_a: set, set_b: set) - float: inter len(set_a set_b) union len(set_a | set_b) return inter / union if union else 0.0 # demo texts { doc1: 局部敏感哈希是一种高效处理近似最近邻搜索的方法, doc2: 局部敏感哈希是一种用来处理近似最近邻检索的技术方案, doc3: 今天天气很好适合出门散步顺便买一杯咖啡, doc4: 今天天气不错出门散步的人很多咖啡店排队到门口, } random.seed(42) features {doc_id: build_shingles(text) for doc_id, text in texts.items()} sigs {doc_id: minhash_signature(feats) for doc_id, feats in features.items()} cand lsh_candidates(sigs, b5, r8) for pair in cand: i, j pair sim jaccard(features[i], features[j]) print(f候选对 {i}-{j} 真实Jaccard{sim:.3f})运行结果里doc1和doc2会被选为候选对且真实Jaccard较高doc3和doc4如果相似度达不到阈值大概率不会进入候选。这里的签名长度只有40随机性比较大正式场景我会至少用128甚至256位签名降低签名之间的方差。4. Banding参数到底怎么定阈值曲线与典型配置4.1 候选概率公式和拐点直觉上一章给出了候选概率公式P(s)1-(1-s^r)^b。这个公式是调参的地图。给定b和r把所有可能相似度s代入就能看到“进入候选集概率”随相似度变化的曲线。理想的曲线是阶梯形相似度高于阈值的对全部召回低于阈值的完全不召回。现实中的LSH曲线是渐变的选择b、r本质上就是在逼近某个目标阈值。工程界常用一个近似公式来估算阈值的“拐点”s≈(1/b)^(1/r)。推导思路很简单令s^r1/b代入P(s)得到1-(1-1/b)^b≈0.63也就是在这个相似度附近候选概率已经抬升到六成以上拐点大致在这里。想调高阈值就调小b或者调大r想调低阈值以获取更高召回就调大b或者调小r。我经常看到有人从网上抄来“b20, r2”之类的配置就上线完全不看自己的目标相似度结果候选爆炸或者漏检严重。配置不是从别处抄来的是从曲线里算出来的后面的典型配置表也只是一种起始参考。4.2 常见任务对应的起始配置我的经验值大致如下签名总长度Kb*r一般取40到128之间任务类型目标相似度拐点推荐b*r签名长度近乎重复查重、盗文检测0.8~0.9b5, r840中等相似语义相近、关联推荐0.6~0.7b10, r440宽泛召回检索初筛0.4~0.5b20, r240这套数字我实测过多次。以b5、r8为例目标相似度0.8附近时候选概率约0.6相似度0.5时只有不到2%的概率进候选。这种配置适合精准找“几乎重复”的数据误报少但会漏掉一些相似度0.5到0.7的对。如果你做的是新闻聚类或推荐召回希望把0.5左右的对也抓出来那b20、r2会更合适虽然它会带来更多误报需要靠精排过滤。签名长度K还会影响检索质量。K太小每个签名分量噪声大同相似度下命中的稳定性差K太大计算和存储开销线性上升。我一般从K40起步效果不理想就升到128。对十万级数据128维签名完全能接受。4.3 性能、漏报、误报的三角关系配置参数的本质是平衡三个指标候选对数量、漏报率、误报率。候选对数量直接决定后续精排要算多少次漏报率是“真的相似但对没进候选”的比例误报率是“不相似但进了候选”的比例。三条互相牵制没法同时最优。比如你把阈值曲线做得特别陡漏报少了但误报也少但前提是特征质量高、相似度估得准特征集合被高频词污染时再多band也救不回来。反过来你想提高召回把b调大候选对可能从几万对膨胀到上百万对精排的计算量又重新变成瓶颈。所以实际项目里我从来不是一次定死参数而是先跑一个小抽样集统计候选集大小和人工标注匹配结果再迭代调整。另外一个关键词是“hash桶容量”。Banding之后理论上每个band的桶数量应该接近文档数但如果某些特征太普遍大量文档共享同一个band元组就会出现一个桶装几千文档的“爆炸桶”候选对瞬间失控。遇到这种情况优先清理特征再调参数顺序不要反。5. 实战中的坑特征污染、爆炸桶与签名失真5.1 特征污染shingle粒度与高频片段踩坑第一例是我做评论去重时遇到的。系统跑完之后大量明显不相关的评论成了候选对人工一看发现它们都包含“哈哈哈哈”或者“无语”这种高频片段字符shingle把这些公共片段当成强相似信号真实Jaccard也被顶得很高。问题出在特征层面。MinHash只认集合重合度不认语义权重高频公共shingle会让不同文档的集合产生大量交集。解决办法有几种对shingle做频次统计把出现超过一定阈值的全局高频项过滤掉或者用n5、n7这种更长片段减少公共片段的比例再或者引入IDF类权重思想对特征做加权采样。我最后选择了“低频过滤适度增长ngram长度”的组合效果立刻正常。纯字符shingle对词序变化其实不敏感这是好事也是坏事。两段文字如果只是词序打乱字符shingle完全不同算出来相似度很低但业务上它们可能是同一份材料。如果你需要“乱序敏感性低”的相似判断可以考虑改成词级特征或者先做词频统计再抽词袋这取决于你要抓的是“字面镜像”还是“内容重排”。5.2 爆炸桶候选对数量失控另一个实战问题是“爆炸桶”。某次我用b20、r2做宽召回结果候选对数量比全量还大排查发现所有文档都在某些band里共享同一个值——因为特征集合太小比如只有三四个shingle签名里大量位置被同一个特征的最小值顶住导致band元组完全一致。特征太少是爆炸桶的常见原因。短文本尤其明显一条只有几十个字的文本切出来的shingle可能就十几个再经过40个哈希取最小之后很多维度都被同一两个特征霸占。解决方法是尽量让特征数量差异不大比如对过短文本做填充或放弃LSH直接全量比对另一个办法是提高r、降低b让band匹配条件更严格。爆炸桶还有一个隐蔽来源是随机哈希函数质量不好哈希值分布不均匀导致某些值频繁胜出所以生成a、b系数时的随机种子和素数选择也要固定好便于复现。5.3 签名失真哈希数量不够带来的方差MinHash签名越长估计出的Jaccard越接近真实值这一点很多人没有直观感受。我试过用20维签名做相似度估计发现同一对文档在不同随机种子下算出来的相似度能差0.15以上这对调参和阈值判断非常致命。签名长度K本质上是抽样量。在K个哈希函数中统计“最小值相等”的次数等于用K个样本估计概率K越大估计越稳。我实际测试中K40时估计误差大概在一个可接受的范围内K128时误差明显变小K低于20就只能在粗筛阶段用。如果你用真实Jaccard对候选对做精排签名精度的作用在于减少无效候选不用太担心。5.4 MinHash 还是 SimHash别用错场景不少初学者把MinHash和SimHash混为一谈其实它们面对的距离度量不同。SimHash把文档表示为带权重的特征向量比如TF-IDF对每个特征哈希后按位加权求和最终得到的是一个固定位数的哈希值两个文档的汉明距离反映余弦相似度。MinHash面对的是集合不关心词频权重天然适合“两袋子里装的元素重合度”。对比维度MinHashSimHash输入特征集合加权特征向量估计的相似度Jaccard余弦对词序不敏感较不敏感对高频词容易被污染权重可调相对可控典型场景去重、集合相似大规模文本粗排我的选择原则是业务里明确是“集合重合”语义用MinHash业务里是“语义方向”或包含权重信息用SimHash。两者也能配合用比如先用SimHash粗召回再用MinHash集合相似度做精排。关键是搞清楚自己在算什么而不是听说哪个流行。6. LSH不只是文本去重更多场景与落地建议6.1 图像感知哈希LSH思想在图片上的变体很多人没意识到图片查重里常用的感知哈希pHash其实就是一种LSH思想。把图片缩放、灰度化、做DCT变换之后取低频系数再二值化为64位指纹本质上就是把“视觉上相近”映射为“指纹汉明距离小”也就是在汉明空间上用LSH做检索。图片套上这个指纹之后再用banding分桶就能在几百万张图里快速找出相似图。我在一个商品图库去重项目里用过这个思路效果很稳。要注意pHash对裁剪和缩放有一定容忍度但对旋转、叠加文字比较敏感。如果你要抓的是“同一张图的压缩变体”pHash足够如果要抓“同一件商品的不同拍摄角度”就得换深度学习特征向量再在特征向量上做LSH或者ANN索引。6.2 推荐系统里的用户相似集合重叠的经典应用推荐场景经常要算“看了A的人还看了什么”。用户行为可以表示成物品集合两个用户相似度就用Jaccard定义。用户量一上千万求相似用户又是O(n²)问题这里直接套MinHashLSH就能把相似用户候选集算出来再对候选用户做协同过滤打分。我实践过的另一个变体是“点击序列相似”。把每个会话的点击物品序列转成集合用LSH找行为轨迹相似的用户群效果比基于规则的渠道归因好得多。核心还是那一步把行为数据转成干净的集合然后让LSH处理粗召回最后用精确指标打分。6.3 向量检索时代的LSH固定内存和理论清晰现在很多ANN流行HNSW、Product Quantization但LSH并没有过时。它最突出的两个优点索引结构简单、内存占用固定可控理论边界清晰对不同距离度量有明确概率保证。在数据分布变化不激烈、特征维度中等、对低延迟有要求的场景LSH仍然值得作为首选方案。落地建议只有一句话LSH只负责候选生成一切精确判断交给“精排层”。先把LSH参数调成“宁可多一点候选也不要漏”再在精排阶段用完整特征、准确距离把不相关的候选滤掉。这个两阶段结构既吃到了LSH的速度又保证了最终质量。不要试图让LSH一个步骤把所有事干完那是它作为召回算法的本分之外的要求。最后分享一个我自己的习惯任何LSH参数上线之前我都会先准备一份标注好的小数据集里面放几百对确定相似的文本和几百对确定不相似的文本把b、r、特征粒度当成参数去扫一遍画出候选命中率对比曲线。这一步花不了多长时间但能避开很多线上翻车现场。LSH不是万能的可一旦你用对了场景把候选生成和精排配合好它在海量数据上的优势几乎不是其他方案能轻易替代的。