LSH(局部敏感哈希)分桶,海量数据下的相似性搜索解决方案

Springboot+LangChain4j+AI智能客服工单系统-AI调用性能优化-使用缓存-LSH分桶代码实现-海量数据下的相似性搜索

LSH,也就是局部敏感哈希分桶技术。我们将从实际问题出发,一步步揭开它的神秘面纱,从核心原理讲到具体的实战应用,希望能帮助大家彻底理解并掌握这项强大的工具。

让我们从一个常见的场景开始。想象一下,我们的客服系统每天都要处理海量的用户请求。当一个新问题进来时,我们最直接的想法就是看看历史记录里有没有类似的问题和解决方案。但如果数据量非常大,比如百万甚至千万级别,用传统的暴力搜索方法,也就是把新问题和每一个历史问题都比较一遍,这显然是不可行的,速度会慢得无法接受。因此,我们迫切需要一种更高效的解决方案。

LSH 分桶的完整流程

场景:来了一个新的工单内容

复制代码
用户提问:"账号登录失败怎么办?"

我们要把这个内容添加到合适的"桶"里,方便以后查找

📝 第一步:计算内容的"指纹"(SimHash)

复制代码
// 1. 先把文字转成向量 (Embedding)
float[] vector = embeddingModel.embed("账号登录失败").vector();
// vector = [0.123, -0.456, 0.789, ...] (1536 个数字)

// 2. 根据向量计算 SimHash (8 位二进制)
String simHash = computeSimHash(vector);
// simHash = "10110011"

SimHash 计算过程:

复制代码
public String computeSimHash(float[] vector) {
    Random random = new Random(42);  // 固定种子
    StringBuilder hash = new StringBuilder();
    
    // 生成 8 个随机超平面
    for (int i = 0; i < 8; i++) {
        float dotProduct = 0;
        
        // 点积计算
        for (int j = 0; j < vector.length; j++) {
            dotProduct += vector[j] * random.nextGaussian();
        }
        
        // 根据正负决定是 0 还是 1
        hash.append(dotProduct > 0 ? "1" : "0");
    }
    
    return hash.toString();  // "10110011"
}

通俗理解:

复制代码
向量有 1536 维 → 太长了不好比较
压缩成 8 位二进制 → "10110011"

就像:
- 向量 = 详细的 DNA 序列 (很长)
- SimHash = 血型 (A/B/O/AB,很简单)

🗑️ 第二步:确定要放入哪些桶

复制代码
// 获取相邻的桶 (包括自己和海明距离为 1 的)
Set<String> buckets = getNeighboringBuckets("10110011");

// buckets = {
//     "10110011",  // 自己 (海明距离 0)
//     "00110011",  // 翻转第 1 位 (海明距离 1)
//     "11110011",  // 翻转第 2 位
//     "10010011",  // 翻转第 3 位
//     "10100011",  // 翻转第 4 位
//     "10111011",  // 翻转第 5 位
//     "10110111",  // 翻转第 6 位
//     "10110001",  // 翻转第 7 位
//     "10110010"   // 翻转第 8 位
// }

// 总共 9 个桶 (1 个自己 + 8 个相邻)

为什么要放多个桶?

复制代码
✅ 好处:避免边界效应

例子:
- 问题 A: "账号登录失败" → SimHash: "10110011"
- 问题 B: "账号登不上去" → SimHash: "10110010" (只有最后一位不同)

虽然 SimHash 不同,但它们非常相似!
如果只放自己的桶,就会错过彼此。

放到相邻的桶后:
- 问题 A 在桶 ["10110011", "10110010", ...]
- 问题 B 在桶 ["10110010", "10110011", ...]

→ 它们有共同的桶 "10110010"!
→ 检索时就能找到彼此!

💾 第三步:写入 Redis 桶中

复制代码
private void writeLSHIndex(float[] vector, String exactKey) {
    // 1. 计算 SimHash
    String simHash = computeSimHash(vector);
    
    // 2. 获取相邻的桶
    Set<String> buckets = getNeighboringBuckets(simHash);
    
    // 3. 把 exactKey 添加到每个桶中
    for (String bucket : buckets) {
        // Redis Key 格式:ai:classification:lsh:[simhash]
        String bucketKey = CACHE_PREFIX + LSH_BUCKET_SUFFIX + bucket;
        
        // 使用 Redis Set 数据结构
        redisTemplate.opsForSet().add(bucketKey, exactKey);
        
        // 设置过期时间 (30 分钟)
        redisTemplate.expire(bucketKey, CACHE_TTL_MINUTES, TimeUnit.MINUTES);
    }
}

Redis 中的实际存储:

复制代码
写入前:
(空的)

写入 "账号登录失败" (exactKey = "ai:classification:exact:a1b2c3"):

ai:classification:lsh:10110011 = {
    "ai:classification:exact:a1b2c3"
}

ai:classification:lsh:00110011 = {
    "ai:classification:exact:a1b2c3"
}

ai:classification:lsh:11110011 = {
    "ai:classification:exact:a1b2c3"
}

... (共 9 个桶,每个都有这个 exactKey)
  • 每个桶里放着:
      • 工单内容的缓存 Key
      • 相似的内容会放在相同或相邻的桶里

📊 完整的添桶流程图

复制代码
┌─────────────────────────────────────┐
│ 新工单内容                            │
│ "账号登录失败怎么办?"                │
└──────────────┬──────────────────────┘
               ↓
┌─────────────────────────────────────┐
│ Step 1: 向量化                       │
│ vector = Embedding("账号登录失败")   │
│ → [0.123, -0.456, 0.789, ...]      │
└──────────────┬──────────────────────┘
               ↓
┌─────────────────────────────────────┐
│ Step 2: 计算 SimHash                 │
│ simHash = computeSimHash(vector)    │
│ → "10110011"                        │
└──────────────┬──────────────────────┘
               ↓
┌─────────────────────────────────────┐
│ Step 3: 获取相邻的桶                 │
│ buckets = getNeighboringBuckets()   │
│ → {"10110011", "00110011", ...}     │
│    (9 个桶)                          │
└──────────────┬──────────────────────┘
               ↓
┌─────────────────────────────────────┐
│ Step 4: 写入 Redis                   │
│ 对每个桶:                           │
│   SADD ai:classification:lsh:桶名   │
│        ai:classification:exact:a1b2c3│
│   EXPIRE 1800 (30 分钟)              │
└─────────────────────────────────────┘

🎯 实际例子演示

场景:连续来了 3 个工单

工单 1: "账号登录失败"
复制代码
// 计算
vector1 = [0.123, -0.456, 0.789, ...]
simHash1 = "10110011"
exactKey1 = "ai:classification:exact:abc123"

// 添桶
buckets1 = {"10110011", "00110011", "11110011", ...}

// Redis 状态
ai:classification:lsh:10110011 = {"ai:classification:exact:abc123"}
ai:classification:lsh:00110011 = {"ai:classification:exact:abc123"}
ai:classification:lsh:11110011 = {"ai:classification:exact:abc123"}
... (共 9 个桶)
工单 2: "账号登不上去"
复制代码
// 计算
vector2 = [0.121, -0.455, 0.788, ...]  // 和 vector1 很接近
simHash2 = "10110010"  // 只有一位不同!
exactKey2 = "ai:classification:exact:def456"

// 添桶
buckets2 = {"10110010", "00110010", "11110010", "10110011", ...}
// 注意:"10110011" 是共同的桶!

// Redis 状态
ai:classification:lsh:10110010 = {
    "ai:classification:exact:def456"  // 新增
}

ai:classification:lsh:10110011 = {
    "ai:classification:exact:abc123",  // 原有的
    "ai:classification:exact:def456"   // 新增的! ✅
}
... (其他桶略)
工单 3: "密码输入错误"
复制代码
// 计算
vector3 = [0.234, -0.567, 0.890, ...]
simHash3 = "11001100"
exactKey3 = "ai:classification:exact:ghi789"

// 添桶
buckets3 = {"11001100", "01001100", "10001100", ...}

// Redis 状态
ai:classification:lsh:11001100 = {
    "ai:classification:exact:ghi789"
}
... (和其他桶没有交集,因为差异较大)

🔍 检索时的效果

现在有人问:"账号登录不了"

复制代码
// 1. 计算查询的 SimHash
queryVector = [0.122, -0.454, 0.787, ...]
querySimHash = "10110010"

// 2. 获取要搜索的桶
bucketsToSearch = {"10110010", "00110010", "11110010", "10110011", ...}

// 3. 从桶中收集候选
candidateIds = new HashSet<>();

for (bucket : bucketsToSearch) {
    bucketKey = "ai:classification:lsh:" + bucket;
    Set<Object> members = redisTemplate.opsForSet().members(bucketKey);
    candidateIds.addAll(members);
}

// 结果:
// ai:classification:lsh:10110010 → {"ai:classification:exact:def456"}
// ai:classification:lsh:10110011 → {"ai:classification:exact:abc123", "ai:classification:exact:def456"}

// candidateIds = {
//     "ai:classification:exact:abc123",  // ← "账号登录失败"
//     "ai:classification:exact:def456"   // ← "账号登不上去"
// }

// ✅ 成功找到了相似的工单!

📈 关键要点总结

1. 为什么一个内容要放多个桶?

复制代码
✅ 扩大召回范围

就像:
- 你给文件打标签:"Java"、"编程"、"技术"
- 别人搜索这些标签时都能找到你的文件

LSH 的相邻桶 = 多个相关标签

2. 桶的命名规则

复制代码
ai:classification:lsh:[simhash]

示例:
- ai:classification:lsh:10110011
- ai:classification:lsh:10110010
- ai:classification:lsh:11001100

3. 为什么设置过期时间?

复制代码
redisTemplate.expire(bucketKey, 30, TimeUnit.MINUTES);

原因:

  • ✅ 避免无限增长
  • ✅ 只保留最近的热点数据
  • ✅ 自动清理旧数据

简单总结

想象一个蜂巢:

复制代码
每个桶里放着:
- 工单内容的缓存 Key
- 相似的内容会放在相同或相邻的桶里

添加过程:

复制代码
来了一个新内容 → 计算 SimHash → 找到自己的桶 → 也放入相邻的桶

检索过程:

复制代码
来了一个查询 → 计算 SimHash → 找自己的桶 + 相邻的桶 → 收集所有候选

这样设计既快速准确! 👍

相关推荐
风筝在晴天搁浅1 小时前
LFU缓存
缓存
计算机_毕业设计2 小时前
java-springboot数字藏品系统 基于 SpringBoot 的区块链数字艺术品交易平台 Java 微服务架构下的加密藏品展示与拍卖系统计算机毕业设计
java·spring boot·课程设计
ONVO ncen2 小时前
Redis6.2.6下载和安装
java
丑八怪大丑2 小时前
JDK8-17新特性
java·开发语言
京师20万禁军教头2 小时前
37面向对象(高级)-main方法
java
书源丶2 小时前
三十五、Java 泛型——类型安全的「万能模板」
java·开发语言·安全
dovens2 小时前
SpringBoot集成MQTT客户端
java·spring boot·后端
❀͜͡傀儡师2 小时前
Spring Boot 集成 RocksDB 实战:打造高性能 KV 存储加速层
java·spring boot·后端·rocksdb
BENA ceic3 小时前
Spring 的三种注入方式?
java·数据库·spring