尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

用C++从零实现向量搜索引擎:原理与最小实现

用C++从零实现向量搜索引擎:原理与最小实现 最近在搭 RAG 应用的时候我的一个直观感受是很多开发者把向量搜索当成了“调一个 FAISS 接口”的事。真正到了线上索引要多大、内存够不够、查询延迟为什么抖动、召回不准到底差在哪这些问题一旦暴露出来光会调 API 是不够的。Hacker News 上有一个很有意思的项目标题是 “Show HN: Gram, a vector search engine I built in C”。Show HN 是 Hacker News 上开发者展示作品的传统板块能在那里出现通常意味着作者对代码质量有足够自信也愿意把实现细节摊开给社区讨论。用 C 从零写向量搜索引擎本身就是一件“吃力但值得”的事情因为它要求作者把相似度计算、索引结构、内存布局、并发查询这些底层问题全部串起来。这篇文章不打算搬运 Gram 的每一行源码而是在现有公开信息的基础上把它放进更大的技术背景里向量搜索引擎到底是什么、为什么值得用 C 写、核心难点在哪里以及怎么一步一步实现一个可运行的最小版本。我会给出完整的 C 代码和编译步骤你可以直接在自己的机器上跑通。适合读这篇文章的人主要有三类一是正在做 RAG 或语义搜索但只停留在调用现成库的开发者二是想用 C 练手、把算法落到工程实践的读者三是想评估“自研向量检索引擎 vs 使用现成组件”的架构师。1. 向量搜索引擎为什么突然成了标配先明确一个判断向量搜索引擎是 AI 应用从“演示”走向“生产”的分水岭。过去搜索是关键词匹配用户搜“苹果”系统返回包含“苹果”两个字的页面。但在大模型时代文档会先被 embedding 模型转成向量查询也会被转成向量所谓检索本质是在向量空间里找最近邻。这个转变解决了一个非常具体的问题语义鸿沟。用户输入“如何快速入睡”如果只做关键词匹配可能匹配不到“睡眠质量改善建议”这类内容因为字面完全不同。向量表示可以把语义相近的文本映射到相近的区域从而召回字面上不匹配但语义相关的结果。那么在工程上向量搜索和普通搜索的核心差别是什么普通搜索的关键词匹配可以依靠倒排索引这是一个非常成熟的体系。向量搜索却要在高维空间里做最近邻查找一篇文章通常会被表示成几百维甚至上千维的浮点向量。数据量一上来线性扫描在延迟和 CPU 上都扛不住。这正是 Gram 这类项目存在的意义它面向的正是“高维向量 海量条目 低延迟查询”这个问题。如果你只是几千条数据暴力搜索就够用但如果你想在十万、百万甚至亿级向量里做毫秒级检索就必须引入近似最近邻ANN索引。对普通后端开发者来说向量搜索已经不是一个“AI 领域专属技术”。RAG 应用的检索模块、推荐系统的召回层、电商的以图搜图、知识库的语义问答底层都在用同一套东西。理解了向量搜索引擎的原理等于把这些场景共同的地基打了一遍。2. 向量搜索引擎的核心概念与原理2.1 从文本到向量embedding 是什么在做向量搜索之前必须先有一个“把数据变成向量”的环节这个环节通常由 embedding 模型完成。比如把一段文本传给一个 BERT 系模型输出是一个 768 维的浮点数组把一张图片传给一个视觉模型输出可能是 512 维的浮点数组。这个数组就是向量它试图用数值位置表达语义。embedding 有一个关键性质语义相近的内容它们的向量在空间里距离更近。这个性质是向量搜索成立的前提。如果没有这个前提后面所有索引优化都只是数字游戏。2.2 相似度度量怎么判断两个向量接近判断两个向量是否相近常用三种度量余弦相似度计算两个向量夹角的余弦值值越大越相似范围为 [-1, 1]。文本语义搜索最常用。欧氏距离计算两个向量在空间中的直线距离值越小越相似。使用 L2 归一化后它和余弦相似度在排序上等价。内积不归一化时适合某些特定模型比如训练时就按内积优化的 embedding。在实现上余弦相似度公式并不复杂cosine(a, b) (a · b) / (|a| * |b|)其中a · b是点积|a|是向量的 L2 范数。代码实现时有一个常用优化先把所有向量归一化这样查询时只需要计算点积省去每次计算范数的开销。本文的 demo 就是采用这个思路。2.3 KNN 与 ANN精确搜索和近似搜索的取舍如果数据量不大可以遍历全部向量逐一计算相似度取 TopK这就是 KNNK 最近邻。它结果准确但时间复杂度是 O(N * D)N 是向量数量D 是维度。当 N 到达百万级D 到几百维一次查询要做亿次级浮点乘法延迟完全不可控。ANN近似最近邻的思路是不保证找到全局最优的 K 个结果但保证大概率找到足够好的结果交换来的是数量级上的性能提升。几乎所有的工业级向量搜索引擎都在做 ANN。理解 ANN 有一个很好的类比你在一座城市里找最像某人的路人。精确做法是把整座城市的人都看一遍而近似做法是先根据“住在哪个区”快速锁定几个候选区域只在区域内部仔细找人。Gram 这类项目本质上就是在做“锁定候选区域”这一步。2.4 几种常见索引结构对比索引结构核心思想查询速度召回质量实现难度暴力扫描遍历全部向量慢100%很低聚类倒排先聚类再在候选簇内搜索快较高中HNSW多层跳表结构的近邻图很快很高高PQ 乘积量化压缩向量存储用查表加速距离快中等高Gram 作为个人项目从零实现时通常会选择一条“由简到难”的路径先暴力扫描保证正确再加聚类索引或倒排索引做近似优化进阶版本会引入 HNSW 图索引。本文的最小实现就按这条路径展开。3. 为什么用 C 写Gram 项目的技术信号从 “Show HN: Gram, a vector search engine I built in C” 这个标题里能读出两层信息第一作者写了一个向量搜索引擎第二这个搜索引擎是用 C 写的。第二点并不只是“选择了某种语言”这么简单它背后是一组技术取舍。向量搜索引擎是典型的内存密集型和计算密集型系统。一次查询要读取大量浮点数做乘加运算索引构建时要频繁分配、移动内存并发查询时要考虑多核扩展。C 的优势恰好都落在这些点上没有运行时 GC 带来的停顿可以精确控制对象的内存布局可以直接使用 SIMD 指令做向量化加速也可以把索引序列化到磁盘后用 mmap 加载。相比之下Python 虽然开发效率高但性能关键路径最终还是要落到 C/C 扩展上。FAISS 的底层就是 C业界很多向量数据库的核心引擎也是 C。Gram 的价值在于它把“被封装在底层里的东西”直接以源码形式暴露出来像一个解剖标本。对开发者来说读这样一个项目的收获往往比读一个封装良好的商业 SDK 更大。另一个容易被忽略的信号是部署形态。纯 C 实现可以编译成单个可执行文件或库不依赖 Python 运行时也不依赖一堆第三方包。这在嵌入式环境、内网环境、容器镜像瘦身等场景里非常实用。如果你需要在资源受限的设备上做语义搜索C 几乎是绕不开的方向。当然自己写也有代价。向量搜索引擎不是“堆上几个算法”就能上生产的产品还涉及持久化、增量更新、并发控制、监控、异常恢复。Gram 更适合被当作一个学习项目和研究起点而不是直接替代成熟数据库。这个边界我会在后面的最佳实践章节详细展开。4. 环境准备与前置条件本文的 demo 代码依赖很少只要有一个支持 C17 的编译器即可。GCC 9 以上、Clang 12 以上、MSVC 2019 以上都能编译我以下用 GCC 做演示。4.1 目录结构建议按下面的结构组织文件gram_demo/ ├── include/ │ ├── search_index.hpp │ └── cluster_index.hpp ├── src/ │ ├── main.cpp │ ├── search_index.cpp │ └── cluster_index.cpp └── CMakeLists.txt可选如果不想用 CMake直接用 g 编译也可以本文会同时给出两种方式。4.2 直接编译命令g -stdc17 -O2 -marchnative \ src/main.cpp src/search_index.cpp src/cluster_index.cpp \ -Iinclude -o gram_demo这里的-O2开启编译优化-marchnative让编译器针对当前 CPU 生成指令对浮点密集运算有明显提升。如果你的运行环境不允许-marchnative去掉它也能正常编译。5. 从零实现一个可运行的最小向量搜索引擎下面进入核心部分。我会实现两个索引一个是暴力扫描索引用于确保正确性另一个是基于 KMeans 聚类的 ANN 索引用来展示“先缩小候选集再精排”的核心思想。所有代码放在一个简短的 C17 项目中。5.1 基础数据结构和精确搜索文件路径include/search_index.hpp#pragma once #include cmath #include string #include utility #include vector namespace gram_demo { using Vector std::vectorfloat; struct Doc { std::string id; Vector embedding; }; // 余弦相似度内部处理归一化 float cosine_similarity(const Vector a, const Vector b); class BruteForceIndex { public: void add(const std::string id, const Vector embedding); void build(); std::vectorstd::pairstd::string, float search( const Vector query, size_t top_k) const; size_t size() const { return docs_.size(); } private: std::vectorDoc docs_; std::vectorVector normalized_; }; } // namespace gram_demo文件路径src/search_index.cpp#include search_index.hpp #include algorithm #include numeric namespace gram_demo { float cosine_similarity(const Vector a, const Vector b) { float dot 0.0f; float na 0.0f; float nb 0.0f; for (size_t i 0; i a.size(); i) { dot a[i] * b[i]; na a[i] * a[i]; nb b[i] * b[i]; } return dot / (std::sqrt(na) * std::sqrt(nb) 1e-6f); } void BruteForceIndex::add(const std::string id, const Vector embedding) { docs_.push_back({id, embedding}); normalized_.push_back(embedding); } void BruteForceIndex::build() { for (auto v : normalized_) { float norm std::sqrt(std::inner_product(v.begin(), v.end(), v.begin(), 0.0f)); if (norm 0.0f) { for (auto x : v) x / norm; } } } std::vectorstd::pairstd::string, float BruteForceIndex::search( const Vector query, size_t top_k) const { Vector q query; float qnorm std::sqrt(std::inner_product(q.begin(), q.end(), q.begin(), 0.0f)); if (qnorm 0.0f) { for (auto x : q) x / qnorm; } std::vectorstd::pairfloat, std::string scores; scores.reserve(normalized_.size()); for (size_t i 0; i normalized_.size(); i) { float dot 0.0f; for (size_t j 0; j q.size(); j) { dot q[j] * normalized_[i][j]; } scores.emplace_back(dot, docs_[i].id); } std::sort(scores.begin(), scores.end(), [](const auto a, const auto b) { return a.first b.first; }); std::vectorstd::pairstd::string, float result; size_t n std::min(top_k, scores.size()); for (size_t i 0; i n; i) { result.emplace_back(scores[i].second, scores[i].first); } return result; } } // namespace gram_demo这段代码的关键逻辑是build()阶段把所有文档向量归一化search()阶段把查询向量也归一化然后直接计算点积。因为归一化后的点积就是余弦相似度这样可以省去每次遍历都重复计算范数。注意1e-6f这个极小值作用是防止对零向量做除法时产生NaN。5.2 聚类索引实现 ANN 近似搜索文件路径include/cluster_index.hpp#pragma once #include search_index.hpp #include cstddef #include random #include string #include utility #include vector namespace gram_demo { class ClusterIndex { public: ClusterIndex(size_t num_clusters, size_t max_iter 10); void add(const std::string id, const Vector embedding); void build(); std::vectorstd::pairstd::string, float search( const Vector query, size_t top_k, size_t probe_clusters 2) const; size_t size() const { return docs_.size(); } private: size_t num_clusters_; size_t max_iter_; std::vectorDoc docs_; std::vectorVector centroids_; std::vectorsize_t assignments_; std::vectorstd::vectorsize_t cluster_members_; }; } // namespace gram_demo文件路径src/cluster_index.cpp#include cluster_index.hpp #include algorithm #include limits #include numeric namespace gram_demo { ClusterIndex::ClusterIndex(size_t num_clusters, size_t max_iter) : num_clusters_(num_clusters), max_iter_(max_iter) {} void ClusterIndex::add(const std::string id, const Vector embedding) { docs_.push_back({id, embedding}); } void ClusterIndex::build() { if (docs_.empty()) return; const size_t n docs_.size(); const size_t dim docs_[0].embedding.size(); std::mt19937 rng(42); std::uniform_int_distributionsize_t dist(0, n - 1); centroids_.clear(); centroids_.reserve(num_clusters_); for (size_t i 0; i num_clusters_; i) { centroids_.push_back(docs_[dist(rng)].embedding); } assignments_.assign(n, 0); for (size_t iter 0; iter max_iter_; iter) { // 第一步把每个文档分配给相似度最高的质心 for (size_t i 0; i n; i) { float best -std::numeric_limitsfloat::max(); size_t best_c 0; for (size_t c 0; c num_clusters_; c) { float sim cosine_similarity(docs_[i].embedding, centroids_[c]); if (sim best) { best sim; best_c c; } } assignments_[i] best_c; } // 第二步用簇内向量均值更新质心 std::vectorVector new_centroids(num_clusters_, Vector(dim, 0.0f)); std::vectorsize_t counts(num_clusters_, 0); for (size_t i 0; i n; i) { size_t c assignments_[i]; for (size_t j 0; j dim; j) { new_centroids[c][j] docs_[i].embedding[j]; } counts[c]; } for (size_t c 0; c num_clusters_; c) { if (counts[c] 0) { for (size_t j 0; j dim; j) { new_centroids[c][j] / static_castfloat(counts[c]); } } else { new_centroids[c] centroids_[c]; } } centroids_ std::move(new_centroids); } // 归一化质心查询时可以直接用点积选簇 for (auto c : centroids_) { float norm std::sqrt(std::inner_product(c.begin(), c.end(), c.begin(), 0.0f)); for (auto x : c) x / norm; } cluster_members_.assign(num_clusters_, {}); for (size_t i 0; i n; i) { cluster_members_[assignments_[i]].push_back(i); } } std::vectorstd::pairstd::string, float ClusterIndex::search( const Vector query, size_t top_k, size_t probe_clusters) const { Vector q query; float qnorm std::sqrt(std::inner_product(q.begin(), q.end(), q.begin(), 0.0f)); if (qnorm 0.0f) { for (auto x : q) x / qnorm; } // 先定位最近的 probe_clusters 个簇 std::vectorstd::pairfloat, size_t cluster_scores; cluster_scores.reserve(num_clusters_); for (size_t c 0; c num_clusters_; c) { float dot 0.0f; for (size_t j 0; j q.size(); j) { dot q[j] * centroids_[c][j]; } cluster_scores.emplace_back(dot, c); } std::sort(cluster_scores.begin(), cluster_scores.end(), [](const auto a, const auto b) { return a.first b.first; }); // 只在候选簇内部做精排缩小搜索范围 std::vectorstd::pairstd::string, float candidates; size_t p std::min(probe_clusters, num_clusters_); for (size_t i 0; i p; i) { size_t c cluster_scores[i].second; for (size_t d : cluster_members_[c]) { float sim cosine_similarity(q, docs_[d].embedding); candidates.emplace_back
返回列表