Hualin Luan Cloud Native · Quant Trading · AI Engineering
返回文章

Article

RAG检索实现深度解析:分块策略、混合检索与意图识别

以个人知识库RAG Worker为例,说明800字符分块、200字符重叠、RRF混合检索、规则意图识别和当前页总结的实现边界。

Meta

Published

2026/5/29

Category

guide

Reading Time

约 18 分钟阅读

引言:检索质量会限制RAG效果

上一篇文章我们讨论了RAG系统的整体架构设计。本篇文章进入代码实现层面,讨论三个核心技术问题:如何尽量把长文档切成语义完整的片段如何融合语义检索和关键词检索的优势如何用可解释规则处理高价值用户意图

在个人知识库问答场景中,很多失败不是模型不会回答,而是检索阶段把错误上下文交给了模型。分块太大,检索结果会带入无关段落;只依赖向量检索,429text-embedding-v4chunkSize=800这类精确信号容易丢失;不区分”全站搜索”和”当前页总结”,回答就会从单页总结漂移成全站综述。

这些问题都落在实现细节上。本文以Milome RAG Worker的设计基线为例,说明800字符分块、200字符重叠、RRF融合和规则意图识别如何协作。它不是通用最优配置,而是一套适合个人知识库语料的起点。

系列位置:第一篇解释系统架构和索引闭环;本文解释检索链路;第三篇解释质量评测、安全门禁和发布前审计。

先把检索链路放在一张图里:

RAG检索实现链路示意图,展示公开HTML分块、稳定chunk ID、意图识别、当前页总结分支、Vectorize与D1 FTS5并行召回、RRF融合、rerank和回答回退路径。

图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;
}

边界优先级设计思路

  1. 段落边界(\n\n:最自然的语义分割点
  2. 标题边界(\n#:技术文档中章节切换的明确标记
  3. 代码块边界(代码围栏标记):保持代码完整性,避免在函数中间截断
  4. 句子边界(. :自然语言的自然停顿点
  5. 换行边界(\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] 评估指标
...

这会导致两个问题:

  1. 结果多样性不足:用户只看到一个来源的信息
  2. 上下文冗余:同一篇文章的多个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. 回答跑题:总结变成了全站相关内容汇总
  2. 来源混杂:引用了与当前页无关的其他文章
  3. 用户体验差:用户期待的是单页总结,得到的却是”知识库综述”

实现策略

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能否从”能返回内容”逐步接近”回答有依据、失败可诊断”。


读者下一步

  1. 用10到20个站内代表性问题建立最小评估集。
  2. 分别跑Vectorize-only、FTS5-only和RRF融合,记录每类问题的来源命中。
  3. 调整chunkSizeoverlapmaxChunksPerDocument和RRF权重时,每次只改一个变量。
  4. 把当前页总结作为单独测试场景,不要混在全站搜索评估里。

实现参考:本文代码片段用于说明检索链路,不承诺与任何公开仓库当前版本逐行一致。落地时应以项目内Worker源码、测试和部署配置为准。

参考资料

系列文章

Reading path

继续沿这条专题路径阅读

按推荐顺序继续阅读 AI 工程化实践 相关内容,而不是只看同专题的随机文章。

查看完整专题路径 →

Next step

继续深入这个专题

如果这篇内容对你有帮助,下一步可以回到专题页继续系统阅读,或者订阅后续更新。

返回专题页 订阅 RSS 更新

RSS Subscribe

订阅更新

通过 RSS 阅读器订阅获取最新文章推送,无需频繁访问网站。

推荐使用 FollowFeedlyInoreader 等 RSS 阅读器

评论与讨论

使用 GitHub 账号登录参与讨论,评论将同步至 GitHub Discussions

正在加载评论...