FAQ 数量中等、设备极弱、对延迟极敏感 | 无论底层是 Word2Vec 还是 BERT,外部(包括 W2VEngine 和 JNI)只依赖 TextEmbedder 这个统一接口: TextEmbedder.cpp 中的核心逻辑是:根据模型路径与指定枚举,选择对应引擎,并对外提供统一的 embed 接口。 可以看到: - TextEmbedder 对外隐藏了 Word2Vec 和 BERT 的具体实现;
- 上层只需要知道:“我传一个模型路径进来,之后就可以用 embed 得到向量”;
- 这为后续支持更多模型(如 MiniLM、bge、m3e 等)留下了很好的扩展空间。
四、Word2Vec 引擎实现:从词向量到句向量4.1 模型格式与加载加载逻辑核心片段如下: 由于 Word2Vec 模型通常是“词级别”的,句子向量需要先做分词。这里采用的是一个兼顾简单与效果的策略: - 对英文数字部分用类似“token until non-alnum”的方式切分;
- 对中文使用 基于词表的最长匹配 策略:
- 从当前位置开始,以 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)结构:  nerror="javas cript: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 打交道的是 。它主要负责: - 初始化 onNX Runtime(Env + Session + MemoryInfo);
- 加载 onNX 模型,记录输入/输出节点信息;
- 调用 BertTokenizer 做分词与 ID 转换;
- 根据输入节点名称构建 input_ids / attention_mask / token_type_ids;
- 执行推理,取 [CLS] 位置向量;
- 对向量做 L2 归一化,并输出。
5.4.1 初始化:加载模型与节点信息完整的 embed 函数实现如下(为便于理解,含详细注释): 这里有几个工程经验值得记录: - 输入节点名鲁棒匹配:通过字符串 find("mask") / find("type") 等方式,提高适配不同导出 onNX 模型时的稳定性;
- [CLS] Pooling + L2Norm:仅保留 [CLS] 向量并做 L2 归一化,不再做向量中心化(Centering),这与业界主流 Sentence Embedding 实践一致;
- 详细日志:包括耗时、向量维度、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 画出与前述各类的关系:  nerror="javas cript: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++ 时序图 nerror="javas cript: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 配置等。
典型的接入流程可以概括为: - 将 libw2v_jni.so 拷贝到 app/src/main/jniLibs/arm64-v8a/;
- 将 W2VNative.java 放到对应 package 下;
- 将模型文件(model.onnx + vocab.txt 或 Word2Vec .bin)与 qa_list.csv 放入 assets/;
- 启动 App 时,将 assets 中的模型与数据复制到可读写的私有目录(如 getFilesDir());
- 调用 W2VNative.initEngine 或 initBertEngine 初始化引擎;
- 调用 W2VNative.loadQAFromFile 加载 QA 数据;
- 在用户输入问题时,调用 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
|