找回密码
 立即注册
首页 业界区 业界 从零实现一个生产级 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.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 的数据文件组织结构如下:
  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 #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[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_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 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 __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 绑定的接口:
[code]#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

相关推荐

7 天前

举报

懂技术并乐意极积无私分享的人越来越少。珍惜
4 小时前

举报

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