Article
RAG检索实现深度解析:分块策略、混合检索与意图识别
以个人知识库RAG Worker为例,说明800字符分块、200字符重叠、RRF混合检索、规则意图识别和当前页总结的实现边界。
引言:检索质量会限制RAG效果
上一篇文章我们讨论了RAG系统的整体架构设计。本篇文章进入代码实现层面,讨论三个核心技术问题:如何尽量把长文档切成语义完整的片段、如何融合语义检索和关键词检索的优势、如何用可解释规则处理高价值用户意图。
在个人知识库问答场景中,很多失败不是模型不会回答,而是检索阶段把错误上下文交给了模型。分块太大,检索结果会带入无关段落;只依赖向量检索,429、text-embedding-v4、chunkSize=800这类精确信号容易丢失;不区分”全站搜索”和”当前页总结”,回答就会从单页总结漂移成全站综述。
这些问题都落在实现细节上。本文以Milome RAG Worker的设计基线为例,说明800字符分块、200字符重叠、RRF融合和规则意图识别如何协作。它不是通用最优配置,而是一套适合个人知识库语料的起点。
系列位置:第一篇解释系统架构和索引闭环;本文解释检索链路;第三篇解释质量评测、安全门禁和发布前审计。
先把检索链路放在一张图里:
图1:RAG检索实现链路。它把“索引期怎么切、查询期怎么召回、当前页总结怎么分流、rerank失败怎么降级”压缩到一条可复查的实现路径里。
分块策略:800字符的选择依据
分块粒度对检索质量的影响
在RAG系统中,分块(chunking)是索引阶段的关键步骤。分块粒度直接影响三个指标:检索精度、存储成本和上下文完整性。
400字符的问题:语义碎片化——一个完整的代码示例或技术解释可能被切分成多个不完整的片段,导致检索时丢失上下文。
1600字符的问题:主题混杂——一个chunk可能同时包含多个主题的讨论,降低检索相关性。
800字符的优势:通常足够容纳一个完整段落或短代码示例,又能降低主题混杂风险。这是本项目设计基线,不是所有语料的固定最佳值。
本项目同时使用200字符重叠,约为chunk size的25%。如果语料中代码块更长、表格更多或文章更短,应通过固定评估集重新验证。
智能边界检测算法
简单按固定字符数截断会破坏语义完整性。我实现了基于优先级的智能边界检测:
const CHUNK_SIZE = 800;
const OVERLAP_SIZE = 200; // 25%重叠,保持跨块连续性
function findChunkEnd(text: string, start: number, preferredEnd: number): number {
// 在preferredEnd之后额外查找100字符,给边界检测留余量
const window = text.slice(start, preferredEnd + 100);
// 按优先级尝试各种边界
const candidates = [
"\n\n", // 1. 段落边界(最优先)
"\n#", // 2. 标题边界
"```", // 3. 代码块边界
"。", // 4. 中文句号
". ", // 5. 英文句号+空格
"\n", // 6. 换行
];
for (const marker of candidates) {
const index = window.lastIndexOf(marker);
// 只接受相对靠后的边界,避免生成过短chunk
if (index > CHUNK_SIZE * 0.5) {
return start + index + marker.length;
}
}
// 没有找到合适的边界,硬截断
return preferredEnd;
}
边界优先级设计思路:
- 段落边界(
\n\n):最自然的语义分割点 - 标题边界(
\n#):技术文档中章节切换的明确标记 - 代码块边界(代码围栏标记):保持代码完整性,避免在函数中间截断
- 句子边界(
。、.):自然语言的自然停顿点 - 换行边界(
\n):最后的fallback
25%重叠率的必要性
重叠(overlap)是指相邻chunk共享的内容。我使用200字符重叠(占800字符的25%):
function buildChunks(text: string, documentId: string): Chunk[] {
const chunks: Chunk[] = [];
let start = 0;
let chunkIndex = 0;
while (start < text.length) {
const end = findChunkEnd(text, start, start + CHUNK_SIZE);
const chunkText = text.slice(start, end);
if (!chunkText.trim()) break;
chunks.push({
id: createChunkId(documentId, chunkIndex),
text: chunkText,
// ... metadata
});
if (end >= text.length) break;
// 下一个chunk向前回退200字符,降低跨块上下文丢失概率。
// Math.max防止短文本或异常边界导致start不前进。
const nextStart = Math.max(0, end - OVERLAP_SIZE);
start = nextStart > start ? nextStart : end;
chunkIndex++;
}
return chunks;
}
重叠的必要性:
假设有一段技术解释横跨两个chunk的边界:
Chunk 1: "...配置batch size时需要考虑内存限制"
Chunk 2: "限制和显存容量。具体数值取决于..."
如果没有重叠,用户查询”batch size应该配置多少”时,可能会同时命中这两个chunk,但由于各自只包含片段信息,语义不完整。
有了重叠,检索到的chunk会包含完整上下文:
Chunk 1: "...配置batch size时需要考虑内存限制和显存容量。"
Chunk 2: "内存限制和显存容量。具体数值取决于..."
稳定Chunk ID生成
增量更新的关键是需要稳定ID——同一文档内容不变时,重新索引应生成相同的chunk ID。
export function createChunkId(documentId: string, chunkIndex: number): string {
return `${fnv1a64(documentId)}:${chunkIndex}`;
}
function fnv1a64(value: string): string {
// FNV-1a 64bit哈希,确定性输出
let hash = 0xcbf29ce484222325n;
for (const char of value) {
hash ^= BigInt(char.charCodeAt(0));
hash = BigInt.asUintN(64, hash * 0x100000001b3n);
}
return hash.toString(16).padStart(16, "0");
}
FNV-1a的选择依据和边界:
- 实现简单:适合为短
documentId生成稳定前缀 - 确定性:同一实现下,相同输入会产生稳定输出
- 边界清楚:它不是安全哈希;如果chunk ID碰撞不可接受,应改用更强的哈希或把完整
documentId纳入ID
混合检索实现:从理论到代码
RRF融合算法的说明性实现
上一篇文章介绍了RRF(Reciprocal Rank Fusion)的理论。下面的代码展示核心逻辑:用排名而不是原始分数融合Vectorize和D1 FTS5结果。实际Worker中还需要补充日志、错误码、指标和输入校验。
interface RetrievedChunk {
id: string;
score: number;
documentId: string;
text: string;
title: string;
url: string;
vectorRank?: number; // 向量检索排名
keywordRank?: number; // 关键词检索排名
vectorScore?: number; // 向量检索分数
bm25Score?: number; // BM25分数
fusionScore?: number; // RRF融合分数
retrievalSources: ("vector" | "keyword" | "page-context")[];
}
export function fuseResults(
vectorChunks: RetrievedChunk[],
keywordChunks: RetrievedChunk[],
finalTopK: number = 24,
maxChunksPerDocument: number = 2
): RetrievedChunk[] {
const RRF_K = 60;
const VECTOR_WEIGHT = 1.0;
const KEYWORD_WEIGHT = 0.8;
// 使用Map去重,key为chunk ID
const candidates = new Map<string, RetrievedChunk>();
// 添加向量检索结果
vectorChunks.forEach((chunk, index) => {
const rank = index + 1;
const fusionScore = VECTOR_WEIGHT / (RRF_K + rank);
candidates.set(chunk.id, {
...chunk,
fusionScore,
vectorRank: rank,
vectorScore: chunk.score,
retrievalSources: ["vector"]
});
});
// 添加关键词检索结果
keywordChunks.forEach((chunk, index) => {
const rank = index + 1;
const existing = candidates.get(chunk.id);
if (existing) {
// 同一chunk被两个来源命中,融合分数
existing.fusionScore = (existing.fusionScore ?? 0)
+ KEYWORD_WEIGHT / (RRF_K + rank);
existing.keywordRank = rank;
existing.bm25Score = chunk.bm25Score ?? chunk.score;
existing.retrievalSources = [...new Set([...existing.retrievalSources, "keyword"])];
} else {
candidates.set(chunk.id, {
...chunk,
fusionScore: KEYWORD_WEIGHT / (RRF_K + rank),
keywordRank: rank,
bm25Score: chunk.bm25Score ?? chunk.score,
retrievalSources: ["keyword"]
});
}
});
// 按融合分数排序
const sorted = [...candidates.values()]
.sort((a, b) => (b.fusionScore ?? 0) - (a.fusionScore ?? 0))
.slice(0, finalTopK);
// 应用每文档限额
return limitChunksPerDocument(sorted, maxChunksPerDocument);
}
每文档限额的实现
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;
});
}
每文档限额的考虑:
考虑一个场景:用户问”RAG优化技巧”,个人知识库中有一篇很长的”RAG专题长文”。如果不加限制,检索结果可能被该文章的多个chunk填满:
Result 1: [RAG专题长文 - Chunk 3] 如何分块
Result 2: [RAG专题长文 - Chunk 5] 混合检索
Result 3: [RAG专题长文 - Chunk 7] 向量数据库选择
Result 4: [RAG专题长文 - Chunk 9] 评估指标
...
这会导致两个问题:
- 结果多样性不足:用户只看到一个来源的信息
- 上下文冗余:同一篇文章的多个chunk往往内容重复
通过限制每文档2个chunk,可以提升结果多样性:
Result 1: [RAG专题长文 - Chunk 3] 如何分块
Result 2: [RAG专题长文 - Chunk 5] 混合检索
Result 3: [向量检索实战] 索引构建方法
Result 4: [D1数据库使用] FTS5全文检索配置
...
限额值不是越小越好。对短站点可以设为2;对大型专题页,可能需要允许3到4个chunk。最终值应由固定评估集决定。
关键词检索的FTS5查询构造
D1使用SQLite FTS5进行关键词检索。查询构造需要特殊处理:
function buildFtsQuery(query: string): string {
return query
// 按非字母数字字符分隔
.split(/[^\p{L}\p{N}_:-]+/u)
.map(token => token.trim())
// 过滤太短或太长的token
.filter(token => token.length >= 2 && token.length <= 64)
// 最多取12个关键词
.slice(0, 12)
// 转义双引号,防止FTS语法错误
.map(token => `"${token.replace(/"/g, '""')}"`)
// 用OR连接,提高召回率
.join(" OR ");
}
FTS5查询构造示例:
用户输入”Cloudflare 429错误”,系统会将其转换为FTS5查询:"Cloudflare" OR "429" OR "错误"。
用户输入”text-embedding-v4怎么用”,转换为:"text-embedding-v4" OR "怎么用"。
用户输入”batch size设置多大”,转换为:"batch" OR "size" OR "设置" OR "多大"。
OR连接的优势:AND会严格匹配所有关键词,导致召回率过低。OR虽然可能返回一些不相关结果,但通过RRF融合和精排可以过滤掉。
意图识别:让系统理解用户想要什么
规则引擎vs机器学习
在设计意图识别时,我考虑过两个方案:
规则引擎方案:可预测、可解释、无外部依赖,但维护成本高、难以覆盖所有场景。
机器学习方案:泛化能力强、自动学习,但需要训练数据、推理延迟高、边缘环境受限。
最终选择规则引擎,原因有三:个人知识库场景有限、边缘计算资源受限、可解释性要求高。规则引擎的缺点也明确:覆盖范围有限,新增站点入口或专题页时需要维护pattern和测试用例。
意图模式定义
interface IntentPattern {
id: string;
// 匹配正则,支持中文和英文
patterns: RegExp[];
// 对应的文档ID(支持多语言)
documentIds: {
zh: string[];
en: string[];
};
// 优先级,高优先级先匹配
priority: number;
}
const INTENT_PATTERNS: IntentPattern[] = [
{
id: "site-topics-overview",
patterns: [
/(网站|本站|个人知识库|知识库).{0,12}(覆盖|主要|哪些|什么|内容|技术).{0,12}(主题|专题|方向|领域|内容)/u,
/(技术主题|技术专题|内容方向|主要覆盖|覆盖哪些|有哪些主题)/u,
/\b(what|which)\b.{0,30}\b(site|website|blog)\b.{0,30}\b(topics?|themes?)\b/i,
],
documentIds: { zh: ["topics"], en: ["en/topics"] },
priority: 1
},
{
id: "contact-collaboration",
patterns: [
/(合作|联系|沟通|邮箱|邮件|contact|collaborat|reach|message)/i,
],
documentIds: {
zh: ["contact", "about"],
en: ["en/contact", "en/about"]
},
priority: 2
},
{
id: "about-me",
patterns: [
/(关于我|关于\s*hualin|介绍.*hualin|背景|经历|about|profile|who is)/i,
],
documentIds: {
zh: ["about"],
en: ["en/about"]
},
priority: 3
},
{
id: "current-page-summary",
patterns: [
/(总结当前页面|总结本页|当前页面总结|summari[sz]e (this|current) page)/i,
],
documentIds: { zh: [], en: [] }, // 特殊处理
priority: 0 // 最高优先级
}
];
意图解析函数
export function resolveIntentDocumentIds(message: string): string[] {
const normalized = message.toLowerCase();
const patterns = [...INTENT_PATTERNS].sort((a, b) => a.priority - b.priority);
// 检测语言
const isEnglish = /[a-z]/i.test(message) &&
!/\p{Script=Han}/u.test(message);
// 按优先级排序匹配
for (const intent of patterns) {
for (const pattern of intent.patterns) {
if (pattern.test(normalized)) {
// 优先返回对应语言文档ID,再追加另一语种作为补充候选
return isEnglish
? [...intent.documentIds.en, ...intent.documentIds.zh]
: [...intent.documentIds.zh, ...intent.documentIds.en];
}
}
}
return []; // 无匹配
}
当前页总结的检测逻辑
function isCurrentPageSummaryRequest(
message: string,
pageContext?: PageContext
): boolean {
// 通过pageContext.intent显式标记
if (pageContext?.intent === "current-page-summary") return true;
// 通过消息内容匹配
return /(总结当前页面|总结本页|当前页面总结|summari[sz]e (this|current) page)/i.test(message);
}
当前页总结:限定Scope的实现
问题背景
“当前页总结”和”全站搜索”是两种截然不同的使用场景:
- 全站搜索:用户想了解某个主题的所有相关内容
- 当前页总结:用户想快速了解当前正在阅读的文章
如果不做区分,系统会将”当前页总结”当作普通查询处理,导致:
- 回答跑题:总结变成了全站相关内容汇总
- 来源混杂:引用了与当前页无关的其他文章
- 用户体验差:用户期待的是单页总结,得到的却是”知识库综述”
实现策略
1. URL匹配优先
async function retrieveCurrentPageChunks(
env: Env,
pageContext: PageContext,
indexEpoch: string
): Promise<RetrievedChunk[]> {
const url = pageContext?.url?.trim();
if (!url) return [];
// 从URL解析documentId
const documentId = await resolveDocumentIdByUrl(env, url, indexEpoch);
if (documentId) {
// 找到了当前页对应的文档,只检索该文档的chunks
return retrieveIntentChunks(env, [documentId], indexEpoch);
}
// 降级:使用pageContext.content作为临时chunk
return createContextFallbackChunks(pageContext);
}
2. URL解析策略
async function resolveDocumentIdByUrl(
env: Env,
rawUrl: string,
indexEpoch: string
): Promise<string | null> {
// 尝试多种URL变体
const urlCandidates = [
rawUrl,
rawUrl.replace("https://hlluan.com", "https://www.hlluan.com"),
rawUrl.replace("https://www.hlluan.com", "https://hlluan.com"),
];
const placeholders = urlCandidates.map(() => "?").join(",");
const row = await env.RAG_DB.prepare(`
SELECT document_id
FROM rag_document_manifest
WHERE index_strategy = 'stable-v1'
AND index_epoch = ?
AND status = 'active'
AND url IN (${placeholders})
LIMIT 1
`).bind(indexEpoch, ...urlCandidates).first<{document_id: string}>();
return row?.document_id ?? null;
}
3. Fallback机制
如果当前页不在corpus中(例如是新发布的文章),使用pageContext.content作为临时chunk:
function createContextFallbackChunks(
pageContext: PageContext
): RetrievedChunk[] {
const content = pageContext.content?.trim();
if (!content) return [];
return [{
id: "current-page-context",
text: content.slice(0, 6000), // 限制长度
title: pageContext.title || "Current page",
url: pageContext.url,
retrievalSources: ["page-context"],
// ... other fields
}];
}
Prompt差异化处理
对于当前页总结,prompt中强调scope限制:
function buildPrompt(
message: string,
chunks: RetrievedChunk[],
currentPageOnly: boolean
): string {
const context = chunks.map(c => `[${c.title}]\n${c.text}`).join("\n\n");
return `
用户问题:${message}
检索到的内容:
${context}
${currentPageOnly ? "当前问题是总结当前页面:回答范围限定在当前页面上下文和当前页面检索块内,不要扩展为全站总结;引用来源应只来自当前页面。" : ""}
回答要求:
- ${currentPageOnly ? "基于当前页面的内容回答" : "基于检索到的内容回答"}
- 如果证据不足,说明未找到充分依据
- 在相关句子后引用 [source-n]
`;
}
性能优化与降级策略
并行检索优化
混合检索的向量检索和关键词检索是独立的,可以并行执行:
const [vectorResult, keywordResult] = await Promise.allSettled([
retrieveVectorChunks(env, embedding, vectorTopK),
retrieveKeywordChunks(env, query, keywordTopK),
]);
const vectorChunks = vectorResult.status === "fulfilled"
? vectorResult.value
: [];
const keywordChunks = keywordResult.status === "fulfilled"
? keywordResult.value
: [];
Promise.allSettled的优势:
因为如果其中一个检索失败(如FTS5语法错误),不应该阻塞整个请求。失败的检索返回空数组,降级为单一检索模式。
降级策略矩阵
| 失败点 | 降级行为 | 用户侧原则 |
|---|---|---|
| D1 FTS失败 | 使用Vectorize-only检索 | 不把FTS失败暴露为500,记录keyword分支失败 |
| Vectorize失败 | 使用D1 keyword-only检索 | 只回答证据足够的问题 |
| 两者都失败 | 返回空证据并触发no_answer | 明确说明知识库未找到依据 |
| Rerank超时 | 使用RRF排序结果 | 不让精排成为可用性单点 |
本项目设计中,rerank超时示例为3秒。实际阈值要结合模型服务、用户体验和成本预算设置。
Rerank精排的超时控制
async function rerankChunks(
query: string,
chunks: RetrievedChunk[],
env: Env
): Promise<RetrievedChunk[]> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000); // 3秒超时
try {
const response = await fetch(`${env.DASHSCOPE_BASE_URL}/rerank`, {
method: "POST",
headers: {
"Authorization": `Bearer ${env.DASHSCOPE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "qwen3-rerank",
query,
documents: chunks.map(c => c.text.slice(0, 1200)),
top_n: 6,
}),
signal: controller.signal,
});
if (!response.ok) throw new Error("Rerank failed");
// 根据rerank结果重排序
const results = await response.json();
return reorderChunksByRerank(chunks, results);
} catch (error) {
// 超时或失败,降级为RRF排序
console.warn("Rerank failed, falling back to RRF:", error);
return chunks.slice(0, 6);
} finally {
clearTimeout(timeout);
}
}
测试与验证
分块测试
// 测试智能边界检测
const testText = `
配置说明:
首先设置环境变量。
代码示例:
\`\`\`typescript
const config = {
chunkSize: 800,
overlapSize: 200
};
\`\``
// 期望:在代码块结束处分块,而不是在中间截断
const chunks = buildChunks(testText, "doc-001");
console.log(chunks[0].text); // 应该包含完整的代码块
混合检索测试
语义问题测试:查询”如何解决限流”,预期向量检索主导结果。
精确匹配测试:查询”429错误”,预期关键词检索主导结果。
混合问题测试:查询”batch size配置”,预期RRF融合两类检索结果。
无结果测试:查询”不存在的主题”,预期触发no_answer响应。
意图识别测试
// 测试用例
test("site-topics-overview", () => {
expect(resolveIntentDocumentIds("网站有哪些技术主题"))
.toEqual(["topics", "en/topics"]);
});
test("contact-collaboration", () => {
expect(resolveIntentDocumentIds("如何联系合作"))
.toEqual(["contact", "about", "en/contact", "en/about"]);
});
test("current-page-summary", () => {
expect(isCurrentPageSummaryRequest("总结当前页面", { intent: "current-page-summary" }))
.toBe(true);
});
总结
本文把RAG检索链路拆成三个实现环节:
1. 分块策略
- 800字符是本项目的设计基线,用来平衡语义完整性和存储效率
- 智能边界检测优先选择段落、标题、代码块等自然边界
- 25%重叠率用于降低跨块上下文丢失概率,仍需要固定评估集校验
- 稳定Chunk ID支持幂等更新
2. 混合检索
- RRF融合消除向量分数和BM25分数的尺度差异
- 每文档限额提升检索结果的来源多样性
- 并行执行优化响应时间
- 多层降级降低单个检索分支失败对服务可用性的影响
3. 意图识别
- 规则引擎适合先覆盖个人知识库的高价值入口,但需要随站点导航和专题变化维护pattern
- 语言感知优先返回对应语种的内容
- 当前页总结通过URL匹配和scope限制,把回答范围约束在当前页面证据内
这些技术细节的打磨,决定了RAG能否从”能返回内容”逐步接近”回答有依据、失败可诊断”。
读者下一步:
- 用10到20个站内代表性问题建立最小评估集。
- 分别跑Vectorize-only、FTS5-only和RRF融合,记录每类问题的来源命中。
- 调整
chunkSize、overlap、maxChunksPerDocument和RRF权重时,每次只改一个变量。 - 把当前页总结作为单独测试场景,不要混在全站搜索评估里。
实现参考:本文代码片段用于说明检索链路,不承诺与任何公开仓库当前版本逐行一致。落地时应以项目内Worker源码、测试和部署配置为准。
参考资料:
- Cloudflare Vectorize API文档
- Cloudflare Vectorize Metadata Filtering文档
- SQLite FTS5文档
- Reciprocal Rank Fusion论文
系列文章:
Reading path
继续沿这条专题路径阅读
按推荐顺序继续阅读 AI 工程化实践 相关内容,而不是只看同专题的随机文章。
Next step
继续深入这个专题
如果这篇内容对你有帮助,下一步可以回到专题页继续系统阅读,或者订阅后续更新。
正在加载评论...
评论与讨论
使用 GitHub 账号登录参与讨论,评论将同步至 GitHub Discussions