软件
embed函数(FastW2V-JNI:从模型到移动端语义检索的完整落地实践)

本专栏致力于探索和讨论当今最前沿的技术趋势和应用领域,包括但不限于ChatGPT和Stable Diffusion等。我们将深入研究大型模型的开发和应用,以及与之相关的人工智能生成内容(AIGC)技术。通过深入的技术解析和实践经验分享,旨在帮助读者更好地理解和应用这些领域的最新进展

本文基于当前 FastW2V-JNI 仓库源码撰写,目标是从「为什么要做」到「如何实现」,系统性拆解一个 支持 Word2Vec + BERT (onNX Runtime) 的中文语义检索引擎,并完整跑在 Android 端离线环境中。文章内容将围绕:同时结合:方便你既能「看懂」,也能「照着改造自己项目」。

一、项目背景:为什么是 FastW2V-JNI?

传统方案要么是:

  • 后端部署大模型服务,App 通过 HTTP 调用:
    好处:模型能力强,易更新;缺点:依赖网络、延迟高、隐私风险、维护成本高。
  • App 内部使用“关键词匹配 + if-else”:
    好处:简单直接;缺点:可维护性差,泛化能力极弱,稍微变换问法就匹配不到。

一句话概括:

先看仓库顶层的目录结构(简化,以核心模块为主):

从「层次」角度划分,整个项目可以分为三层:

  1. 模型与向量层(Embedding Layer)
  • W2VEmbedder:负责 Word2Vec 模型加载、分词、句向量生成;BertEmbedder + BertTokenizer:负责 BERT (onNX Runtime) 推理和 WordPiece 分词;TextEmbedder:在上层统一包装,外部只关心“给文本 -> 出向量”。
  1. 检索层(Search Layer)
  • SimilaritySearch:负责存储 QA 对、计算余弦相似度、返回匹配结果。
  1. 桥接与应用层(Bridge & App Layer)
  • W2VEngine:把嵌入层与检索层组合成一个“引擎实例”;JNI 层(com_example_w2v_W2VNative.cpp + W2VNative.java):把 C++ 能力暴露给 Java/Android;Android Demo App:展示如何在真实 App 中使用引擎。

2.2 总体架构 Mermaid 图

可以看到,Java 侧只需要和 W2VNative 打交道,其余所有细节(模型类型选择、onNX Runtime 推理、Word2Vec 加载、相似度计算等)都隐藏在 C++ 内部。

三、双引擎设计:Word2Vec vs BERT

下面用一个表来概览两者差异:

Word2Vec 引擎

模型类型

Transformer 句向量模型(Sentence Embedding)

腾讯 AI Lab 中文词向量(轻量版)

向量维度(示例)

384 / 768 等(具体随模型配置)

无(词级),句子向量靠平均池化

推理依赖

onNX Runtime (C++ / Android)

非常快(子毫秒级)

精度 / 语义能力

高(尤其对语义相近但词面不同的问句更敏感)

FAQ 数量中等、设备极弱、对延迟极敏感

无论底层是 Word2Vec 还是 BERT,外部(包括 W2VEngine 和 JNI)只依赖 TextEmbedder 这个统一接口:

TextEmbedder.cpp 中的核心逻辑是:根据模型路径与指定枚举,选择对应引擎,并对外提供统一的 embed 接口。

可以看到:

  • TextEmbedder 对外隐藏了 Word2Vec 和 BERT 的具体实现;
  • 上层只需要知道:“我传一个模型路径进来,之后就可以用 embed 得到向量”;
  • 这为后续支持更多模型(如 MiniLM、bge、m3e 等)留下了很好的扩展空间。

四、Word2Vec 引擎实现:从词向量到句向量

4.1 模型格式与加载

加载逻辑核心片段如下:

由于 Word2Vec 模型通常是“词级别”的,句子向量需要先做分词。这里采用的是一个兼顾简单与效果的策略:

  1. 对英文数字部分用类似“token until non-alnum”的方式切分;
  2. 对中文使用 基于词表的最长匹配 策略:
  • 从当前位置开始,以 max_word_len_ 为上界向后尝试;如果某个子串在 word_vectors_ 中存在,就作为一个词切分;找不到则回退为单个 UTF-8 字符。

// src/W2VEmbedder.cpp(中文分词核心逻辑,附注释)
std::vector<std::string> W2VEmbedder::tokenize_chinese(const std::string& text) {
std::vector<std::string> tokens;
size_t i = 0;
while (i < text.length()) {
// 1)ASCII 分支:英文 / 数字 / 下划线
if ((text[i] & 0x80) == 0) {
if (isspace(text[i])) {
// 跳过空白字符
i++;
continue;
}
std::string token;
// 连续的 [a-zA-Z0-9_] 视为一个 token
while (i < text.length() &&
(text[i] & 0x80) == 0 &&
(isalnum(text[i]) || text[i] == '_')) {
token += text[i++];
}
if (!token.empty()) tokens.push_back(token);
else if (i < text.length()) i++;
continue;
}
// 2)非 ASCII 分支:中文 / 其他 UTF-8 字符
bool matched = false;
size_t remaining_len = text.length() - i;
// 匹配长度不能超过 max_word_len_ 与剩余长度
size_t match_limit = std::min((size_t)max_word_len_, remaining_len);
// 从最长可能长度开始做“最大匹配”
for (size_t len = match_limit; len > 0; --len) {
std::string sub = text.substr(i, len);
// 如果词表中存在这个子串,就认为找到了一个词
if (word_vectors_.count(sub)) {
tokens.push_back(sub);
i += len;
matched = true;
break;
}
}
if (!matched) {
// 如果没有匹配到任何词,则回退为“单个 UTF-8 字符”
size_t char_len = 1;
unsigned char c = (unsigned char)text[i];
if (c >= 0xF0) char_len = 4;
else if (c >= 0xE0) char_len = 3;
else if (c >= 0xC0) char_len = 2;
if (i + char_len > text.length()) char_len = text.length() - i;
tokens.push_back(text.substr(i, char_len));
i += char_len;
}
}
return tokens;
}

4.3 句向量生成:平均池化 + L2 归一化

对应实现如下:

到这里,Word2Vec 引擎的完整路径就是:

这也是大量传统检索系统使用的标准套路。

五、BERT 引擎实现:CoROM-Tiny + onNX Runtime

在仓库中,CoROM 模型目录下的 resources/dual-encoder.png 描绘了典型双塔(Dual-Encoder)结构:

FastW2V-JNI:从模型到移动端语义检索的完整落地实践nerror="javascript:errorimg.call(this);">

在当前 FastW2V-JNI 实现中,我们先实现了“单塔使用”:

  • 对 QA 库中的 Question 做一次性编码,得到一组 Question 向量;
  • 对用户 Query 同样编码,得到 Query 向量;
  • 使用余弦相似度在 Question 向量集合中做最近邻检索。

5.2 模型导出脚本:convert_model.py

关键代码如下:

BertTokenizer 负责将输入的中文(及混合文本)转换为 BERT 所需的 token 序列和对应的 ID。

拆几个关键函数来看。

5.3.1 词表加载与特殊 token

// 预处理 + 初步 token 切分
std::vector<std::string> BertTokenizer::split_text(const std::string& text) {
std::vector<std::string> tokens;
std::string clean_text;

// 1)预处理:转小写、过滤控制字符
for (size_t k = 0; k < text.length(); ++k) {
unsigned char c = (unsigned char)text[k];
if (c == 0 || is_control(c)) {
// 过滤掉 \0 和控制字符(除 \t \n \r)
continue;
}
if (c >= 'A' && c <= 'Z') {
// 大写转小写
clean_text += (char)(c + ('a' - 'A'));
} else {
clean_text += text[k];
}
}
size_t i = 0;
while (i < clean_text.length()) {
unsigned char c = (unsigned char)clean_text[i];
if (isspace(c)) {
// 直接跳过空白
i++;
continue;
}
// 2)ASCII 分支:直接交给英文数字 token 逻辑
if (c < 128) {
std::string s;
// 将连续的非空白、非标点、非中文字符拼成一个 token
while (i < clean_text.length()) {
unsigned char cur = (unsigned char)clean_text[i];
if (isspace(cur) || is_punctuation(cur) || cur >= 128) {
break;
}
s += clean_text[i++];
}
if (!s.empty()) tokens.push_back(s);
} else {
// 3)中文分支:以 UTF-8 字符为单位
size_t char_len = 1;
if (c >= 0xF0) char_len = 4;
else if (c >= 0xE0) char_len = 3;
else if (c >= 0xC0) char_len = 2;

if (i + char_len > clean_text.length()) char_len = clean_text.length() - i;
std::string utf8_char = clean_text.substr(i, char_len);

tokens.push_back(utf8_char);
i += char_len;
}
}
return tokens;
}

5.3.3 WordPiece 切分与 tokenize

真正和 onNX Runtime 打交道的是 。它主要负责:

  1. 初始化 onNX Runtime(Env + Session + MemoryInfo);
  2. 加载 onNX 模型,记录输入/输出节点信息;
  3. 调用 BertTokenizer 做分词与 ID 转换;
  4. 根据输入节点名称构建 input_ids / attention_mask / token_type_ids;
  5. 执行推理,取 [CLS] 位置向量;
  6. 对向量做 L2 归一化,并输出。

5.4.1 初始化:加载模型与节点信息

完整的 embed 函数实现如下(为便于理解,含详细注释):

这里有几个工程经验值得记录:

  1. 输入节点名鲁棒匹配:通过字符串 find("mask") / find("type") 等方式,提高适配不同导出 onNX 模型时的稳定性;
  2. [CLS] Pooling + L2Norm:仅保留 [CLS] 向量并做 L2 归一化,不再做向量中心化(Centering),这与业界主流 Sentence Embedding 实践一致;
  3. 详细日志:包括耗时、向量维度、L2 norm、均值、前 5 维等,方便调试与监控。

5.5 BERT 推理链路时序图

无论使用的是 Word2Vec 还是 BERT,最终都会归结为“在一个向量集合中做最近邻搜索”。在 FastW2V-JNI 中,SimilaritySearch 负责这一层逻辑。

6.1 设计目标

  • 将 QA 库中的每条 Question 编成向量,并与对应 Answer 一起存储;
  • 提供单条查询与批量查询接口;
  • 使用 标准余弦相似度 作为相似度度量;
  • 针对当前工程目标(QA 数量中等),使用线性扫描即可满足性能需求;
  • 保留扩展点(optimize()),未来可以接入 ANN(近似最近邻)索引。

6.2 数据结构与余弦相似度实现

class SimilaritySearch::Impl {
private:
struct QAEntry {
std::string question; // 原始问题文本
std::string answer; // 对应答案
std::vector<float> embedding; // 问题向量

QAEntry(const std::string& q, const std::string& a, const std::vector<float>& e)
: question(q), answer(a), embedding(e) {}
};

std::vector<QAEntry> qa_entries_; // 所有 QA 条目
int embedding_dim_; // 向量维度
bool initialized_; // 是否已初始化

// 计算余弦相似度
float cosine_similarity(const std::vector<float>& vec1, const std::vector<float>& vec2) {
if (vec1.size() != vec2.size() || vec1.empty()) {
return 0.0f;
}

float dot_product = 0.0f;
float norm1 = 0.0f;
float norm2 = 0.0f;

for (size_t i = 0; i < vec1.size(); i++) {
dot_product += vec1[i] * vec2[i];
norm1 += vec1[i] * vec1[i];
norm2 += vec2[i] * vec2[i];
}

if (norm1 < 1e-9f || norm2 < 1e-9f) {
return 0.0f;
}

return dot_product / (std::sqrt(norm1) * std::sqrt(norm2));
}

6.3 单条查询与线性搜索

// 搜索单个查询
SearchResult search_single(const std::vector<float>& query_embedding, int top_k) {
if (qa_entries_.empty() || !initialized_) {
return SearchResult("", "", 0.0f);
}

// 线性搜索:对每条 QA 向量计算一次余弦相似度
float best_similarity = -1.0f;
size_t best_index = 0;

for (size_t i = 0; i < qa_entries_.size(); i++) {
float similarity = cosine_similarity(query_embedding, qa_entries_[i].embedding);
if (similarity > best_similarity) {
best_similarity = similarity;
best_index = i;
}
}

if (best_similarity < -1.0f) {
return SearchResult("", "", 0.0f);
}

// 直接使用原始余弦相似度 [-1, 1],并做裁剪防止浮点误差
float final_score = std::max(-1.0f, std::min(1.0f, best_similarity));

return SearchResult(qa_entries_[best_index].question,
qa_entries_[best_index].answer,
final_score);
}

bool add_qa_batch(const std::vector<std::string>& questions,
const std::vector<std::string>& answers,
const std::vector<std::vector<float> >& embeddings);
SearchResult search(const std::vector<float>& query_embedding, int top_k);
std::vector<SearchResult> search_batch(const std::vector<std::vector<float> >& query_embeddings, int top_k);

Query 文本 → 嵌入层(Word2Vec/BERT) → Query 向量
→ SimilaritySearch.search → 返回最佳匹配 QA。

为了让 JNI 层更简单、易维护,项目中引入了一个封装类 ,它负责将:

  • TextEmbedder(Word2Vec / BERT);
  • SimilaritySearch(向量检索);

核心代码如下:

用 Mermaid 画出与前述各类的关系:

FastW2V-JNI:从模型到移动端语义检索的完整落地实践nerror="javascript:errorimg.call(this);">

八、JNI 层实现:从 Java 调用 C++

// 全局引擎映射:jlong -> shared_ptr<W2VEngine>
std::unordered_map<jlong, std::shared_ptr<W2VEngine> > engine_map;
jlong next_engine_id = 1;
// 初始化 Word2Vec 引擎
jlong native_initEngine(JNIEnv *env, jclass clazz, jstring modelPath) {
std::string model_path = jstring_to_string(env, modelPath);
std::shared_ptr<W2VEngine> engine = std::make_shared<W2VEngine>();
if (!engine->initialize(model_path)) {
return 0; // 返回 0 表示失败
}
jlong engine_id = next_engine_id++;
engine_map[engine_id] = engine;
return engine_id;
}
// 初始化 BERT 引擎
jlong native_initBertEngine(JNIEnv *env, jclass clazz, jstring modelPath, jstring vocabPath) {
std::string model_path = jstring_to_string(env, modelPath);
std::string vocab_path = jstring_to_string(env, vocabPath);
std::shared_ptr<W2VEngine> engine = std::make_shared<W2VEngine>();
if (!engine->initialize_bert(model_path, vocab_path)) {
return 0;
}
jlong engine_id = next_engine_id++;
engine_map[engine_id] = engine;
return engine_id;
}
// 释放引擎
void native_releaseEngine(JNIEnv *env, jclass clazz, jlong enginePtr) {
auto it = engine_map.find(enginePtr);
if (it != engine_map.end()) {
it->second->release(); // 释放 C++ 内部资源
engine_map.erase(it); // 删除映射记录
}
}

8.2 字符串与数组转换

// 缓存 SearchResult 类的 Class 和构造方法 ID
static jclass gResultClass = nullptr;
static jmethodID gResultInit = nullptr;
// 单条查询
jobject native_search(JNIEnv *env, jclass clazz, jlong enginePtr, jstring query) {
auto it = engine_map.find(enginePtr);
if (it == engine_map.end()) return nullptr;
float similarity = 0.0f;
auto result = it->second->search(jstring_to_string(env, query), &similarity);

if (!gResultClass || !gResultInit) return nullptr;

// 将 C++ 字符串转换为 jstring
jstring jq = string_to_jstring(env, result.first);
jstring ja = string_to_jstring(env, result.second);
// 调用 Java SearchResult(String q, String a, float score) 构造函数
jobject jobj = env->NewObject(gResultClass, gResultInit, jq, ja, similarity);
env->DeleteLocalRef(jq);
env->DeleteLocalRef(ja);
return jobj;
}
// 批量查询
jobjectArray native_searchBatch(JNIEnv *env, jclass clazz, jlong enginePtr, jobjectArray queries) {
auto it = engine_map.find(enginePtr);
if (it == engine_map.end()) return nullptr;
std::vector<std::string> q_vec = jobjectarray_to_stringvector(env, queries);
std::vector<float> sims;
auto results = it->second->search_batch(q_vec, &sims);

if (!gResultClass || !gResultInit) return nullptr;
// 创建一个 SearchResult[ ] 数组
jobjectArray jarray = env->NewObjectArray(results.size(), gResultClass, nullptr);

for (size_t i = 0; i < results.size(); i++) {
jstring jq = string_to_jstring(env, results[i].first);
jstring ja = string_to_jstring(env, results[i].second);
jobject jobj = env->NewObject(gResultClass, gResultInit, jq, ja, sims[i]);
env->SetObjectArrayElement(jarray, i, jobj);
env->DeleteLocalRef(jq);
env->DeleteLocalRef(ja);
env->DeleteLocalRef(jobj);
}
return jarray;
}

8.4 Java JNI C++ 时序图

FastW2V-JNI:从模型到移动端语义检索的完整落地实践nerror="javascript:errorimg.call(this);">

九、Android 集成与 Demo 工程

两者结构类似,都包含:

  • app/src/main/java/com/example/w2v/:MainActivity.java、AndroidJavaTest.java、W2VNative.java;
  • app/src/main/assets/:qa_list.csv 等资源;
  • 以及构建脚本、Gradle 配置等。

典型的接入流程可以概括为:

  1. 将 libw2v_jni.so 拷贝到 app/src/main/jniLibs/arm64-v8a/;
  2. 将 W2VNative.java 放到对应 package 下;
  3. 将模型文件(model.onnx + vocab.txt 或 Word2Vec .bin)与 qa_list.csv 放入 assets/;
  4. 启动 App 时,将 assets 中的模型与数据复制到可读写的私有目录(如 getFilesDir());
  5. 调用 W2VNative.initEngine 或 initBertEngine 初始化引擎;
  6. 调用 W2VNative.loadQAFromFile 加载 QA 数据;
  7. 在用户输入问题时,调用 W2VNative.search 获取匹配结果。

// 假设已经实现 copyAssetToFile,将 assets 中的文件复制到 /data/data/.../files 目录
String onnxPath = copyAssetToFile(context, "model.onnx");
String vocabPath = copyAssetToFile(context, "vocab.txt");
String qaPath = copyAssetToFile(context, "qa_list.csv");
// 1)初始化 BERT 引擎
long enginePtr = W2VNative.initBertEngine(onnxPath, vocabPath);
// 2)加载 QA 数据
W2VNative.loadQAFromFile(enginePtr, qaPath);
// 3)执行语义搜索
W2VNative.SearchResult result = W2VNative.search(enginePtr, "系统如何重启");
if (result != null) {
Log.i("Demo", "匹配问题: " + result.question);
Log.i("Demo", "答案: " + result.answer);
Log.i("Demo", "相似度: " + result.score);
}

9.2 性能与内存指标(示例)

引擎类型

内存占用

Word2Vec

~120MB

BERT (CoROM-Tiny)

~30MB

注:BERT 引擎依赖 onnxruntime-android,而 Word2Vec 引擎不依赖额外推理框架。

十、端到端调用路径总览

这张图很好地体现了 FastW2V-JNI 的关键特性:

  • 对 Java / Android 层暴露的是一个非常简洁的接口;
  • 底层可以自由切换不同的模型(Word2Vec / BERT),且未来可以继续扩展;
  • 所有语义计算都在本地完成,网络只是“可选项”而非“必须项”。

十一、工程实践总结与扩展方向

如果基于 FastW2V-JNI 做进一步演进,可以考虑:

  • 替换 / 增加其他句向量模型(如 MiniLM、bge、m3e 等),在 TextEmbedder 中新增对应实现;
  • 在 SimilaritySearch 中接入 HNSW / Faiss 等近似最近邻索引,支持更大的 QA 库;
  • 加入简单的多轮对话上下文拼接逻辑,在问答时考虑“历史消息”;
  • 打造一个简单的“向量监控”工具,实时显示 embeddings 的分布、相似度 Top-K 等。

十二、结语

通过它可以学到:

  • 如何组织一个“模型 + 检索 + JNI + Android Demo”的完整工程;
  • 如何在 C++ 侧使用 onNX Runtime 做 BERT 推理;
  • 如何设计可扩展的嵌入接口与检索接口;
  • 如何在不依赖网络的前提下,在端侧实现较高质量的中文语义搜索。

希望这篇文档能帮助你不仅“看懂” FastW2V-JNI,也能以此为蓝本,搭建出适合自己业务的端侧语义检索引擎。

引用链接

[1] 《大模型AIGC》: https://blog.csdn.net/u011239443/category_12095381.html
[2] 《课程大纲》: https://blog.csdn.net/u011239443/article/details/132688694
[3] 《知识星球》: https://t.zsxq.com/17pscrZpc
[4] `src/W2VEmbedder.cpp`: https://github.com/xiaoyesoso/FastW2V-JNI/blob/main/src/W2VEmbedder.cpp
[5] `scripts/convert_model.py`: https://github.com/xiaoyesoso/FastW2V-JNI/blob/main/scripts/convert_model.py
[6] `src/BertEmbedder.cpp`: https://github.com/xiaoyesoso/FastW2V-JNI/blob/main/src/BertEmbedder.cpp
[7] `include/W2VEngine.h`: https://github.com/xiaoyesoso/FastW2V-JNI/blob/main/include/W2VEngine.h
[8] `jni/com_example_w2v_W2VNative.cpp`: https://github.com/xiaoyesoso/FastW2V-JNI/blob/main/jni/com_example_w2v_W2VNative.cpp


顶一下()     踩一下()
发表评论
0评