引言:单机 RAG 服务的困境与出路
做本地 RAG 的开发者,大概都经历过这样的折腾:想跑一个纯本地的文档检索服务,结果发现内存态索引重启就丢,得接持久化向量库;向量检索搞定了,又得配 Ollama 做重排;再后来发现检索质量不稳定,还得上 pgvector……最后部署的时候,容器镜像大得离谱,运维成本远超预期。
rag-task-service 这个项目就是对着这个痛点来的:能不能用最简单的 SQLite + hash embedding,开箱即用;同时又预留接口,让有需要的人能平滑迁移到生产级后端?
答案是:可以,而且背后的设计决策值得细细拆开看。
这篇文章将围绕七个关键设计决策展开——从存储层到检索层,从并发控制到重排降级,每个决策都会对比"直觉做法"和"实际做法",解释为什么后者更好。
速览(TL;DR)
- 零外部依赖即可跑通整套 RAG 流水线:随附 .env 默认启用 LanceDB(本地文件、零外部服务)+ hash embedding + SQLite WAL,克隆后
make run直接可用,语义嵌入与 pgvector 均可选 - embedding 用 FNV-1a 而非 Rust
DefaultHasher,跨版本稳定,持久化向量索引重启后不会因哈希算法漂移而失效 - 混合检索(默认
hybrid)并行跑 BM25 关键词召回与向量召回,再用 RRF 融合(k=60),中文短查询与专有名词场景明显强于纯向量 - LLM 重排是可选的健壮性增强:调用超时/异常时自动降级为按 RRF 原序返回,搜索永远有结果
- 持久化后端可切换:LanceDB 在数万 chunk 内表现良好(17k chunk 混合检索约 54ms);切到 pgvector 后把 BM25 下推到 SQL 内并走 HNSW 索引,340k chunk 混合检索约 1.5s
一、为什么选 SQLite WAL 而不是直接跑内存
但实际动手之后,我发现这类存储有个隐性问题:它们大多只提供简单的 Key-Value 接口,而 RAG 场景的核心需求是带索引的文本查询,这本质上是关系型查询的变体。
SQLite 不同。它不要求先起任何独立服务,而且 WAL(Write-Ahead Logging)模式解决了并发读的性能瓶颈——对"写少读多"的 RAG 服务来说,这恰好是最优的存储模型。更关键的是,这个选择会一路影响检索层的形态,后面的检索设计都围绕"不引入外部服务、需要时再整体切换"展开。
数据库通过 tokio_rusqlite 打开,提供非阻塞的异步接口,这样 HTTP 请求线程不会因为等待数据库 IO 而阻塞。
这段触发器是我最满意的设计之一。RAG 系统中的事件日志(文档上传、分块、嵌入任务)如果无限增长,会严重影响查询性能。用触发器在 INSERT 之后自动裁剪到 200 条,既保证了监控需求,又避免了手动清理的逻辑散布在业务代码中。
SQLite WAL 模式同时支持多读者和单写入者,这对于一个"写少读多"的 RAG 服务来说,恰好是最优的存储模型。
二、乐观并发控制:版本号如何避免静默覆盖
如果两个请求同时更新同一条任务记录,会发生什么?
直觉做法是"最后写入者胜"——谁的请求后到达,谁的更新就覆盖前者。这在大多数 CRUD 场景下没有问题,但在 RAG 任务管道中,任务状态流转是关键的:一个文档可能正被分块、嵌入、写入向量库,如果中间被另一个请求静默覆盖,整个流水线会陷入不一致状态。
我的做法是引入 expected_version 字段,配合 HTTP PATCH 请求做乐观锁校验。
如果版本不一致,数据库层会返回 409 Conflict,客户端拿到这个响应后可以选择重试(重新读取最新状态再更新)或者放弃。这个机制的代价极低——只多了一个整数字段和一个比较操作,但换来的是任务状态的一致性保证。
对比行锁方案:行锁会阻塞并发请求,在高并发场景下成为瓶颈;而乐观锁让并发请求快速失败,客户端自己决定如何处理冲突,更符合 RESTful 的风格。
三、为什么 hash embedding 选 FNV-1a 而非 DefaultHasher
Hash Embedding 是一种将文本映射到低维向量的技术:将文本切分为 bigram(2-gram),对每个 bigram 做哈希,然后用哈希值作为向量索引,累加对应的维度。
直觉上,用 Rust 标准库的 DefaultHasher 是最方便的选择——一行代码,零依赖。但 DefaultHasher 有一个致命问题:它不保证跨版本一致。Rust 不同版本可能使用不同的默认哈希算法,这意味着今天持久化到 SQLite 的向量,明天重启服务后 hash 值变了,整个向量索引就废了。
RAG 场景对 hash 的稳定性要求极高——文档一旦入库,无论服务重启多少次,相同的文本必须映射到相同的向量。
/// FNV-1a 64 位哈希。选择它而非 `DefaultHasher` 的原因:哈希结果在任意 Rust 版本与
/// 平台上都稳定,保证 hash embedding 落盘后跨进程一致。
fn fnv1a(bytes: &[u8]) -> u64 {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for &byte in bytes {
hash ^= byte as u64;
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
/// 对两个字符组成的 bigram 做稳定哈希,输入为其 UTF-8 字节序列。
fn fnv1a_bigram(bigram: &[char]) -> u64 {
let mut bytes = Vec::with_capacity(8);
for character in bigram {
let mut buffer = [0; 4];
bytes.extend_from_slice(character.encode_utf8(&mut buffer).as_bytes());
}
fnv1a(&bytes)
}
这不是过度设计,而是 RAG 系统的底线要求——嵌入不稳定,检索就是空转。
四、RRF 融合:为什么混合检索选择"倒数排名"而非加权打分
混合检索是 RAG 的经典架构:用 BM25 做关键词召回,用向量相似度做语义召回,然后把两路结果合并。
直觉做法是把 BM25 分和向量相似度直接相加。但这里有个问题:BM25 分和向量相似度的量纲完全不同。BM25 分可能落在 0 到 10 之间,而余弦相似度落在 0 到 1 之间。直接相加,谁占主导取决于你碰巧调出的参数,没有理论依据。
我的选择是 RRF(Reciprocal Rank Fusion,倒数排名融合)。RRF 不关心原始分数,只关心排名:
RRF 的核心优势在于:它天然归一化。无论两路检索的原始分数分布如何,只要排名有意义,融合结果就有意义。k=60 是 RRF 原论文中提出的经验值,在实践中表现稳定。
更重要的是,RRF 让你可以独立调优两路检索,而不需要为它们的分数对齐而头疼。BM25 调得再激进取 top-20,向量检索调得再保守取 top-50,RRF 都能合理融合。
五、为什么 BM25 索引要持久化:其一是离线可跑,其二是重启免重建
大多数 RAG 教程会建议你用 SQLite 存文本、用专门的向量数据库(如 Chroma、Weaviate)存向量。但在单机场景中,这个选择引入了不必要的复杂性。
rag-task-service 的做法是把 BM25 倒排索引(chunk_meta / chunk_terms 两张普通表)和向量库分开放,但都做成了可持久化:关键词 postings 落在 SQLite 里,向量落在 LanceDB(pgvector 模式下两者都进 PostgreSQL)。文档分块后,分词结果与文档长度统计写入 SQLite 的倒排表,hash embedding 写入向量表,两者通过文档 ID 关联。
这样一个设计解决了两个彼此相关的痛点。第一,离线可跑:默认不配置任何外部服务就能完整演示 RAG 全流程,评测与测试不用依赖网络。第二,重启免重建:倒排索引和向量都持久化,服务重启后无需对已有文档重新分块、重新嵌入、重新建索引,检索立即可用。
需要注意的是,BM25 用的是 jieba-rs 分词后的自定义倒排表,而不是 SQLite 内置的 FTS5——倒排表可以直接复用 SQL 读写与事务,也和向量后端一样能整体切换到 pgvector,避免引入两套独立的索引机制。
这个取舍的代价是:如果数据量到了百万级以上,SQLite 单文件的性能确实不如专用搜索引擎。但我的判断是:对于单机 RAG 场景,百万级文档已经是天花板,而 SQLite + LanceDB 在这个规模下完全够用。
六、LLM 重排为什么需要自动降级机制
有了混合检索,结果质量还不够吗?够,但可以更好。
LLM 重排的思路是:先用混合检索取出 top-K 个候选文档,再让 LLM 根据查询语句对这些文档重新排序,输出最终排序。相比基于规则的排序,LLM 能更好地理解查询意图和文档内容的相关性。
但 LLM 重排有一个明显问题:它依赖外部服务(比如 Ollama),而外部服务可能不可用、可能响应慢、可能返回错误结果。
我的做法是设计一个带自动降级的重排管道。正常流程:混合检索 → LLM 重排 → 返回。降级流程:混合检索 → 直接按 RRF 分数排序 → 返回。
// LLM 调用失败(超时/网络/HTTP)时降级返回原顺序:重排是可选增强,不应让搜索 503。
let content = match self.call_llm(query, &candidates).await {
Ok(content) => content,
Err(error) => {
eprintln!("rerank degraded to original order: {error}");
return Ok(candidates.into_iter().take(top_k).collect());
}
};
// 输出解析失败同样回退原顺序。
let ordered = match parse_order(&content, n) {
Some(order) => reorder(candidates, &order),
None => candidates,
};
Ok(ordered.into_iter().take(top_k).collect())
降级不是"没做好",而是"工程健壮性"。LLM 服务偶尔重启、网络抖动、超时——这些在生产环境中是常态。一个不能降级的好功能,在故障时就是一个坏功能。
七、Axum 路由设计:为什么按资源而非阶段组织
RESTful API 的路由设计有很多流派。有人把 RAG 流水线按阶段组织(/rag/upload, /rag/embed, /rag/search, /rag/rerank),有人按资源组织。
我选择了后者。原因很简单:如果按 RAG 阶段拆端点,客户端要先记住一堆时序步骤才能完成一个操作;而把"任务"和"文档"建模为资源,每个操作都是对单个资源的 CRUD,职责清晰,也方便做权限控制和乐观锁。
实际的 API 分层如下(前缀 /api):
POST/GET /api/tasks # 创建/列取任务(任务用于追踪后台流水线进度)
GET/PATCH/DELETE /api/tasks/{id} # 任务详情/更新(乐观锁 expected_version)/删除
POST/GET /api/documents # 上传文档(触发导入任务)/列取文档与状态
DELETE /api/documents/{id} # 删除文档及其分块向量
GET /api/search # 检索(SEARCH_MODE=vector / hybrid,可选 rerank)
GET /api/events + /stream # 事件历史 / SSE 实时推送
这里有个容易被直觉带偏的细节:任务和文档是两个独立资源,而不是"一次任务对应一次上传"的绑定关系。文档承载内容与状态(queued → processing → ready),任务追踪导入等后台流水线的进度,两者通过文档 ID 关联但生命周期独立——删文档不会要求任务必须删,任务失败也不会回滚文档。上传一个文档会创建一个导入任务,但任务只是观测手段,不是领域模型的唯一入口。
按资源组织路由有几个好处:第一,URL 本身就表达了系统的领域模型,开发者一看就懂。第二,Axum 的路由处理器可以按资源分组,每个处理器专注一个实体的 CRUD,代码结构清晰。第三,如果将来需要加权限控制,按资源粒度加比按功能粒度加更容易推理。
这个项目最让我有成就感的,不是它跑起来了,而是每个决策都有清晰的"为什么"。SQLite WAL 解决并发、FNV-1a 解决稳定性、RRF 解决量纲、降级解决健壮性——这些不是一拍脑袋的选择,而是在实践中不断排除错误选项后留下的最优解。希望这些决策背后的思路,也能给正在搭建本地 RAG 系统的你一点启发。
常见问题(FAQ)
rag-task-service 需要哪些外部依赖才能跑起来?
零外部依赖。随附的 .env 默认启用 LanceDB(本地文件、零外部服务)+ hash embedding + SQLite 存储,克隆后直接 make run 即可完成文档导入与混合检索;只有接入真实语义嵌入(如 Ollama nomic-embed-text)或使用 pgvector 后端时才需要额外服务。
为什么要用 FNV-1a 而不是 Rust 的 DefaultHasher?
DefaultHasher 不保证哈希值跨 Rust 版本一致。持久化到向量库的 embedding 一旦在重启后换了算法,整个索引就失效了。FNV-1a 是独立的稳定算法,同一文本在任何版本、任何平台都得到相同向量,这是 RAG 系统”重启后检索仍可用”的底线。
服务重启后,之前导入的文档和索引还在吗?
在。任务、文档元数据存入 SQLite(WAL 模式),向量写入 LanceDB;BM25 倒排索引也持久化在 SQLite。重启后两者自动恢复,无需重新分块、重新嵌入或重建索引,查询立即可用。
混合检索和纯向量检索有什么区别?该选哪个?
纯向量(vector)只做语义召回;混合(hybrid,默认)同时跑 BM25 关键词召回与向量召回,再用 RRF 融合两路排名。短中文查询、含专有名词时混合明显更稳——向量容易漏掉词面命中,BM25 补上这个缺口。
什么时候应该切换到 PostgreSQL + pgvector?
chunk 数达到数万到数十万级、想单机扛更大规模时。LanceDB 在低几万 chunk 内表现良好(17k chunk 混合检索 54ms),但过了约 3–5 万 chunk 就开始劣化;pgvector 模式把 BM25 打分下推到 SQL 内、向量走 HNSW 索引,340k chunk 时混合检索约 1.5s。
LLM 重排失败会影响检索结果吗?
不会报错中断。重排是可选的增强环节:配置 RERANK_MODEL 后,若 LLM 调用超时、网络异常或返回不可解析结果,服务自动降级为按 RRF 原始排序返回。搜索永远有结果,只是少了一次质量增强。
接口鉴权是怎么做的?
配置 API_KEY 后,Axum 中间件强制校验每个请求的 x-api-key 头,未带或错误返回 401;未配置时放行(本地开发)。由于浏览器 EventSource 无法自定义请求头,SSE 流的鉴权由反向代理(开发时为 Vite 代理,部署时为 nginx 容器)注入 x-api-key 完成。
项目地址
同系列文章
本文是「实战项目拆解(PSE)」系列之一。同系列其他文章:
- 基于 AutoGen 构建 PSE 三角色闭环:一个可重试、可追溯的 Agent 协作框架 — Planner / Specialist / Evaluator 三角色协作,强调可重试与可追溯
- AutoGen PSE 架构解析:用 Planner/Specialist/Evaluator 模式构建可靠的多智能体协作系统 — 从消息总线到生命周期的架构拆解
- 当 LLM 开始骗自己:一个用正则表达式守护文章可信度的多 Agent 框架 — 程序化校验 + 多 Agent 防止 LLM 幻觉
- LangGraph PSE: 用状态机显式建模 PSE 三角色协作 — 以状态机显式建模三角色协作
- 让智能体从源头不说谎:用 LlamaIndex Workflow 构建 RAG 接地的 PSE 多智能体框架 — LlamaIndex Workflow 编排 RAG 接地的多智能体
发表回复