Hotdry.
ai-engineering

客户端GitHub Stars余弦相似度计算:WASM向量搜索与浏览器端工程化参数

深入解析完全在浏览器端运行的GitHub Stars相似度计算系统,涵盖128D嵌入向量训练、80MB数据压缩策略、USearch WASM精确搜索实现,以及应对GitHub API速率限制的工程化参数。

在推荐系统领域,基于用户行为的协同过滤一直是核心技术。然而,当这一技术需要完全在浏览器端运行时,工程挑战便成倍增加。最近在 Hacker News 上引起关注的 GitStars 项目,正是这样一个将 300k + 仓库嵌入向量、4 百万开发者兴趣矩阵压缩到 80MB,并通过 WebAssembly 在客户端完成余弦相似度计算的工程实践。

嵌入向量训练:从 1TB 原始数据到 128D 语义空间

项目的核心假设基于 “聚类假设”:相似兴趣的开发者会 star 相似的仓库。这一假设需要通过大规模数据验证,而 GitHub Archive 提供了约 1TB 的原始数据源。

数据预处理与采样策略

数据管道从 BigQuery 开始,通过两个关键查询构建兴趣矩阵:

  1. Stars 事件收集:筛选拥有 10-800 个 stars 的用户,排除机器人和非活跃账户
  2. 仓库元数据收集:获取仓库名称、提交日期和描述信息

最终生成的 Parquet 文件包含约 4 百万用户和 2.5 百万个唯一仓库。然而,直接使用所有数据训练既不现实也不必要。项目采用了随机分桶采样策略:将每个用户的 stars 列表随机分为两个非重叠的 “桶”,使用 EmbeddingBag 计算每个桶的平均嵌入向量。

损失函数选择:MultiSimilarityLoss 的平衡艺术

训练目标需要同时满足两个看似矛盾的条件:

  1. 同一用户的两个桶尽可能接近(正样本对)
  2. 不同用户的桶尽可能远离(负样本对)

MultiSimilarityLoss 在此场景下表现最佳。该损失函数的核心思想是,对于每个锚点样本,同时考虑多个相似度级别(正样本、中等相似样本、负样本),通过动态调整权重来优化嵌入空间。

// 简化的训练逻辑示意
const userStars = ["pandas", "numpy", "scikit-learn"];
const bucketA = ["pandas", "numpy"];  // 随机分桶
const bucketB = ["scikit-learn"];     // 随机分桶

// EmbeddingBag计算平均向量
const embeddingA = embeddingBag(bucketA);  // [0.2, -1.1, 0.9, ...]
const embeddingB = embeddingBag(bucketB);  // [0.1, -1.3, 0.6, ...]

// 损失计算:拉近embeddingA和embeddingB,推远与其他用户桶的距离

向量维度与压缩:128D FP16 的权衡

选择 128 维向量是基于多个因素的权衡:

  • 内存限制:300k 个 128D FP16 向量约 80MB,接近浏览器 IndexedDB 的合理上限
  • 语义表达能力:实验表明 128D 在仓库相似度任务上已足够
  • 计算效率:WASM 中 128 维向量的点积计算效率较高

使用 FP16 而非 FP32 进一步将内存占用减半,虽然浏览器原生不支持 FP16 运算,但可以在 WASM 层进行转换。

WASM 向量搜索:从 HNSW 退回到精确搜索的工程决策

索引结构的选择困境

最初考虑使用 HNSW(Hierarchical Navigable Small World)图索引,这是高维向量搜索的行业标准。然而测试发现,预计算的 HNSW 索引在浏览器中占用的内存超过了原始向量数据本身。

// HNSW索引内存占用分析
const rawVectors = 300000 * 128 * 2;  // 300k个128D FP16向量 ≈ 76.8MB
const hnswIndex = rawVectors * 1.5;   // HNSW索引额外开销 ≈ 115.2MB
// 总计:192MB,超出大多数浏览器的舒适区

USearch WASM 的精确搜索实现

项目最终选择了 USearch 库的 WASM 版本,但放弃了其近似搜索功能,转而实现精确搜索。虽然时间复杂度为 O (N),但对于 300k 量级的数据集,在现代浏览器中仍可接受。

关键实现细节:

  1. 内存管理:手动分配缓冲区,通过_malloc管理 WASM 内存
  2. FP16 转换:在 JavaScript 层将 FP16 转换为 FP32 供 WASM 使用
  3. 并行化:使用 Web Workers 将搜索任务分片
// WASM精确搜索的核心逻辑
const searchInWASM = (queryVector, vectors, k = 10) => {
  // 1. 分配WASM内存
  const queryPtr = Module._malloc(queryVector.length * 4);
  const vectorsPtr = Module._malloc(vectors.length * 4);
  
  // 2. 复制数据到WASM内存
  Module.HEAPF32.set(queryVector, queryPtr / 4);
  Module.HEAPF32.set(vectors, vectorsPtr / 4);
  
  // 3. 调用USearch精确搜索函数
  const resultsPtr = Module._usearch_exact_search(
    vectorsPtr, vectors.length / 128, 128,
    queryPtr, k
  );
  
  // 4. 读取结果并释放内存
  const results = new Uint32Array(Module.HEAPU32.buffer, resultsPtr, k * 2);
  Module._free(queryPtr);
  Module._free(vectorsPtr);
  Module._free(resultsPtr);
  
  return results;
};

性能优化参数

经过测试,以下参数组合在大多数设备上表现最佳:

  • 批量大小:一次处理 1000 个向量(平衡内存和速度)
  • Worker 数量:根据 CPU 核心数动态调整(navigator.hardwareConcurrency)
  • 缓存策略:向量数据缓存在 IndexedDB,有效期 7 天
  • 渐进加载:优先加载前 10k 个最流行仓库的向量

用户兴趣向量计算:GitHub API 集成与速率限制应对

向量平均的数学基础

用户兴趣向量通过简单平均其 starred 仓库的嵌入向量得到:

user_vector = (1/n) * Σ(repo_vector_i) for i in starred_repos

这一方法的有效性依赖于嵌入空间的线性性质。虽然不如更复杂的聚合方法(如注意力加权),但在实践中表现足够好。

GitHub API 速率限制的工程化处理

GitHub REST API 的速率限制是主要挑战:

  • 未认证用户:60 请求 / 小时
  • 认证用户:5000 请求 / 小时

应对策略:

  1. 分批请求:每批获取 100 个 stars(GitHub API 上限)
  2. 指数退避重试:遇到速率限制时等待时间递增
  3. 本地缓存:将获取的 stars 数据存储在 localStorage
  4. 增量更新:只获取上次同步后的新 stars
const fetchUserStars = async (username, token = null) => {
  const stars = [];
  let page = 1;
  const batchSize = 100; // GitHub API每页最大数量
  
  while (true) {
    // 检查本地缓存
    const cacheKey = `stars_${username}_${page}`;
    const cached = localStorage.getItem(cacheKey);
    if (cached && !forceRefresh) {
      stars.push(...JSON.parse(cached));
      page++;
      continue;
    }
    
    // 发起API请求
    const response = await fetch(
      `https://api.github.com/users/${username}/starred?per_page=${batchSize}&page=${page}`,
      {
        headers: token ? { Authorization: `token ${token}` } : {}
      }
    );
    
    // 处理速率限制
    if (response.status === 403) {
      const resetTime = response.headers.get('X-RateLimit-Reset');
      const waitTime = Math.max(0, resetTime * 1000 - Date.now()) + 1000;
      await new Promise(resolve => setTimeout(resolve, waitTime));
      continue;
    }
    
    const data = await response.json();
    if (data.length === 0) break;
    
    // 缓存结果
    localStorage.setItem(cacheKey, JSON.stringify(data));
    stars.push(...data);
    page++;
    
    // 礼貌性延迟,避免触发速率限制
    await new Promise(resolve => setTimeout(resolve, 100));
  }
  
  return stars;
};

技能雷达:从 128D 向量到可解释的技能维度

线性探针训练

为了让 128 维向量对人类可解释,项目训练了 10 个逻辑回归分类器(线性探针),每个对应一个技能类别(如 "GenAI"、"Web3"、"System Programming")。

训练过程:

  1. 参考仓库收集:使用 LLM 为每个类别生成 20 个代表性仓库列表
  2. 特征提取:获取这些仓库的 128D 嵌入向量
  3. 模型训练:训练逻辑回归区分不同类别
  4. 浏览器端推理:将用户向量输入训练好的模型得到概率分数
// 线性探针推理
const interpretUserVector = (userVector, probes) => {
  const skills = {};
  
  for (const [category, probe] of Object.entries(probes)) {
    // 逻辑回归推理:sigmoid(w·x + b)
    let score = probe.bias;
    for (let i = 0; i < 128; i++) {
      score += probe.weights[i] * userVector[i];
    }
    skills[category] = 1 / (1 + Math.exp(-score)); // sigmoid
  }
  
  return skills;
};

百分位转换:让相似度分数更直观

原始余弦相似度分数(-1 到 1)对用户不够直观。项目采用了分位数转换

  1. 预计算大量随机用户对的相似度分布
  2. 将原始分数映射到百分位(0-100%)
  3. "95% 匹配" 意味着比 95% 的随机用户对更相似

工程挑战与解决方案

内存管理优化

80MB 向量数据对浏览器内存构成压力,优化策略包括:

  1. 内存映射文件:将向量数据存储为 ArrayBuffer,避免复制
  2. 懒加载:只加载搜索所需的向量子集
  3. 内存监控:使用 performance.memory API 监控内存使用
  4. 清理策略:长时间不活动时释放部分内存

离线支持与数据同步

作为 "离线优先" 应用,需要处理:

  1. Service Worker 缓存:缓存核心资源供离线使用
  2. IndexedDB 版本管理:向量数据格式变更时的迁移策略
  3. 后台同步:在用户在线时静默更新数据
  4. 冲突解决:本地修改与服务器数据的合并策略

性能监控指标

关键性能指标包括:

  • 向量加载时间:目标 < 3 秒(80MB 数据)
  • 搜索延迟:目标 < 100ms(300k 向量)
  • 内存使用:目标 < 150MB 峰值
  • 首次输入延迟:目标 < 50ms

实际应用场景与限制

有效应用场景

  1. 仓库发现:基于现有 stars 推荐相似仓库
  2. 开发者网络:寻找兴趣相似的其他开发者
  3. 技能评估:通过 stars 分析技术栈倾向
  4. 趋势分析:识别新兴技术栈的采用模式

已知限制

  1. 冷启动问题:新用户或 stars 少的用户效果有限
  2. 流行度偏差:流行仓库在向量空间中占据主导
  3. 跨领域比较:不同技术领域间的相似度计算可能不准确
  4. 时间维度缺失:未考虑 stars 的时间顺序和趋势变化

未来扩展方向

技术改进

  1. 增量学习:支持新仓库和用户行为的在线更新
  2. 多模态融合:结合 README 文本和代码特征
  3. 时间感知:加入 stars 时间戳的时间衰减因子
  4. 个性化权重:根据用户行为调整不同 stars 的权重

产品化路径

  1. 浏览器扩展:集成到 GitHub 页面直接推荐
  2. 团队分析:分析团队整体的技术栈分布
  3. 招聘匹配:连接求职者与公司的技术栈需求
  4. 学习路径:基于现有技能推荐学习资源

结语

GitStars 项目展示了将传统推荐系统技术完全移植到浏览器端的可行性。通过精心设计的嵌入向量训练、WASM 优化和客户端缓存策略,实现了在无需后端服务器的情况下处理百万级向量数据的能力。

这一架构不仅适用于 GitHub stars 分析,其核心模式 —— 客户端向量计算、WASM 加速、离线优先 —— 可以扩展到其他需要隐私保护或低延迟的推荐场景。随着 WebAssembly 生态的成熟和浏览器能力的提升,我们有望看到更多复杂计算任务从服务器迁移到客户端。

资料来源:

  1. GitStars 项目主页:https://puzer.github.io/github_recommender/
  2. GitHub 仓库:https://github.com/Puzer/github-repo-embeddings
  3. Hacker News 讨论:https://news.ycombinator.com/item?id=46511903
查看归档