C#实现词嵌入相似度查询:从零构建语义搜索系统

1次阅读
没有评论

共计 2750 个字符,预计需要花费 7 分钟才能阅读完成。

image.webp

为什么需要词嵌入相似度?

在开发搜索或推荐系统时,我们经常遇到一个核心问题:如何让计算机理解词语之间的语义关系?传统字符串匹配(如 SQL 的 LIKE 或正则表达式)只能判断字面是否相同,但无法识别 ” 手机 ” 和 ” 智能手机 ” 这类语义相近的词汇。

C# 实现词嵌入相似度查询:从零构建语义搜索系统

技术选型:从 TF-IDF 到 BERT

  1. TF-IDF:基于词频统计,适合关键词匹配但无法捕获语义
  2. Word2Vec/GloVe:将单词映射到 300 维左右的稠密向量,语义相似的词向量几何距离近
  3. BERT:上下文相关的深度模型,计算成本较高

对于大多数应用场景,预训练的词嵌入模型(如 GloVe)在效果和性能间取得了良好平衡。

核心实现步骤

1. 加载预训练词向量

推荐使用 Stanford 的 GloVe 或 Facebook 的 FastText 预训练模型。以下演示加载 GloVe 向量:

public class WordEmbeddings
{private readonly Dictionary<string, float[]> _vectors;

    public WordEmbeddings(string filePath)
    {_vectors = new Dictionary<string, float[]>(StringComparer.OrdinalIgnoreCase);

        foreach (var line in File.ReadLines(filePath))
        {var segments = line.Split(' ');
            var word = segments[0];
            var vector = Array.ConvertAll(segments[1..], float.Parse);
            _vectors[word] = vector;
        }
    }
}

2. 余弦相似度计算优化

使用 SIMD 指令集加速向量运算:

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static float CosineSimilarity(ReadOnlySpan<float> x, ReadOnlySpan<float> y)
{
    float dot = 0, xNorm = 0, yNorm = 0;

    for (int i = 0; i < x.Length; i++)
    {dot += x[i] * y[i];
        xNorm += x[i] * x[i];
        yNorm += y[i] * y[i];
    }

    return dot / (MathF.Sqrt(xNorm) * MathF.Sqrt(yNorm));
}

3. 完整查询示例

public class SemanticSearcher
{
    public IReadOnlyDictionary<string, float> FindSimilarWords(
        string targetWord, 
        int topK = 10)
    {if (!_embeddings.TryGetVector(targetWord, out var targetVec))
            throw new KeyNotFoundException($"{targetWord} not in vocabulary");

        var results = new ConcurrentDictionary<string, float>();

        Parallel.ForEach(_embeddings.Words, word =>
        {if (word != targetWord)
            {
                var similarity = CosineSimilarity(targetVec.AsSpan(), 
                    _embeddings[word].AsSpan());
                results.TryAdd(word, similarity);
            }
        });

        return results.OrderByDescending(kv => kv.Value)
                      .Take(topK)
                      .ToDictionary(kv => kv.Key, kv => kv.Value);
    }
}

性能优化技巧

  1. 内存优化:
  2. 使用 Span<T> 减少内存分配
  3. 对高频词建立独立缓存
  4. 查询加速:
  5. 预先归一化所有向量(使 L2 范数 =1)
  6. 对海量词汇使用近似最近邻 (ANN) 算法
  7. 并行计算:
  8. 用 Parallel.ForEach 处理批量查询
  9. 对 SIMD 操作启用AggressiveOptimization

常见问题解决方案

遇到生词 (OOV) 怎么办?

  1. 使用 FastText 模型(支持子词推断)
  2. 回退到字符 n -gram 组合
  3. 计算与已知词汇的平均相似度

数值不稳定问题

  • 添加极小值 ε 防止除以零:
    float eps = 1e-8f;
    return (dot + eps) / (MathF.Sqrt(xNorm) * MathF.Sqrt(yNorm) + eps);
  • 对极端值进行 clipping

扩展应用:文档相似度

通过词向量加权平均表示文档:

public float[] GetDocumentVector(IEnumerable<string> words)
{
    var validVectors = words
        .Where(w => _embeddings.ContainsWord(w))
        .Select(w => _embeddings[w])
        .ToArray();

    if (validVectors.Length == 0) 
        return new float[_embeddings.Dimensions];

    var sum = new float[_embeddings.Dimensions];

    foreach (var vec in validVectors)
    {for (int i = 0; i < vec.Length; i++)
        {sum[i] += vec[i];
        }
    }

    // 归一化
    float norm = MathF.Sqrt(sum.Sum(x => x * x));
    return sum.Select(x => x / norm).ToArray();}

单元测试要点

[TestMethod]
public void TestCosineSimilarity()
{var vec1 = new float[] {1, 2, 3};
    var vec2 = new float[] { 4, 5, 6};

    // 手工计算结果
    float expected = (1*4 + 2*5 + 3*6) / 
                    (MathF.Sqrt(1+4+9) * MathF.Sqrt(16+25+36));

    Assert.AreEqual(expected, 
        VectorMath.CosineSimilarity(vec1, vec2),
        0.0001f); // 允许浮点误差
}

实践心得

在实际项目中,我们发现 300 维的 GloVe 向量在商品搜索场景下,配合简单的缓存策略(最近查询词的 LRU 缓存),可以在 10ms 内完成百万级词汇库的相似度查询。关键是要做好预处理:词向量归一化、建立内存映射文件、避免不必要的内存拷贝。

对于更复杂的场景,可以尝试组合使用词嵌入与 BM25 等传统算法,在语义相关性和关键词重要性间取得平衡。

正文完
 0
评论(没有评论)