『7x24小时有问必答』

一、这玩意儿到底是干嘛的?

说白了,这是一个用纯 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每一层里面有两大部分:
自注意力(Self-Attention)
输入分别过 Q、K、V 三个线性变换
算注意力分数:softmax(Q × Kᵀ / √d)
用注意力分数加权 V,得到注意力输出
过一个线性投影,加残差连接,LayerNorm
前馈网络(Feed-Forward)
先扩维(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.txt

python3 models/download-ggml.py download all-MiniLM-L6-v2 q4_0

这会下载 4-bit 量化版的 all-MiniLM-L6-v2 模型,只有 14MB 左右。
如果你想自己转换模型(比如用别的 HuggingFace 模型):
cd  models

git  clone  https://huggingface.co/sentence-transformers/multi-qa-MiniLM-L6-cos-v1

sh run_conversions.sh multi-qa-MiniLM-L6-cos-v1

这会生成 F32、F16、Q4_0、Q4_1 四个版本。

7.2 编译

编译动态库(给 Python 调用):
mkdir build

cd  build

cmake .. -DBUILD_SHARED_LIBS=ON -DCMAKE_BUILD_TYPE=Release

make -j$(nproc)

cd  ..

编译静态库 + 示例程序(Server + Client):
mkdir build

cd  build

cmake .. -DBUILD_SHARED_LIBS=OFF -DCMAKE_BUILD_TYPE=Release

make -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 8085

Server 启动后会监听 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  ctypes

import  numpy  as  np

lib = 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【程序猿编码】

---

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

上一主题上一主题         下一主题下一主题
QQ手机版小黑屋粤ICP备17165530号

关于我们·投诉举报· 用户帮助· 联系我们 · 本站服务 · 版权声明· 隐私政策 · 投搞指南

法律保护:PLC技术网,plcjs.com,plcjs.net等字样
Copyright 2010-2030. All rights reserved. 


微信公众号二维码 抖音二维码 百家号二维码 今日头条二维码哔哩哔哩二维码