找回密码
 立即注册
首页 业界区 业界 从零实现一个生产级 RAG 语义搜索系统:C++ + ONNX + FA ...

从零实现一个生产级 RAG 语义搜索系统:C++ + ONNX + FAISS 实战

颜才 2026-2-12 22:55:33
1. 引言

既然是“从零实现”,本文暂不深入探讨繁复的理论背景,而是先聚焦一个核心问题:语义化搜索中的“语义化”到底是什么意思?
传统的关键词搜索依赖字面匹配——比如搜索“如何序列化 JSON”,系统只会返回包含这些精确关键词的文档。这种方式简单直接,但十分“呆板”:它无法理解“JSON 序列化方法”或“把对象转成 JSON 字符串”其实表达了相同的意图。
而语义化搜索的突破在于:它不再比较文字,而是比较含义。其核心思想是将文本(无论是查询还是文档)通过嵌入模型(Embedding Model)转换为高维向量——这个过程称为“向量化”或“嵌入”(Embedding)。在向量空间中,语义相近的句子会被映射到彼此靠近的位置。于是,搜索就变成了一个向量相似度计算问题:找出与查询向量最接近的文档向量。
表面上看,这是数学(向量、内积、距离);本质上,这是智能——因为模型通过海量数据学习到了人类语言的语义结构。而我们要做的,就是用编程语言把这套“用数学理解语言”的能力,变成一个高效、可靠、可部署的生产级系统。
2. 目标

本文的初衷,是为我的个人博客网站 Charlee44 的技术驿站 实现一套真正可用的站内搜索功能。由于博客部署在资源极其有限的云服务器上(比如1核 CPU、1GB 内存),我对系统提出了两个“极致”要求:极致的性能极致的资源效率

毕竟,每一分算力都来自自己的钱包——这促使我放弃了主流但相对“重”的 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 上下载,下载后的数据文件组织结构如下:
  1. 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 映射([CLS], [SEP], [PAD] 等) ├── 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 脚本来实现:
  1. # export_onnx.py from transformers import AutoTokenizer, AutoModel from optimum.onnxruntime import ORTModelForFeatureExtraction from 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 模型和 tokenizer ort_model.save_pretrained(onnx_path) tokenizer.save_pretrained(onnx_path)  print(f"✅ ONNX 模型已导出到 {onnx_path}")
复制代码
转换后的 ONNX 格式嵌入模型 bge-small-zh-onnx 的数据文件组织结构如下:
  1. 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 映射([CLS], [SEP], [PAD] 等)
复制代码
4. 分词器

在使用 ONNX Runtime 加载嵌入模型对文本进行向量化之前,我们还需要了解一个关键组件:分词器(Tokenizer)。
所谓“字、词、句、段、篇”,语言的理解是分层的。但对于现代深度学习模型(尤其是基于 Transformer 的架构)而言,它们并不直接处理原始字符,而是将文本切分为更小的语义单元——“词元”(token)。这个过程就是 Tokenization(词元化),由分词器完成。
分词器的重要性体现在以下两点:

  • 模型输入的前提:嵌入模型只接受 token ID 序列作为输入,而非原始字符串。没有正确的分词,就无法生成有效的 embedding。
  • 影响语义精度:不同的分词策略会导致不同的 token 序列,进而影响向量表达的质量。
4.1 自定义分词器

其实分词器的实现也并不神秘,我们完全可以实现一个简单版本的:
  1. #include  #include  #include  #include   #ifdef _WIN32 #include  #endif  using 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[token] = 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[i] & 0x80) == 0) {  // ASCII       chars.push_back(std::string(1, text[i++]));     } else {  // UTF-8 多字节       int bytes = 0;       if ((text[i] & 0xE0) == 0xC0)         bytes = 2;       else if ((text[i] & 0xF0) == 0xE0)         bytes = 3;       else if ((text[i] & 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_mask std::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 ID   const int clsId = vocab.at("[CLS]");   const int sepId = vocab.at("[SEP]");   const int padId = vocab.at("[PAD]");   const int unkId = vocab.at("[UNK]");    // 初始化向量(自动 padding)   std::vector inputIds(maxLength, padId);   std::vector attentionMask(maxLength, 0);    //开头加 "[CLS]"   size_t currentIndex = 0;   inputIds[currentIndex] = clsId;   attentionMask[currentIndex] = 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[currentIndex] = (it == vocab.end() ? unkId : it->second);     attentionMask[currentIndex] = 1;     currentIndex++;   }    //结尾加 "[SEP]"   inputIds[currentIndex] = sepId;   attentionMask[currentIndex] = 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& [inputIds, attMask] = Tokenize(text, vocab);    // 打印前10个 token ID 验证   cout  u64 {     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 === #[unsafe(no_mangle)] 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. 执行分词 === #[unsafe(no_mangle)] 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![0u32; input_ids.len()];      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. 释放结果内存 === #[unsafe(no_mangle)] 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 接口如下:
  1. // 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;
复制代码
  1. // hf_tokenizer_ffi #pragma once  #include "tokenizer_result.h"  #ifdef __cplusplus extern "C" { #endif  void* 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 绑定的接口:
[code]#include   #include  #include   #ifdef _WIN32 #include  #endif  using namespace std;  void TestTokenize() {   void* handle =       tokenizer_create("C:/Github/search/bge-small-zh-onnx/tokenizer.json");    if (!handle) {     std::cerr

相关推荐

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