明思义 发表于 2026-2-12 22:25:04

从零实现一个生产级 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

热琢 发表于 2026-2-19 19:43:42

收藏一下   不知道什么时候能用到

村亢 发表于 2026-2-21 02:44:53

用心讨论,共获提升!

汇干环 发表于 2026-2-24 08:07:56

这个好,看起来很实用

枢覆引 发表于 2026-2-24 13:37:35

感谢分享,下载保存了,貌似很强大

步雪卉 发表于 2026-2-24 22:27:38

感谢发布原创作品,程序园因你更精彩

胥望雅 发表于 2026-2-26 21:01:02

感谢发布原创作品,程序园因你更精彩

吮槌圯 发表于 2026-2-27 05:15:30

热心回复!

嗣伐 发表于 2026-3-9 23:43:24

东西不错很实用谢谢分享

康器 发表于 14 小时前

鼓励转贴优秀软件安全工具和文档!
页: [1]
查看完整版本: 从零实现一个生产级 RAG 语义搜索系统:C++ + ONNX + FAISS 实战