一、这玩意儿到底是干嘛的?
说白了,这是一个用纯 C++ 写的句子嵌入(Sentence Embedding)推理引擎。它能干的事情很简单:你丢给它一句话,它返回一个固定长度的向量(比如 384 维)。这个向量不是随便生成的——语义相近的句子,向量在空间里的距离也近;语义无关的句子,向量离得远。
有了这个能力,你就能做语义搜索:把文档库里所有句子都转成向量存起来。用户提问时,把问题也转成向量,然后算一下余弦相似度,找出最相近的几个句子,就是答案。
它最狠的地方在于体积极小。用 all-MiniLM-L6-v2 这个模型,全精度要 43MB,但经过 4-bit 量化后只有 14MB。更离谱的是,量化后的准确率几乎没掉——在标准的语义相似度测试集上,全精度得分 0.8201,4-bit 量化得分 0.8175,差距小到可以忽略。
而且它是纯 C++ 实现,基于 ggml 张量计算库,不依赖 PyTorch、不依赖 CUDA,直接在 CPU 上跑,x86 有 AVX2 加速,ARM 也能跑。
二、为什么要搞这个?
现在做语义搜索,主流方案是调 SentenceTransformers 的 Python 库,背后用 PyTorch 推理。这套方案的问题是:
第一,太重。 PyTorch 本身就要几百 MB,再加上模型权重,部署到边缘设备或者服务端容器里,资源占用很大。
第二,依赖多。 Python 环境、PyTorch、CUDA(如果用 GPU 的话),部署链条长,出问题的地方也多。
第三,性能不可控。 Python 的 GIL、PyTorch 的调度 overhead,在 latency 敏感的场景下很头疼。
所以这个项目的思路很直接:把模型权重导出来,用 C++ 和 ggml 重新实现推理逻辑,支持 4-bit 量化,直接跑在 CPU 上。 体积小、依赖少、速度快、跨平台。
三、核心设计思路
3.1 基于 ggml:不重复造轮子
ggml 是一个专门为端侧推理设计的张量计算库,支持:
多种数据类型:F32、F16、Q4_0、Q4_1(4-bit 量化)
多线程并行计算
跨平台:x86(AVX/AVX2)、ARM(NEON)、Apple Silicon
自动图构建和计算
这个项目没有自己写矩阵乘法、Softmax、LayerNorm,而是直接调用 ggml 的 API。这样可以把精力集中在模型结构的正确还原和** tokenizer 的实现**上。
3.2 模型结构:还原 Transformer 编码器
它支持的是 Transformer 编码器架构(比如 BERT、MiniLM、DistilBERT 等)。模型结构分成几大块:
词嵌入层(Embeddings)
Word Embeddings:把 token ID 转成向量
Token Type Embeddings:区分句子 A 和句子 B(虽然这里主要做单句)
Position Embeddings:给每个位置加一个位置编码
三层加完后,过一层 LayerNorm
编码器层(Encoder Layer)× 6每一层里面有两大部分:
输入分别过 Q、K、V 三个线性变换
算注意力分数:softmax(Q × Kᵀ / √d)
用注意力分数加权 V,得到注意力输出
过一个线性投影,加残差连接,LayerNorm
先扩维(384 → 1536),GELU 激活
再压回(1536 → 384)
加残差连接,LayerNorm
池化 + 归一化(Pooler + Normalizer)
对所有 token 的向量做 Mean Pooling(取平均)
对结果做 L2 归一化,得到最终的句子嵌入
3.3 4-bit 量化:体积砍到 1/3
量化是这个项目的一大亮点。它的思路很简单:
模型权重在训练时用的是 32 位浮点数(F32),但推理时不需要那么高的精度。把权重压到 4 位整数,再配合一个缩放因子,就能在几乎不损失精度的情况下,把模型体积砍到 1/6~1/8。
具体来说,支持四种格式:
格式 | 每权重位数 | all-MiniLM-L6-v2 体积 | STS 基准得分 |
F32 | 32 bit | ~86 MB | 0.8201 |
F16 | 16 bit | ~43 MB | 0.8201 |
Q4_0 | 4 bit | ~14 MB | 0.8175 |
Q4_1 | 4 bit | ~14 MB | 0.8223 |
Q4_0 和 Q4_1 的区别在于:Q4_0 每 64 个权重共享一个缩放因子;Q4_1 除了缩放因子还多一个偏移量,精度稍微好一点点。从测试结果看,4-bit 量化的得分和全精度几乎没差别,但体积只有 1/3,推理速度还更快。
3.4 Tokenizer:WordPiece 分词
文本不能直接喂给模型,要先切成 token。这里用的是 WordPiece 分词器,规则如下:
去掉重音符号(比如 é → e)
全部转小写
用正则表达式把文本切成单词、标点、数字
对每个单词,从长到短匹配词表里的 token。如果单词不在词表里,就拆成子词(subword),前面加 ## 标记
句子开头加 [CLS](分类标记),结尾加 [SEP](分隔标记)
比如 "health insurance" 可能被切成:[CLS], health, insurance, [SEP]。
四、代码实现原理拆解
4.1 模型加载
模型文件是 ggml 格式的二进制文件,加载时做这几件事:
验证魔数:文件开头必须是 0x67676d6c("ggml" 的 ASCII),防止读错文件
读超参数:词表大小、最大 token 数、嵌入维度、层数、头数、量化类型等
读词表:把每个 token 的字符串和 ID 映射存到字典里
创建 ggml 上下文:根据模型大小申请一块连续内存
创建张量:按模型结构创建每个权重张量,并建立 名字 → 张量指针 的映射
读权重数据:从文件里把权重数据读进对应的张量
加载完成后,还会做一次"预热":用 4 个 token 跑一遍前向推理,目的是算出 每个 token 需要多少内存,方便后续动态调整计算缓冲区的大小。
4.2 前向推理(Forward Pass)
推理的核心在 eval 函数里。它用 ggml 的"计算图"(Computation Graph)来描述整个前向过程:
创建输入张量:token ID、token type(全0)、position(0,1,2...N-1)
查嵌入表:分别查 word、type、position 三张嵌入表,加起来
嵌入层 LayerNorm:归一化 + 缩放 + 偏移
循环 6 层编码器:
自注意力:Q/K/V 变换 → reshape → permute → 算 KQ → scale → softmax → 乘 V → 合并
注意力输出投影 + 残差 + LayerNorm
前馈网络:扩维 → GELU → 压维 + 残差 + LayerNorm
Mean Pooling:对所有 token 向量取平均
L2 归一化:除以向量的模长,得到单位向量
执行计算图:ggml 自动调度多线程执行
4.3 批处理(Batching)
目前批处理是"伪批量":虽然接口支持一次传多个句子,但底层还是逐个推理。真正的批处理(把多个句子的矩阵拼在一起算)还在开发中。
不过代码里做了个小优化:按句子长度排序。先把短句子和短句子放一起,长句子和长句子放一起,这样可以减少 padding 的浪费。
4.4 C API 设计
项目暴露了一套 C 接口,方便被其他语言调用:
// 加载模型struct bert_ctx* bert_load_from_file(const char* fname);// 单句编码void bert_encode(struct bert_ctx* ctx, int32_t n_threads, const char* text, float* embeddings);// 批量编码void bert_encode_batch(struct bert_ctx* ctx, int32_t n_threads, int32_t n_batch_size, int32_t n_inputs, const char** texts, float** embeddings);// 释放模型void bert_free(struct bert_ctx* ctx);Python 示例就是通过 ctypes 调用这套 C API 实现的。
五、相关领域知识点速览
知识点 | 一句话解释 |
| 句子嵌入(Sentence Embedding) | 把整句话压缩成一个固定维度的向量,语义相近的句子向量距离近 |
| Transformer 编码器 | 用自注意力机制捕捉句子中词与词的关系,层层提炼语义 |
| 自注意力(Self-Attention) | 每个词都"看看"其他所有词,根据相关性加权组合信息 |
| LayerNorm | 对每一层的输出做归一化,让训练更稳定 |
| GELU 激活函数 | 比 ReLU 更平滑的激活函数,Transformer 标配 |
| Mean Pooling | 把所有 token 的向量取平均,得到句子级别的表示 |
| L2 归一化 | 把向量长度缩成 1,这样余弦相似度就等于点积 |
| 4-bit 量化(Q4_0/Q4_1) | 把 32 位浮点权重压到 4 位整数,配合缩放因子还原 |
| ggml | 轻量级张量计算库,支持量化和多线程,llama.cpp 同款 |
| WordPiece 分词 | 把单词拆成子词单元,处理未登录词(OOV) |
| 余弦相似度 | 两个向量夹角的余弦值,越接近 1 越相似 |
| STS Benchmark | 语义文本相似度标准测试集,衡量句子嵌入质量 |
六、这玩意儿能用来干啥?
语义搜索:把文档库编码成向量,用户提问时找最相似的文档
文本聚类:把大量句子编码后做聚类,自动发现话题
重复检测:判断两句话是不是同一个意思(去重、查重)
推荐系统:把用户 query 和商品描述都编码,做语义匹配
RAG(检索增强生成):给大语言模型外挂一个知识库,先检索相关段落再生成答案
七、怎么跑起来?编译和测试手把手教
7.1 下载模型
pip3 install -r requirements.txtpython3 models/download-ggml.py download all-MiniLM-L6-v2 q4_0这会下载 4-bit 量化版的 all-MiniLM-L6-v2 模型,只有 14MB 左右。
如果你想自己转换模型(比如用别的 HuggingFace 模型):
cd modelsgit clone https://huggingface.co/sentence-transformers/multi-qa-MiniLM-L6-cos-v1sh run_conversions.sh multi-qa-MiniLM-L6-cos-v1这会生成 F32、F16、Q4_0、Q4_1 四个版本。
7.2 编译
编译动态库(给 Python 调用):
mkdir buildcd buildcmake .. -DBUILD_SHARED_LIBS=ON -DCMAKE_BUILD_TYPE=Releasemake -j$(nproc)cd ..编译静态库 + 示例程序(Server + Client):
mkdir buildcd buildcmake .. -DBUILD_SHARED_LIBS=OFF -DCMAKE_BUILD_TYPE=Releasemake -j$(nproc)cd ..CMake 会自动检测你的 CPU 架构,开启 AVX2/FMA 等加速指令。
7.3 运行 Python 动态库示例
python3 examples/sample_dylib.py models/all-MiniLM-L6-v2/ggml-model-f16.bin这个脚本会:
加载模型
读入 1738 条示例文本
把所有文本编码成向量
循环等待用户输入查询
对查询做语义搜索,返回 Top-3 最相似的文本
示例输出:
Starting with a test query "Should I get health insurance?"Closest texts:1. Can I sign up for Medicare Part B if I am working and have health insurance through an employer? (similarity score: 0.4790)2. Will my Medicare premiums be higher because of my higher income? (similarity score: 0.4633)3. Should I sign up for Medicare Part B if I have Veterans' Benefits? (similarity score: 0.4208)7.4 运行 TCP Server
./build/bin/server -m models/all-MiniLM-L6-v2/ggml-model-q4_0.bin --port 8085Server 启动后会监听 8085 端口,等待客户端发送文本进行编码。
然后用 Python 客户端连接:
python3 examples/sample_client.py 8085效果和动态库示例一样,只是推理在 Server 端完成,客户端只负责发文本和收结果。适合把编码服务部署成独立进程,多个客户端共享。
7.5 自己写代码调用
C++ 调用:
...int main() { // 加载模型 auto* ctx = bert_load_from_file("models/all-MiniLM-L6-v2/ggml-model-q4_0.bin"); // 准备输出缓冲区 int n_embd = bert_n_embd(ctx); // 384 float embeddings[384]; // 编码一句话 bert_encode(ctx, 4, "Hello world", embeddings); // embeddings 现在包含 384 个浮点数 // 可以拿去算相似度、存数据库... bert_free(ctx); return 0;}Python 调用(通过 ctypes):
import ctypesimport numpy as nplib = ctypes.CDLL("./build/libbert.so")# 加载模型ctx = lib.bert_load_from_file(b"models/all-MiniLM-L6-v2/ggml-model-q4_0.bin")# 编码n_embd = lib.bert_n_embd(ctx)embeddings = np.zeros(n_embd, dtype=np.float32)lib.bert_encode(ctx, 4, b"Hello world", embeddings.ctypes.data)# embeddings 就是句子向量print(embeddings[:5])lib.bert_free(ctx)If you need the complete source code, please add the WeChat number (c17865354792)
八、总结
这个项目虽然代码量不大,但把"在端侧跑 Transformer 编码器"这件事做得非常扎实:
基于 ggml:复用成熟的张量计算和量化基础设施,不重复造轮子
4-bit 量化:14MB 的模型体积,准确率几乎不掉,适合部署到资源受限环境
**纯 C++**:零 Python 依赖,跨平台,x86/ARM 都能跑
C API + Python 绑定:既可以直接嵌入 C++ 项目,也可以被 Python 脚本调用
语义搜索示例:不只是跑通模型,还给了完整的应用场景(Server/Client 架构)
如果你正在做一个需要语义搜索、文本匹配、或者 RAG 检索的项目,又不想拖着一个几百 MB 的 PyTorch 环境,这就是一个很好的参考方案。14MB 的模型,CPU 上就能跑,准确率还够用——在很多场景下,这就足够了。
Welcome to follow WeChat official account【程序猿编码】
---
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!