
1. RAG 召回端到底长什么样为什么我会把技术栈定在 C/ONNX/FAISS最近给内部知识库的检索模块做了一轮从关键词到语义搜索的升级最后跑通的方案就是标题里写的这套组合C17 负责服务和数据管线ONNX Runtime 负责 embedding 模型的推理FAISS 负责大规模向量的召回。RAG 链路里真正吃资源的往往不是生成那一段而是召回每次查询都要把用户问题编码成向量然后到几百万甚至上千万的 chunk 里找最接近的一批结果。召回质量决定生成内容的天花板这一步要是查得不准后面大模型再强也很难补回来。我先把系统拆成六个模块文档接入、文本清洗、分块、批量嵌入、索引构建、查询服务。接入侧负责拉取原始文档清洗侧把 HTML 标签、多余空白、BOM 头处理掉分块侧按语义边界切出合适大小的片段批量嵌入这步把文本变成浮点向量也是 CPU 占用最大的地方索引侧负责把向量写进 FAISS查询侧接一个 RPC/HTTP 接口拿到 query 向量后做 ANN 检索最后把 top-k 结果连同元数据返回给下游生成服务。为什么不用 Python 直接做这套先说清楚几万条文档、查得也不频繁的场景下用 Python 封装其实完全没有问题开发速度快得多。但当我需要把它做成一个常驻服务跑在 16 核、几十 GB 内存的容器里还要接受每秒几十次的查询时C 的收益就很明显了。没有解释器没有全局锁线程调度可控内存峰值可预测容器镜像也小。ONNX Runtime 和 FAISS 都有原生 C 接口组合使用没有阻力。这套组合的另一层好处是把模型推理和向量索引都放在一个进程里数据不用反复序列化传出去。我的设计是「索引构建进程」和「查询服务进程」共用同一套 Embedder 类、同一个分块算法只是启动参数不同。索引进程负责全量文档离线建索引查询进程加载已生成的索引文件对外服务复盘和线上逻辑天然一致。下面从模型转换开始讲因为这是整条链路上最容易翻车的一环。2. embedding 模型的 ONNX 转换导出、池化、句子向量2.1 模型选型时先想清楚这几件事embedding 模型选择没有绝对最优主要看三件事向量维度、最大 token 长度、单条推理耗时。我最终用的是某个 6 层的中文 BERT 类 embedding 模型输出维度 768支持 512 token开源社区有现成权重导出 ONNX 后单条 CPU 推理耗时可以接受。选它不是因为效果最好而是因为部署成本低换成 12 层大模型后 CPU 推理时延会明显上升除非你愿意上 GPU。有一个选择上的关键点要选「以句子为单位输出向量」的模型而不是普通预训练语言模型。后者默认只给出最后一层每个 token 的 hidden state需要自己做池化。很多开源的 embedding 模型已经是加了池化和归一化的封装导出后直接输出一条 768 维向量省事很多。但如果你拿到的是裸 Transformer 权重就必须在导出时把池化逻辑一并包进去。2.2 导出时最关键的动态维度设置导出 ONNX 时我最强调的一件事就是 dynamic axes 一定要配好。如果 batch 维和 seq_len 维都固定死线上服务就没法做批量推理也没法接收长度不同的 query。import torch from transformers import AutoModel, AutoTokenizer model AutoModel.from_pretrained(model_path) tokenizer AutoTokenizer.from_pretrained(model_path) # 包装一层最后输出均值池化后的句子向量 class SentenceEmbedder(torch.nn.Module): def __init__(self, model): super().__init__() self.model model def forward(self, input_ids, attention_mask): last_hidden self.model(input_ids, attention_mask).last_hidden_state mask attention_mask.unsqueeze(-1).float() summed (last_hidden * mask).sum(dim1) avg summed / mask.sum(dim1).clamp_min(1e-9) return torch.nn.functional.normalize(avg, p2, dim1) student SentenceEmbedder(model) dummy_input_ids torch.ones(1, 128, dtypetorch.int64) dummy_attention_mask torch.ones(1, 128, dtypetorch.int64) torch.onnx.export( student, (dummy_input_ids, dummy_attention_mask), embedding_model.onnx, input_names[input_ids, attention_mask], output_names[sentence_embedding], dynamic_axes{ input_ids: {0: batch, 1: seq_len}, attention_mask: {0: batch, 1: seq_len}, }, opset_version14, )上面这段代码里池化逻辑放在导出包装模块中这样线上 C 侧拿到的就是最终的句子向量。注意均值池化时用 attention_mask 做加权避免把 padding token 对应的 hidden state 也算进平均值里。很多坑都是在这里踩到的直接用 last_hidden_state 的第一维 cls token或把 padding 位置也平均进去结果向量质量明显下降。2.3 向量一定要做归一化几乎所有向量检索系统都应该在模型内部把句子向量做 L2 归一化而不是存到 FAISS 里再算。归一化之后内积就是余弦相似度所以用 IndexFlatIP 直接当余弦检索用。这步在导出图里完成线上不仅少一次循环还能保证 query 和 doc 两侧的处理完全一致。我在模型里顺手把归一化写进 forward导出图会自动包含 L2 Norm 节点。实际观测下来缺少这步会让相似度分数不稳定特别是在做阈值截断时会遇到一些莫名其妙的误召回。把归一化放在模型图内至少所有输入都经历了同一套变换。2.4 C 侧调用 ONNX Runtime 的基本姿势C 侧做推理的路数比较固定加载 session、准备输入 tensor、run、取输出。我在工程里封装了一个 Embedder 类持有 Ort::Session 和输入输出名缓存避免每次查询都重新获取 name。#include onnxruntime_cxx_api.h class Embedder { public: Embedder(const std::string model_path) { Ort::SessionOptions options; options.SetIntraOpNumThreads(4); options.SetGraphOptimizationLevel( GraphOptimizationLevel::ORT_ENABLE_ALL); session_ std::make_uniqueOrt::Session( env_, model_path.c_str(), options); input_names_ session_-GetInputNames(); output_names_ session_-GetOutputNames(); } std::vectorfloat Encode(const std::vectorint64_t input_ids, const std::vectorint64_t attention_mask); private: Ort::Env env_; std::unique_ptrOrt::Session session_; std::vectorstd::string input_names_; std::vectorstd::string output_names_; };这里有个重要经验不要在每次调用时新建 Ort::Sessionsession 创建开销很大一次查询如果包含初始化 session 的时间延迟会翻几十倍。还有如果服务是多线程并发查询不要给每个线程各建一个 session建议共享同一个 session然后通过线程池控制并发否则内存占用会非常夸张。tokenizer 的处理是另一个模块。ONNX Runtime 本身不做分词你需要用独立的 tokenizer 库把中文文本转成 token id 和 attention mask。这一步看起来简单实际容易出各种不一致问题后面我单独说。3. 中文文本切分别让一块好内容被「腰斩」3.1 为什么不能固定长度硬切很多人刚开始做 RAG 时会用「每 200 个字符切一块」这类粗暴方法效果往往不理想。问题在于知识库里的 FAQ 通常是「问题 回答」的结构如果回答中间刚好切到第 200 字符那么标题、条件、答案可能被分到三个不同的块里。用户查「信用卡逾期怎么办」召回的是第三块「应对措施」但缺少问题背景生成端看到的信息就是残缺的。正确做法是依据文本结构递归切分。我按这个优先级处理分隔符段落空行、换行符、句号、分号、逗号。先尽量在段落边界切如果当前块已经太长就往下一级分隔符找。这样切出来的块大多语义完整就算不能完全锲合语义边界也远比硬切字符强。3.2 中文场景下的分块参数参考我最终把参数定为块最大长度 400 个中文字符重叠长度 48 到 64 个字符。为什么要有重叠因为有些关键信息跨在边界附近比如「本产品不支持……的机型」分词后可能把「不支持」落在上一块末尾下一块开头只有「的机型」。重叠部分的存在能显著降低这种边界截断带来的召回损失代价是索引体积增加 10% 左右可以接受。一句话概括我的经验宁可块小一点也别让块之间失去关联。块太大时embedding 向量会被平均池化稀释导致相似度区分度下降块太小又容易丢失上下文。400 字符对中文是一个比较稳的起点后续可以用实验调。切分模块的接口设计成纯函数输入原始文本和元数据输出一系列 Chunk 对象。这样做的好处是离线建索引和线上增量更新复用同一套逻辑不会出现「离线切 A 方式、线上切 B 方式」的经典事故。struct Chunk { uint64_t chunk_id; uint64_t doc_id; size_t offset; std::string content; std::string section_path; };3.3 清洗和去重不能只做一次文本清洗最容易被忽略但直接影响嵌入质量。我在实际数据里遇到过从 PDF 提取出来的文本带大量换行但语义连续从 Markdown 带出来一堆空格从网页抓下来有 BOM 头和各种 HTML 实体。清洗规则不统一同一个句子可能因为换行位置不同被切到不同块生成不同的向量。我建议在清洗阶段把换行规范成统一格式去掉 HTML 标签把全角空格转半角再处理 BOM。这里有个实用技巧清洗之后再按文档内容做 hash 去重。知识库里大量重复文档不是完全相同而是仅有日期或版本号不同。可以用「去数字 去空白」后的文本哈希作为重复度判断或者用 minhash 做近似去重。宁可保守一些也不要让同一内容的 20 个副本同时进入索引。4. FAISS 索引进 CIndex 选型和常见接口4.1 索引类型怎么选先跑通精确索引再谈近似索引FAISS 提供了多种索引类型刚开始别一上来就上 HNSW 或 IVF先建一个精确内积索引跑通全流程之后再用验证集对比召回率和时延。索引类型适用规模时延特征内存占用说明IndexFlatIP十万量级以下全部扫描随规模线性增长每向量 4 字节 × 维度精确做基准对照最合适IndexHNSWFlat百万量级对数增长通常毫秒级较高图结构额外内存召回率高推荐默认选IndexIVFFlat百万以上受 nprobe 控制中等需要训练聚类IndexIVFPQ千万量级低极低压缩会牺牲召回率我线上的数据大概在几十万 chunk 级别选的是 HNSWFlatM 设 32efConstruction 设 200查询时 efSearch 动态可调。这个配置在本地压测里top-10 查询 P50 在 1 到 2 毫秒P99 也很稳。如果你只有一两万 chunk直接用 FlatIP 就够了HNSW 虽然快但构建参数调起来多一层不确定性。4.2 用 IndexIDMap 管理自定义 chunk IDFAISS 内部的向量 ID 默认是从 0 开始递增的序号但工程上我更希望直接用 chunk_id 进行检索后再映射回原文。最方便的方式是用 IndexIDMap 包一层底层索引add 时传入 chunk_idsearch 返回的 labels 就是 chunk_id。#include faiss/IndexFlat.h #include faiss/IndexIDMap.h #include faiss/IndexHNSW.h #include faiss/index_io.h using faiss::Index; int embedding_dim 768; // 精确索引方案 faiss::IndexFlatIP* base_flat new faiss::IndexFlatIP(embedding_dim); faiss::IndexIDMap* index new faiss::IndexIDMap(base_flat); // IndexIDMap 负责释放 base_flat不要手动 delete // 如果是 HNSW 方案这样建 faiss::IndexHNSWFlat* hnsw new faiss::IndexHNSWFlat(embedding_dim, 32); hnsw-hnsw.efConstruction 200; faiss::IndexIDMap* index_hnsw new faiss::IndexIDMap(hnsw); // 添加向量embedding_matrix 是连续数组ids 是 chunk_id index-add_with_ids(total_count, embedding_matrix.data(), ids.data());很多初次用的人会直接调index-add()然后发现没法把向量对回文本。用 IndexIDMap 后用add_with_idssearch 返回的 labels 就是自定义 id这一步能省掉后续一堆映射代码。我甚至把 chunk_id 设计成 64 位整数高 32 位存 doc_id低 32 位存块序号。这样检索结果天然自带文档归属信息不需要额外查表。4.3 查询时的常见错误和参数调整查询侧代码看起来更简单但有几个细节必须注意。第一query 向量必须和索引里的向量做过相同的归一化否则内积分数没有意义。第二HNSW 的 efSearch 参数不传时用默认值效果未必好。我建议把 efSearch 设成召回率调优的开关先设 64再逐步调高观察召回增益和时延变化。std::vectorint64_t input_ids /* tokenizer 输出 */; std::vectorint64_t attention_mask /* tokenizer 输出 */; std::vectorfloat query_vec embedder.Encode(input_ids, attention_mask); std::vectorfloat distances(10); std::vectorfaiss::idx_t labels(10); faiss::SearchParametersHNSW params; params.efSearch 256; index-search(1, query_vec.data(), 10, distances.data(), labels.data(), params);HNSW 的搜索是线程安全的只要没有并发写操作。我线上用读锁包住 search写索引时统一加写锁。如果你又需要高频更新又想保持低延迟最简单的方案是「双索引」备索引在内存里用副本先构建完成后把指针原子切换旧索引延迟释放。这个模式鲁棒性很强后续可以做全量重建不影响线上查询。4.4 持久化与载入FAISS 本身提供了write_index和read_index生产环境一定要定期做索引快照。建完索引后保存一份服务启动时直接读避免每次都要重新处理所有文档。faiss::write_index(index, knowledge_hnsw.idx); auto persisted_index std::unique_ptrIndex(faiss::read_index(knowledge_hnsw.idx));这里有个经验read_index 之后尽量做一次空查询预热让页缓存和 HNSW 的图结构先落到 CPU cache 里否则线上刚启动的前几秒查询延迟会异常高。5. 查询路径的并发优化吞吐比你想的更依赖 embedding5.1 把批量推理放进查询路径很多人以为查询路径慢在向量检索实际不是。HNSW 搜索在几十万规模下通常是毫秒级真正的大头是 embedding 推理。如果每来一个 query 就单独做一个模型推理CPU 利用率很糟糕并发一上来延迟就飙升。我的做法是引入「批量合并」查询请求进入队列后最多等 2 毫秒收集同窗口的 query凑够 8 条就一起扔进 ONNX Runtime 跑一个 batch。ONNX 导出时 batch 维是动态的所以这种批量推理可以无缝工作。实测下来8 条 batch 相比单条推理平均单条耗时有明显降低。主要原因是模型里大量矩阵乘法和批量矩阵乘法算子可以复用同一组底层 BLAS 计算减少固定开销。需要注意的是批量合并会引入最坏情况下的等待时延。我的策略是设一个 deadline窗口时间到就立刻执行哪怕只有 1 条 query同时 batch 达到 8 条也立刻执行。这样既能吃到批量红利又不至于让单条请求久等。5.2 用 LRU 缓存覆盖重复查询知识库检索场景里用户的高频问题集中在很小的一个子集里。同一批热门 query 可能每天都有人查直接跑 embedding 太浪费。我加了一个无锁 LRU 缓存键是 query 的规范化文本值是 top-k 的 results。缓存命中时甚至不需要碰 FAISS直接从内存返回。这个缓存的命中率在内部统计里能到 30% 以上对整体 QPS 的提升非常可观。实现上不用引入额外重型依赖用一个 hash map 加双链表就能做。注意线程安全问题最后我干脆用了读写锁读多写少锁竞争很低。5.3 索引的并发读与监控指标为了不对 FAISS 内部做侵入式修改我对外只保证「检索是无状态的」。索引写成配置文件启动后加载query 阶段只读因此可以直接用 shared_mutex 保护。但如果要做增量更新就切换成双索引模式避免写锁挡住线上流量。上线前一定要埋指标。我至少会记录这三组embedding 时延 p50/p99、向量检索时延 p50/p99、整体查询 QPS。每次发布模型或调整索引参数后对比这几组指标。不是只在本地把效果调好了就行线上观测到的延迟分布才是真正要盯的东西。这节说的都是具体工程优化没有太多高深的算法。生产级系统很多时候也就是靠这些一点一点堆出来的批量、缓存、预热、并发控制每一项都不复杂但合起来决定了服务稳定性和成本。6. 上线后才暴露的四个坑比教程里更值得看6.1 tokenizer 不一致离线验证和线上召回完全对不上我在联调阶段就吃过一次大亏。离线测试时用某个 tokenizer 脚本处理文档线上 C 服务用的却是另一个语言包的 tokenizer。两边分词结果不同同一个句子编出的向量差异很大导致评测集里表现不错的模型一上线就拉胯。根因是「离线建索引文档」和「线上 query 编码」必须严格走同一个 tokenizer 版本。我后来把 tokenizer 静态编译进服务并加了「一致性测试」启动时拿一组固定句子分别用离线和线上代码编码计算余弦相似度差异超过阈值就直接告警。这套自检机制帮我挡住了后面两次 tokenizer 升级带来的隐患。6.2 query 指令前缀丢失召回率掉几个点很多开源中文 embedding 模型在使用时有一条隐藏要求文档侧直接原样编码但 query 侧要加上特定的指令前缀例如「为这个句子生成表示以用于检索相关文章」。前缀的作用是把「查询意图」和「文档内容」放入不同的语义空间。我刚开始没做这一步内测时几个典型问题的 top-10 召回明显偏弱。把前缀加上后同一批查询的召回率有明显回升。这个点在模型文档里经常被一句话带过但对生产系统来说就是实实在在的差异。我的建议是不论用什么模型先把官方建议的 query 指令原样实现再用评测集对比有无前缀的差异。6.3 FAISS 并发读导致的不稳定单线程很快多线程反而崩有段时间我发现压测时偶发超时甚至出现 segment fault。定位后发现是在写索引的同时去 search底层 HNSW 图结构在写路径上发生了变更。FAISS 的 Index 对象默认不是线程安全的并发读写对象虽然很多帖子说搜索线程安全但那是「只读且无写入」的前提。之后我统一改成双索引 原子切换线上查询永远打当前只读快照重建索引时在新副本上完成最后再把原子指针切过去。这个方案需要多一份内存但换来了极简的并发模型。修完这个问题之后压测再没出现过类似的崩溃。6.4 FP16 模型在 CPU 上会造成精度回退为了省内存我一度把 ONNX 模型转成 FP16 精度。GPU 上没问题但 CPU 上跑 FP16 推理时部分算子的精度不稳定导致同一个句子编码出的向量和 FP32 版本相差不小。当时用「向量余弦一致性」测试发现平均差异已经大到会对 top-10 排序产生干扰。最终我在 CPU 场景下乖乖用 FP32动态量化也只用于离线特征提取而不是线上服务。如果你真的对内存敏感可以尝试 INT8 动态量化但一定要做过验证集测试再切换。这里最怕的是你没注意到精度差异最后把问题甩给「索引调参」。6.5 检索质量的验证不要只靠肉眼调几个例子最后一个坑是方法论层面的。很多人改完分块或向量参数后拿两三个例子跑一遍看「感觉效果还行」就上线了。我强烈建议建一个可重复的评测集从知识库里抽取几百个带标准答案的问题定义 recallk 为「正确答案是否出现在前 k 个结果中」然后固定 seed 自动化跑完整评测。有了评测集每次调整参数都有客观依据。我分享一个简单的流程先跑 FlatIP 作为召回率绝对上限然后用 HNSW 逼近它比较在不同 efSearch 下的 recallk如果 HNSW 的 recall 和 FlatIP 差得太多先调索引参数别急着怀疑模型。分块、清洗、模型、索引每一层的影响都被分离开出问题时能快速定位。这套系统上线后我最大的感悟是RAG 的技术点看起来都很简单但把每一层都做到可观测、可复现、可替换才是「生产级」和「demo」的本质区别。从模型导出、分块逻辑到索引并发每一个环节都是独立可验证的模块最后拼起来才敢放心让线上流量走这条路。