Article
RAG系统架构设计:边缘计算、混合检索与增量索引闭环实战
以个人知识库问答系统为例,拆解Cloudflare Workers、Vectorize、D1 FTS5、KV、混合检索和增量索引闭环的架构取舍与验证边界。
引言:当检索成为AI应用的瓶颈
RAG系统最容易被低估的部分,不是把向量库接上模型,而是让回答尽量只引用它应该引用的内容。
在一个个人知识库问答场景里,用户可能问”AI-TDD如何约束AI输出”。通用模型也许知道测试驱动开发,却不知道站内文章如何把 Manifest、门禁、追溯矩阵和交付证据组织成一套约束链路。这个差距不是模型能力不足,而是知识边界没有进入上下文。
问题的根源在于上下文窗口的有限性与企业知识的海量性之间的矛盾。这正是RAG(Retrieval-Augmented Generation,检索增强生成)的价值——它通过外部检索将相关知识注入模型上下文,让回答尽量贴近站内证据,而不是只依赖通用模型记忆。
本文复盘一个个人知识库RAG系统的架构设计。它覆盖Cloudflare Workers、Vectorize、D1 FTS5、KV、混合检索、增量索引和发布门禁。文章中的实现细节来自项目设计基线,不把个人知识库场景包装成通用企业标准。
适用边界:本文适合个人知识库问答、公开内容问答、文档站和小型知识库。它不覆盖多租户权限、内部机密文档、合规审计或强一致全局限流。如果你的场景涉及这些能力,需要在本文方案之外增加鉴权、审计、权限过滤和合规治理。
系列位置:本文是RAG指南第一篇,负责解释架构和索引闭环;第二篇进入分块、混合检索和意图识别实现;第三篇讨论质量评测、安全防护和发布门禁。
先看整体骨架:
图1:RAG系统架构与增量索引闭环。它回答的不是“某个API怎么调”,而是“公开入口、三存储分层和发布前索引同步分别落在哪个边界”。
架构选型:为什么不是传统的云服务器方案?
传统方案的隐性成本
在开始设计之前,我评估了两种主流架构:
传统云服务器方案:使用云主机或容器服务,加上OpenSearch / Elasticsearch、Redis和自维护应用服务。控制面更熟悉,但需要维护检索服务、缓存、扩缩容和跨地域延迟。
边缘计算方案:使用Cloudflare Workers + Vectorize + D1,运维面更小,但实际费用取决于请求量、向量规模、模型调用、区域和账号计划。
传统方案的隐性成本在于:向量数据库的运维复杂度、跨地域部署的延迟优化、以及突发流量的扩缩容管理。对于一个个人知识库来说,这些成本远超收益。
边缘计算的三点收益
1. 边缘入口更接近公开访问流量
Cloudflare Workers的优势是边缘运行和托管运行时。对个人知识库来说,这意味着:
- 请求入口不必回源到自建服务器
- Worker、D1、Vectorize绑定可以在同一运行时内组织
- 突发小流量不需要提前维护一套常驻服务
2. 小规模公开内容的成本弹性
Cloudflare的免费层和低门槛托管服务适合先验证公开内容问答。但这里不能把它写成无条件免费:
- Cloudflare额度和价格会变化,需要以官方文档和实际账号为准
- embedding、rerank和LLM调用通常来自外部模型服务,成本不由Cloudflare单独决定
- 免费额度适合原型和低流量场景,不应作为生产预算依据
3. 与静态站点的低侵入接入
如果个人知识库托管在GitHub Pages或其他静态平台,可以通过一个脚本挂件接入RAG入口:
<script
src="https://rag-worker.example.com/widget.js"
data-endpoint="https://rag-worker.example.com">
</script>
实际发布时仍要配置Origin allowlist、CORS响应头、限流和公开聊天开关。脚本接入容易,不代表安全边界可以省略。
数据层设计:三存储分离的架构哲学
为什么不用单一数据库?
设计数据层时,我考虑过几种方案:
- 纯Vectorize方案:简单,但无法高效支持关键词检索和分页查询
- 纯PostgreSQL方案:功能强大,但免费额度有限,运维复杂
- 三存储分离方案:每种存储负责最擅长的事情
最终选择了第三种方案,原则是单一职责。
Vectorize:专注向量相似性搜索
Vectorize是Cloudflare的托管向量数据库,与Workers深度集成:
// 向量检索示例
const results = await env.BLOG_VECTORIZE.query(embedding, {
topK: 30,
filter: {
indexStrategy: "stable-v1",
indexEpoch: "prod-v1",
status: "active"
}
});
它的优势在于:
- 高维向量检索:索引创建时固定维度,本项目按
text-embedding-v4设计基线使用1024维 - 元数据过滤:支持在向量搜索时附加过滤条件;实际可过滤字段需符合Vectorize metadata index和字段长度限制
- 托管运行面:无需自行维护向量数据库进程,但仍要关注维度、metadata、候选规模和索引版本
但Vectorize不适合存储管理状态,因为它不支持分页查询和复杂的事务操作。
D1 SQLite:事务性存储与全文检索
D1是Cloudflare基于SQLite的边缘数据库,我选择它的原因:
1. 关系数据支持
CREATE TABLE rag_document_manifest (
document_id TEXT PRIMARY KEY,
document_hash TEXT NOT NULL,
chunk_count INTEGER NOT NULL,
url TEXT NOT NULL,
title TEXT NOT NULL,
locale TEXT NOT NULL,
index_strategy TEXT NOT NULL,
index_epoch TEXT NOT NULL,
status TEXT NOT NULL,
corpus_hash TEXT,
last_ingest_run_id TEXT,
updated_at TEXT NOT NULL
);
CREATE INDEX idx_manifest_epoch_status
ON rag_document_manifest(index_epoch, status);
2. FTS5全文检索
SQLite的FTS5 trigram tokenizer会把连续三个字符作为token,可用于中英文混合内容的子串匹配。它不是中文语义分词器,但能补足向量检索在错误码、模型名和配置项上的精确匹配短板。
CREATE VIRTUAL TABLE rag_chunks_fts USING fts5(
title, text,
tokenize = 'trigram'
);
这缓解了纯向量检索在错误码、API名称、数字等精确匹配上的不足。
3. 批量写入与状态门禁
// 说明性片段:实际SQL、错误处理和finalized stats更新需要在项目代码中实现
const statements = [
env.RAG_DB.prepare("INSERT OR REPLACE INTO rag_chunks ..."),
env.RAG_DB.prepare("DELETE FROM rag_chunks_fts WHERE chunk_id = ?"),
env.RAG_DB.prepare("INSERT INTO rag_chunks_fts ...")
];
await env.RAG_DB.batch(statements);
D1 batch适合把同一批prepared statements一起提交。对RAG索引来说,更关键的边界是:建议只在Vectorize和D1写入都完成后更新finalized stats;失败的ingest run不应被/admin/stats当作当前可用索引。
KV:轻量级计数器
Cloudflare KV用于存储限流计数和熔断状态:
// Rate limit计数
await env.RAG_LIMITS.put(`session:${sessionId}:minute`, count, { expirationTtl: 60 });
await env.RAG_LIMITS.put(`ip:${ipHash}:hour`, count, { expirationTtl: 3600 });
KV的特点是最终一致性——在分布式环境下,计数可能有短暂的不一致。对个人知识库的轻量限流可以接受这个权衡,但它不适合作为强一致配额或审计事实源。
数据层职责表
| 存储 | 负责什么 | 不负责什么 | 设计理由 |
|---|---|---|---|
| Vectorize | chunk向量、检索必要metadata、向量相似性搜索 | manifest、分页审计、latest stats | 向量库只做检索面,避免把管理状态塞进向量metadata |
| D1 | document manifest、chunk镜像、FTS5索引、ingest runs | 高维向量计算 | D1适合事务、审计、分页和关键词检索 |
| KV | 运行时计数、限流窗口、轻量开关状态 | 强一致配额、审计事实源 | KV适合低成本计数,但要接受最终一致性 |
表1:RAG系统数据层职责——三存储分离设计
关键设计思路:
- Vectorize存储检索数据(向量+精简metadata)
- D1存储管理状态(manifest、chunk内容、FTS索引)
- KV存储运行时状态(限流计数、熔断状态)
这种分离让每个存储处理自己更适合的职责,同时降低数据耦合带来的维护复杂性。
混合检索:从单一向量到多路召回
纯向量检索的局限性
在系统初期,我仅使用Vectorize进行语义检索。很快我发现了一个问题:
场景1:错误码查询 用户问”如何解决Cloudflare 429错误”,向量检索返回了包含”限流”、“批量处理”等语义相关的文章,但没有精确匹配”429”这个错误码的文档。
场景2:API名称查询 用户问”如何使用text-embedding-v4”,向量检索将”v4”理解为版本概念,但用户实际想查询的是特定的模型名称。
场景3:数字查询 用户问”chunkSize应该设置为多少”,向量检索无法理解”800”这个数字的具体含义。
这三类场景的共性是:需要精确匹配的内容,只靠语义检索时容易漏掉关键信号。
混合检索的三层架构
| 阶段 | 默认设计基线 | 作用 |
|---|---|---|
| 语义召回 | Vectorize topK=30 | 找到语义相关内容,适合概念类问题 |
| 关键词召回 | D1 FTS5 topK=30 | 捕获错误码、模型名、配置项、数字等精确信号 |
| 融合与精排 | RRF topK=24,可选 qwen3-rerank topN=6 | 统一排序,避免单一检索器主导上下文 |
为什么选择RRF而非简单加权?
因为Vectorize的余弦相似度(0-1范围)和D1 FTS5的BM25分数(无界,越小越相关)不在同一分数尺度上,无法直接相加。
RRF公式:
function rrfScore(rank: number, k = 60): number {
return 1 / (k + rank);
}
// 融合公式
fusionScore = vectorWeight * rrfScore(vectorRank)
+ keywordWeight * rrfScore(keywordRank);
为什么k=60?
k值决定高排名的”惩罚程度”。较大的k值让排名差异影响更温和,避免单一检索器主导。本项目设计基线采用k=60,与Vectorize topK=30 + D1 FTS5 topK=30的候选规模匹配。上线前仍应通过固定评估集验证是否需要调整。
关键词检索的查询构造
FTS5使用trigram分词,查询构造需要特殊处理:
function buildFtsQuery(query: string): string {
return query
.split(/[^\p{L}\p{N}_:-]+/u) // 按非字母数字分隔
.filter(token => token.length >= 2 && token.length <= 64)
.slice(0, 12) // 最多12个关键词
.map(token => `"${token.replace(/"/g, "\"\"")}"`) // 转义双引号
.join(" OR ");
}
// 示例:
// 输入:"Cloudflare 429 错误怎么解决"
// 输出:"Cloudflare" OR "429" OR "错误" OR "解决"
这种构造方式既保留了中文短语,又将查询拆分为独立的OR条件,提高召回率。
每文档限额:避免单一主题垄断
一个常见问题是:如果用户询问”RAG优化”,而个人知识库中恰好有一篇很长的”RAG专题长文”,不加限制的情况下,检索结果可能被该文章的多个chunk填满。
解决方案是每文档限额:
function limitChunksPerDocument(chunks: RetrievedChunk[], max: number): RetrievedChunk[] {
const documentCounts = new Map<string, number>();
return chunks.filter((chunk) => {
const key = chunk.documentId;
const count = documentCounts.get(key) ?? 0;
if (count >= max) return false;
documentCounts.set(key, count + 1);
return true;
});
}
设置为maxChunksPerDocument=2意味着:即使某篇文章与查询高度相关,最多也只保留2个chunk,从排序层面提升结果多样性。
增量索引:从全量重建到精细化更新
早期方案的问题
系统最初采用全量重建策略:每次内容更新时,删除所有向量,重新导出corpus,重新生成所有embedding。这种方式的问题很明显:
- 成本集中:文章规模越大,embedding和upsert成本越容易被单次发布放大
- 时间较长:全量处理会把每次小改动变成一次完整索引作业
- 风险集中:一旦失败,索引处于不一致状态
增量更新的五状态模型
我采用了增量更新策略,将文档变化分为五种状态:
added状态:新documentId出现时触发。处理行为是全部分块、embedding、upsert,需要调用embedding。
contentUpdated状态:documentHash变化时触发。处理行为是重新分块、稳定ID覆盖、删除旧chunk,需要调用embedding。
metadataUpdated状态:hash不变但元数据变化时触发。处理行为是仅刷新metadata(getByIds + upsert),不需要调用embedding。
deleted状态:documentId消失时触发。处理行为是删除全部chunks和manifest,不需要调用embedding。
unchanged状态:hash完全一致时触发。处理行为是跳过,不需要调用embedding。
documentHash计算:
const documentHash = sha256(normalizedContentText + title + locale);
这里真正需要冻结的是参与哈希的字段边界。示例把正文、标题和语言纳入documentHash,而把publishedAt排除在外,这样单独修改发布日期不会触发重新embedding。实际项目可以使用任意稳定哈希实现,但本地dry-run与远端authoritative dry-run应共享同一归一化规则。
双层Dry-Run保护机制
增量更新的风险在于误操作可能导致大规模数据丢失。为此设计了双层Dry-Run机制:
第一层:CI本地Dry-Run
npm run rag:ingest -- --mode incremental --dry-run
输出预估统计:
{
"added": 2,
"contentUpdated": 1,
"metadataUpdated": 1,
"deleted": 0,
"unchanged": 417,
"estimatedNewChunks": 11,
"estimatedEmbeddingBatches": 2
}
第二层:Worker Authoritative Dry-Run
npm run rag:ingest -- --mode incremental --dry-run --remote-dry-run
Worker使用真实分块逻辑计算:
{
"estimatedChunkCount": 11,
"metadataReembeddedChunkCount": 0,
"embeddingBatchCount": 2,
"largestDocumentChunkCount": 8,
"dryRunHash": "sha256(...)"
}
dryRunHash的作用:apply阶段应携带这个hash,让Worker校验payload与前一次authoritative dry-run输入一致;如果hash不匹配,应拒绝写入。
Metadata-Only更新:适合域名迁移等元数据调整
当站点从旧域名迁移到新域名时,URL变了但正文未变。传统全量方案会重新embedding;metadata-only路径的目标是尽量复用已有向量,只刷新URL、标题、发布时间等展示字段。
async function refreshMetadataOnlyDocuments(
documents: MetadataDocument[],
env: Env
): Promise<MetadataRefreshStats> {
for (const doc of documents) {
// 1. 读取D1中的现有chunks
const rows = await readActiveDocumentChunks(doc.documentId, env);
// 2. 从Vectorize获取现有向量values
const existingVectors = await getVectorsByIds(rows.map(r => r.chunk_id), env);
// 3. 使用相同values + 新metadata调用upsert
const upsertVectors = existingVectors.map(vector => ({
id: vector.id,
values: vector.values, // 保持原有向量值!
metadata: { ...vector.metadata, url: doc.newUrl, title: doc.newTitle }
}));
await env.BLOG_VECTORIZE.upsert(upsertVectors);
}
}
这种方式的关键是getByIds + upsert:先读取现有向量,保持values不变,只更新metadata。需要注意,平台如果不能直接patch metadata,就可能需要对受影响chunks重新embedding并记录metadataReembeddedChunkCount。因此更严谨的表述是:metadata-only路径的目标是避免全文重建,但在部分平台条件下仍可能产生embedding成本。
增量索引闭环流程
完整的增量索引闭环可以拆成七步:
| 步骤 | 动作 | 阻断条件 |
|---|---|---|
| 1 | 从公开dist导出corpus | 导出失败或包含非公开内容 |
| 2 | 本地dry-run计算diff | 变更数量超过阈值 |
| 3 | Worker authoritative dry-run | chunk数、batch数或dryRunHash异常 |
| 4 | 人工确认高风险变更 | 删除量、重建量或成本预估异常 |
| 5 | apply ingest | payload与dryRunHash不匹配 |
| 6 | 写入Vectorize与D1 | 任一写入失败则不更新finalized stats |
| 7 | verify manifest/stats/evaluation | 统计、来源引用或固定评估集失败 |
分块边界:为什么这件事要在架构层先冻结
分块不是一个随手调整的常量
分块参数看起来像实现细节,但它实际上决定了三个架构面的成本:
- 索引成本:chunk越小,embedding批次和向量数量越多。
- 检索噪音:chunk越大,单个结果越容易混入多个主题。
- 提示词预算:召回结果最终要进入模型上下文,chunk过长会直接挤压可用证据数量。
因此,架构篇至少要先冻结一个可验证的起点。本项目当前设计基线是chunkSize=800、overlap=200。它适合个人知识库语料,但不应被当作跨语料通用最优值。
需要冻结的不是一个神奇数字,而是一套验证闭环
真正需要在架构层先说清楚的,是下面这些不变量:
- 本地dry-run和Worker authoritative dry-run应共享同一套分块边界规则。
- 稳定chunk ID和
documentHash应建立在相同的归一化结果之上。 - 任何分块参数变更都不应只看索引规模,还要回到固定评估集验证召回质量。
换句话说,架构篇负责定义**“分块属于什么级别的设计决策”**,而不是在这里重复边界检测算法。具体的智能边界、重叠率和稳定ID实现,放到第二篇《RAG检索实现深度解析》展开。
公开入口边界:为什么安全默认 fail-closed
第一篇需要回答的安全问题,不是每条规则怎么写,而是:一个公开/chat入口到底要在哪些节点先拒绝请求,避免把错误和成本推进到更贵的下游。
本项目把公开入口冻结为六层 fail-closed 边界:
| 层级 | 作用 | 架构层要先冻结的事实 |
|---|---|---|
| Kill Switch | 紧急关闭公开聊天入口 | 关闭后不继续进入检索、rerank或模型调用 |
| Origin校验 | 限定允许接入的前端来源 | 不合法来源在业务逻辑前直接拒绝 |
| Rate Limit | 限制单用户或单IP刷量 | 计数可用KV等低成本存储,但要接受最终一致性 |
| Daily Budget | 阻止预算被异常流量打穿 | 预算检查应发生在高成本上游调用之前 |
| Circuit Breaker | 降低上游连续失败带来的级联故障 | 熔断状态不宜只放在单实例内存里 |
| Input Validation | 限制消息长度、history和pageContext | 输入边界要在检索前就完成裁剪 |
这六层的详细门限、示例代码和发布门禁,留给第三篇《RAG质量评测与安全防护》。架构篇只冻结一个原则:默认先拒绝,再调用上游。
Telemetry 也属于架构边界
公开问答系统上线后,最容易走偏的一步是把调试便利置于隐私边界之上。这里需要提前冻结最小原则:
- 允许记录:脱敏后的请求ID、sessionHash、ipHash、状态、延迟、检索数量、indexEpoch
- 禁止记录:原始用户问题、原始页面内容、完整回答、原始IP、embedding向量
如果这条边界没有先冻结,后续的debug report、评估日志和运营报表都会不断诱惑系统把更多原始数据写进日志。
总结与展望
关键设计决策回顾
- 边缘计算架构:减少自建服务运维面,并把请求入口放到边缘运行时
- 三存储分离:Vectorize/D1/KV各司其职,避免把检索数据、管理状态和运行时计数混在一起
- 混合检索:语义+关键词+精排三层架构,兼顾召回和精度
- 增量更新:五状态分类+双层Dry-Run,降低小改动触发全量重建的风险
- 分层安全:六层防护+隐私保护,降低公开入口风险
上线前应记录的指标
本文不使用未公开的生产指标证明系统表现。真正上线前,至少需要记录这些口径:
| 指标 | 为什么重要 | 建议记录方式 |
|---|---|---|
| 响应时间分位数 | 判断边缘入口、检索、rerank和LLM调用的尾延迟 | 按请求ID记录脱敏telemetry |
| 检索命中率 | 判断Vectorize、FTS5和RRF是否有效 | 固定评估集和debug report |
| no-answer比例 | 发现召回不足或阈值过严 | 按类别统计,不保存用户原文 |
| daily budget消耗 | 防止公开入口刷量击穿预算 | KV/D1计数加告警 |
| citation accuracy | 防止回答引用错误来源 | 固定评估集和抽样复核 |
未来演进方向
1. 多语言支持
当前系统支持中英文混合检索,但英文术语、中文短语、slug和模型名的匹配需求并不相同。后续可以按语料重新评估trigram、unicode61、别名表和规则意图之间的组合,而不是只替换一个tokenizer。
2. 用户反馈闭环 目前使用固定评估集验证质量,未来可以引入经过脱敏和同意边界约束的用户反馈,把它作为检索排名调整和人工复核的输入信号。
3. 权限感知检索
对于企业场景,可以扩展tenantId、userId等字段,作为权限过滤的基础条件;真正上线还需要鉴权、策略同步和审计日志配套。
4. 自动评估 引入LLM-as-Judge作为二级抽查和趋势分析信号,但首阶段发布门禁仍应以Rule-Based Evaluation和固定评估集为准。
读者下一步:
如果你要复用这套架构,建议按这个顺序落地:
- 先确定索引边界:只从公开
dist导出内容,不直接索引草稿或私有Markdown。 - 再搭建最小Vectorize-only链路,用固定问题验证基础召回。
- 接着补D1 FTS5和RRF,专门测试错误码、模型名和数字配置问题。
- 最后接入增量索引dry-run、
dryRunHash和发布前评估集,不要直接让全量重建进入常规发布流程。
如果你还没做分块算法、当前页总结和安全闸门的细节实现,继续读后两篇比在第一篇里抄参数更有价值:第二篇负责把检索链路拆到代码级,第三篇负责把质量和发布门禁变成可审计证据。
参考资料:
- Cloudflare Workers文档
- Cloudflare Vectorize文档
- Cloudflare D1文档
- Cloudflare Workers KV文档
- SQLite FTS5文档
- RRF融合算法论文
系列文章:
实现参考:本文以作者的RAG Worker设计为背景整理;具体仓库、部署配置和模型服务可能随项目演进调整。
Reading path
继续沿这条专题路径阅读
按推荐顺序继续阅读 AI 工程化实践 相关内容,而不是只看同专题的随机文章。
Next step
继续深入这个专题
如果这篇内容对你有帮助,下一步可以回到专题页继续系统阅读,或者订阅后续更新。
正在加载评论...
评论与讨论
使用 GitHub 账号登录参与讨论,评论将同步至 GitHub Discussions