从零实现一个生产级 RAG 语义搜索系统:C++ + ONNX + FAISS 实战
1. 引言既然是“从零实现”,本文暂不深入探讨繁复的理论背景,而是先聚焦一个核心问题:语义化搜索中的“语义化”到底是什么意思?
传统的关键词搜索依赖字面匹配——比如搜索“如何序列化 JSON”,系统只会返回包含这些精确关键词的文档。这种方式简单直接,但十分“呆板”:它无法理解“JSON 序列化方法”或“把对象转成 JSON 字符串”其实表达了相同的意图。
而语义化搜索的突破在于:它不再比较文字,而是比较含义。其核心思想是将文本(无论是查询还是文档)通过嵌入模型(Embedding Model)转换为高维向量——这个过程称为“向量化”或“嵌入”(Embedding)。在向量空间中,语义相近的句子会被映射到彼此靠近的位置。于是,搜索就变成了一个向量相似度计算问题:找出与查询向量最接近的文档向量。
表面上看,这是数学(向量、内积、距离);本质上,这是智能——因为模型通过海量数据学习到了人类语言的语义结构。而我们要做的,就是用编程语言把这套“用数学理解语言”的能力,变成一个高效、可靠、可部署的生产级系统。
2. 目标
本文的初衷,是为我的个人博客网站 Charlee44 的技术驿站 实现一套真正可用的站内搜索功能。由于博客部署在资源极其有限的云服务器上(比如1核 CPU、1GB 内存),我对系统提出了两个“极致”要求:极致的性能 与 极致的资源效率。
https://img2024.cnblogs.com/blog/1000410/202602/1000410-20260212213951954-1452703526.png
毕竟,每一分算力都来自自己的钱包——这促使我放弃了主流但相对“重”的 Python 生态(如 Sentence Transformers + ChromaDB 的常规组合),转而选择 C++ 作为实现语言。C++ 不仅能提供更低的内存开销和更高的推理吞吐,也让我有机会深入理解 embedding 推理、向量索引、文本分块等模块的底层机制。
当然,需要说明的是:如果是在企业环境中开发,或对迭代速度要求更高,Python 生态仍是更高效、更成熟的选择。而本项目更多是一次“用工程约束驱动深度学习”的实践——在有限资源下,亲手构建一个轻量、可控、可落地的语义搜索系统。
3. 嵌入模型
既然语义化搜索的核心在于将文本转化为富含语义的向量,那么实现这一能力的关键,就在于选择一个合适的嵌入模型。这类模型的作用,是将任意长度的文本映射为固定维度的稠密向量,使得语义相似的文本在向量空间中距离更近。笔者这里使用的是 bge-small-zh-v1.5 。bge-small-zh-v1.5 是由北京智源人工智能研究院(BAAI)推出的 BGE(BAAI General Embedding)系列中的轻量级中文模型。它基于 Transformer 架构,在大规模中英文语料上进行对比学习训练,专为检索任务优化。尽管它并非该系列中最新或最大的版本(如 bge-large),但其在推理速度、内存占用与语义质量之间取得了极佳的平衡,尤其适合资源受限或对延迟敏感的生产环境。
bge-small-zh-v1.5 可以从 Hugging Face 上下载,下载后的数据文件组织结构如下:
bge-small-zh-v1.5/├── config.json # 模型架构配置(层数、隐藏层维度等)├── pytorch_model.bin # PyTorch 格式的模型权重(核心文件)├── tokenizer.json # 分词器定义(WordPiece 词汇表 + 算法参数)├── tokenizer_config.json # 分词器配置(如是否小写化、特殊 token 等)├── vocab.txt # WordPiece 词汇表(纯文本,每行一个 token)├── special_tokens_map.json # 特殊 token 映射(, , 等)├── modules.json # (可选)模型模块信息├── sentence_bert_config.json # (可选)Sentence-BERT 相关配置├── README.md # 模型卡片(含使用说明、引用信息等)└── .gitattributes # Git LFS 配置(用于大文件管理)不过,bge-small-zh-v1.5 原生基于 PyTorch(属于 Python 生态),直接在 C++ 环境中调用并不方便,也难以满足我们对性能和资源占用的要求。为了在 C++ 中高效运行该模型,最佳实践是将其导出为 ONNX 格式。
ONNX(Open Neural Network Exchange)是一种开放的神经网络模型交换格式,由微软、Facebook 等公司共同推动,旨在实现“一次训练,多端部署”。它不依赖特定框架(如 PyTorch 或 TensorFlow),而是将模型结构与权重统一序列化为标准格式,从而可在不同硬件和语言环境中高效推理。
要在 C++ 中加载和运行 ONNX 模型,我们需要使用 ONNX Runtime——这是微软开源的高性能推理引擎,支持 CPU/GPU 加速、跨平台(Windows/Linux/macOS)以及 C/C++/Python 等多种语言绑定。通过 ONNX Runtime,我们可以在无 Python 依赖的情况下,以极低的开销完成 embedding 推理,完美契合本项目的轻量级目标。
因此,关键的第一步是需要将 bge-small-zh-v1.5 转换成 ONNX 格式的嵌入模型。嵌入模型的格式转换是预处理步骤,可以通过 Python 脚本来实现:
# export_onnx.pyfrom transformers import AutoTokenizer, AutoModelfrom optimum.onnxruntime import ORTModelForFeatureExtractionfrom pathlib import Path# 改为你的本地模型路径(注意:使用原始字符串或正斜杠)model_path = "C:/Github/search/bge-small-zh-v1.5" onnx_path = "./bge-small-zh-onnx"# 导出 ONNX(从本地模型加载)ort_model = ORTModelForFeatureExtraction.from_pretrained( model_path, # ← 关键:使用本地路径 export=True, provider="CPUExecutionProvider")tokenizer = AutoTokenizer.from_pretrained(model_path)# ← 同样用本地路径# 保存 ONNX 模型和 tokenizerort_model.save_pretrained(onnx_path)tokenizer.save_pretrained(onnx_path)print(f"✅ ONNX 模型已导出到 {onnx_path}")转换后的 ONNX 格式嵌入模型 bge-small-zh-onnx 的数据文件组织结构如下:
bge-small-zh-onnx/├── model.onnx # ✅ 核心:ONNX 格式的模型计算图(含权重)├── config.json # 模型架构配置(与原版一致)├── tokenizer.json # 分词器定义(WordPiece + 预处理规则)├── tokenizer_config.json # 分词器行为配置(如 do_lower_case)├── vocab.txt # WordPiece 词汇表(纯文本备份)└── special_tokens_map.json # 特殊 token 映射(, , 等)4. 分词器
在使用 ONNX Runtime 加载嵌入模型对文本进行向量化之前,我们还需要了解一个关键组件:分词器(Tokenizer)。
所谓“字、词、句、段、篇”,语言的理解是分层的。但对于现代深度学习模型(尤其是基于 Transformer 的架构)而言,它们并不直接处理原始字符,而是将文本切分为更小的语义单元——“词元”(token)。这个过程就是 Tokenization(词元化),由分词器完成。
分词器的重要性体现在以下两点:
[*]模型输入的前提:嵌入模型只接受 token ID 序列作为输入,而非原始字符串。没有正确的分词,就无法生成有效的 embedding。
[*]影响语义精度:不同的分词策略会导致不同的 token 序列,进而影响向量表达的质量。
4.1 自定义分词器
其实分词器的实现也并不神秘,我们完全可以实现一个简单版本的:
#include #include #include #include #ifdef _WIN32#include #endifusing namespace std;std::unordered_map LoadVocab(const std::string& path) {std::unordered_map vocab;std::ifstream file(path);std::string token;int id = 0;while (std::getline(file, token)) { vocab = id++;}return vocab;}// 注意:需处理 UTF-8 多字节字符!std::vector SplitTextChars(const std::string& text) {std::vector chars;for (size_t i = 0; i < text.size();) { if ((text & 0x80) == 0) {// ASCII chars.push_back(std::string(1, text)); } else {// UTF-8 多字节 int bytes = 0; if ((text & 0xE0) == 0xC0) bytes = 2; else if ((text & 0xF0) == 0xE0) bytes = 3; else if ((text & 0xF8) == 0xF0) bytes = 4; else throw std::runtime_error("Invalid UTF-8"); chars.push_back(text.substr(i, bytes)); i += bytes; }}return chars;}//输入字符串,输出 input_ids 和 attention_maskstd::pair Tokenize( const std::string& text, const std::unordered_map& vocab, int maxLength = 512) {if (maxLength < 2) { throw std::invalid_argument("maxLength must be at least 2");}// 预取特殊 token IDconst int clsId = vocab.at("");const int sepId = vocab.at("");const int padId = vocab.at("");const int unkId = vocab.at("");// 初始化向量(自动 padding)std::vector inputIds(maxLength, padId);std::vector attentionMask(maxLength, 0);//开头加 ""size_t currentIndex = 0;inputIds = clsId;attentionMask = 1;currentIndex++;//中间每个字作为一个 token(查 vocab 得 ID)auto chars = SplitTextChars(text);for (const auto& ch : chars) { if (currentIndex >= maxLength - 1ULL) { break; } const auto& it = vocab.find(ch); inputIds = (it == vocab.end() ? unkId : it->second); attentionMask = 1; currentIndex++;}//结尾加 ""inputIds = sepId;attentionMask = 1;return {inputIds, attentionMask};}void TestTokenize() {string vocabPath = "C:/Github/search/bge-small-zh-onnx/vocab.txt";string text = "git撤回提交";auto vocab = LoadVocab(vocabPath);const auto& = Tokenize(text, vocab);// 打印前10个 token ID 验证coutu64 { if handle.is_null() || text.is_null() { return 0; } let handle_ref = unsafe { &*(handle as *mut TokenizerHandle) }; let text_cstr = unsafe { CStr::from_ptr(text) }; let text_str = match text_cstr.to_str() { Ok(s) => s, Err(_) => return 0, }; match handle_ref.raw_tokenizer.encode(text_str, true) { Ok(encoding) => encoding.len() as u64, Err(_) => 0, }}// === 5. 销毁 tokenizer ===#pub extern "C" fn tokenizer_destroy(handle: *mut std::ffi::c_void) { if !handle.is_null() { unsafe { let _ = Box::from_raw(handle as *mut TokenizerHandle); // Drop 自动调用 } }}// === 6. 执行分词 ===#pub extern "C" fn tokenizer_encode( handle: *mut std::ffi::c_void, text: *const c_char,) -> TokenizerResult { let default_result = TokenizerResult { input_ids: std::ptr::null_mut(), attention_mask: std::ptr::null_mut(), token_type_ids: std::ptr::null_mut(), length: 0, }; if handle.is_null() || text.is_null() { return default_result; } let handle_ref = unsafe { &*(handle as *mut TokenizerHandle) }; let text_cstr = unsafe { CStr::from_ptr(text) }; let text_str = match text_cstr.to_str() { Ok(s) => s, Err(_) => return default_result, }; let encoding = match handle_ref.tokenizer.encode(text_str, true) { Ok(e) => e, Err(_) => return default_result, }; let input_ids: Vec = encoding.get_ids().iter().map(|&x| x as i64).collect(); let attention_mask: Vec = encoding .get_attention_mask() .iter() .map(|&x| x as i64) .collect(); let token_type_ids: Vec = encoding.get_type_ids().iter().map(|&x| x as i64).collect(); // BGE 不需要,但 C++ 代码传了 // let token_type_ids: Vec = vec!; let len = input_ids.len(); // 应该是 512,但更通用 TokenizerResult { input_ids: vec_to_c_ptr(input_ids), attention_mask: vec_to_c_ptr(attention_mask), token_type_ids: vec_to_c_ptr(token_type_ids), length: len as u64, }}// === 7. 释放结果内存 ===#pub extern "C" fn tokenizer_result_free(result: TokenizerResult) { if !result.input_ids.is_null() { unsafe { let _ = Vec::from_raw_parts( result.input_ids, result.length as usize, result.length as usize, ); } } if !result.attention_mask.is_null() { unsafe { let _ = Vec::from_raw_parts( result.attention_mask, result.length as usize, result.length as usize, ); } } if !result.token_type_ids.is_null() { unsafe { let _ = Vec::from_raw_parts( result.token_type_ids, result.length as usize, result.length as usize, ); } }}对应的 C 接口如下:
// tokenizer_result.h#pragma once#include // 定义结构体struct TokenizerResult { int64_t* input_ids; int64_t* attention_mask; int64_t* token_type_ids; uint64_t length;};typedef struct TokenizerResult TokenizerResult;// hf_tokenizer_ffi#pragma once#include "tokenizer_result.h"#ifdef __cplusplusextern "C" {#endifvoid* tokenizer_create(const char* tokenizer_json_path);void tokenizer_destroy(void* handle);TokenizerResult tokenizer_encode(void* handle, const char* text);uint64_t tokenizer_count(void* handle, const char* text);void tokenizer_result_free(TokenizerResult result);#ifdef __cplusplus}#endif我们在 C++ 程序中调用这个 C 绑定的接口:
#include #include #include #ifdef _WIN32#include #endifusing namespace std;void TestTokenize() {void* handle = tokenizer_create("C:/Github/search/bge-small-zh-onnx/tokenizer.json");if (!handle) { std::cerr 收藏一下 不知道什么时候能用到 用心讨论,共获提升! 这个好,看起来很实用 感谢分享,下载保存了,貌似很强大 感谢发布原创作品,程序园因你更精彩 感谢发布原创作品,程序园因你更精彩 热心回复! 东西不错很实用谢谢分享 鼓励转贴优秀软件安全工具和文档!
页:
[1]