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 → 找自己的桶 + 相邻的桶 → 收集所有候选
这样设计既快速 又准确! 👍