RAG 语义检索改造前端组件文档站:从 52% 到 93% 召回率的实战记录

3 阅读11分钟

RAG 语义检索改造前端组件文档站:从 52% 到 93% 召回率的实战记录

组件库文档搜不出东西,这个痛点你大概率体验过。我们团队维护着一套 280+ 组件的内部 UI 库,Vue 3 + Vite 构建,文档站跑在 VitePress 上。组件数量膨胀到这个量级之后,左侧导航栏已经叠了五层折叠菜单,新人入职第一周最高频的问题不是"这个组件怎么用",而是"这个组件到底叫什么"。

VitePress 自带的 minisearch 做的是全文检索——输入"表格行合并"搜不到 TableMergeCell,输入"上传进度条"搜不到 FileUploaderprogress 属性。关键词搜索和语义搜索,本质上是两个物种。

我们的改造方向是给文档站接入 RAG(Retrieval-Augmented Generation),用向量数据库做语义检索,再把召回的文档片段喂给 LLM 生成精准回答。思路不复杂,但从 PoC 跑通到真正上线,踩了五个大坑,每个都值得单独记一笔。

坑一:Markdown 切片策略远比想象中复杂

按文档结构做语义切片

我们最终写了一个针对组件文档的 AST 级切片器,核心思路是按 Markdown 标题层级切分,同时保留上下文链路。

interface DocChunk {
  content: string
  metadata: {
    component: string       // "DateRangePicker"
    section: string         // "Props"
    heading_chain: string[] // ["DateRangePicker", "Props"]
    doc_url: string
  }
}

function chunkComponentDoc(markdown: string, componentName: string): DocChunk[] {
  const ast = parseMarkdownToAST(markdown)
  const chunks: DocChunk[] = []
  const sections = splitByHeadingLevel(ast, 2)

  for (const section of sections) {
    const sectionTitle = extractHeadingText(section)

    if (isAPITable(section)) {
      // Props/Events 表格:按行拆,每行一个 chunk
      for (const row of extractTableRows(section)) {
        chunks.push({
          content: `${componentName} 组件的 ${sectionTitle}${row.name} - ${row.description},类型为 ${row.type},默认值 ${row.default}`,
          metadata: { component: componentName, section: sectionTitle,
            heading_chain: [componentName, sectionTitle, row.name],
            doc_url: `/${componentName}#${sectionTitle.toLowerCase()}` }
        })
      }
    } else {
      // 普通段落:保持完整
      chunks.push({
        content: `${componentName} - ${sectionTitle}${extractText(section)}`,
        metadata: { component: componentName, section: sectionTitle,
          heading_chain: [componentName, sectionTitle],
          doc_url: `/${componentName}` }
      })
    }
  }
  return chunks
}

这里有个不太直觉的细节:我们把组件名和章节标题直接拼进了 content 字段,而不是只放在 metadata 里。为什么?因为向量化只看 content,metadata 不参与 Embedding 计算。 如果 content 只写"绑定值,类型为 [Date, Date]",在向量空间里它和"日期范围选择器"根本关联不上。

切片粒度的权衡

这里存在经典的 trade-off。切太粗(一个组件一个 chunk),召回了但噪声太多,塞不进 context window;切太细(每行一个 chunk),单个 chunk 语义不完整,Embedding 质量差。我们最终的策略是 API 表格按行拆(每行约 30-80 token),正文段落按 h2/h3 拆(每段约 200-600 token),代码示例保持完整不截断。280 个组件的文档最终被切成了约 12000 个 chunk,平均每个组件 43 个。

坑二:Embedding 模型选择直接决定天花板

通用模型的瓶颈

一开始用 OpenAI 的 text-embedding-ada-002,Top-5 召回率大概 62%。换成 text-embedding-3-small 后升到 68%,但瓶颈已经很明显——通用模型对前端领域术语的理解有限。看几个真实的 bad case:

用户查询期望召回实际召回
表格行合并TablespanMethodMergeCell 组件(不存在)
弹窗关闭回调Dialogclosed 事件Modalvisible 属性
下拉菜单搜索过滤SelectfilterableDropdowntrigger

问题在于,通用模型把"表格行合并"映射到的向量,跟文档中"通过 spanMethod 函数控制行列合并逻辑"映射到的向量,余弦相似度不够高。领域知识的缺失,靠调参是补不回来的。

对比学习微调

我们用了一个相对轻量的方案:收集约 2000 条 <query, positive_chunk, negative_chunk> 三元组,对 bge-base-zh-v1.5 做对比学习微调。

训练数据有两个来源。一是历史搜索日志——VitePress 搜索框的埋点记录了用户输入了什么、最终点击了哪篇文档,这些天然构成正负样本对。二是用 GPT-4 批量合成查询:让它扮演"不太熟悉组件库的开发者",根据每个 chunk 的内容生成三种可能的提问方式。同一个 disabledDate 属性,GPT-4 会生成"怎么禁用某些日期""日期选择器灰色不可选""disabled date picker specific dates"三种不同表述。这些合成数据对提升覆盖面很关键。

hard negative 的选取也有讲究:同组件不同章节、或者不同组件但功能相似的 chunk,比随机负样本更能帮模型学到细粒度的区分能力。微调之后 Top-5 召回率从 68% 跳到了 81%,提升 13 个百分点。

坑三:纯向量检索不够,混合检索才是生产级方案

向量搜索的盲区

向量检索擅长语义模糊匹配,但对精确关键词反而不敏感。用户搜 v-model,你希望召回所有提到双向绑定的 chunk,但向量空间里这个 token 的语义权重可能被周围词稀释掉。

更典型的一个例子:搜 DatePicker 和搜 DateRangePicker,向量空间里余弦相似度高达 0.92,但它们是两个完全不同的组件。

混合检索:向量 + BM25 + 业务规则

解决思路是把向量检索和传统的 BM25 关键词检索结合起来,再叠加一层业务规则加权。

RRF 的核心思想很优雅:不关心绝对分数,只关心排名。 某个 chunk 在向量检索中排第 1、在 BM25 中排第 3,融合得分就是 1/(60+1) + 1/(60+3) = 0.0323。这避免了两种检索方式分数量纲不同的问题。

融合之后再做业务规则加权:Props/API 类 chunk 加 0.15 的 boost(用户大概率在找 API 用法),如果查询里明确提到了组件名且完全匹配,再加 0.3。

async function hybridSearch(query: string, topK = 10): Promise<DocChunk[]> {
  // 向量检索 + BM25 并发执行
  const [vectorResults, bm25Results] = await Promise.all([
    milvus.search({
      collection: 'component_docs',
      vector: await embed(query),
      topK: topK * 2,
      metric_type: 'COSINE'
    }),
    elasticsearch.search({
      index: 'component_docs',
      query: { bool: { should: [
        { match: { content: query } },
        { term: { 'metadata.component': extractComponentName(query) } }
      ]}}
    })
  ])

  // RRF 融合 + 业务规则加权
  const fused = reciprocalRankFusion(vectorResults, bm25Results, { k: 60 })
  return applyBusinessBoost(fused, query).slice(0, topK)
}

混合检索上线后,Top-5 召回率从 81% 到了 89%。

为什么不干脆只用 BM25?因为用户不会每次都精准输入组件名。"怎么做一个可以拖拽排序的列表"——这句话里没有 DraggableSortListSortable 任何一个关键词,BM25 就无能为力了。但向量检索能把它和文档中"支持拖拽手柄列表项排序"一下关联起来。两者互补,缺一不可。

坑四:前端接入层的工程化问题比算法更耗时间

流式 Markdown 渲染的时序问题

RAG 的响应链路从网络请求到向量检索再到 LLM 生成,端到端延迟 2-5 秒。等全部生成完再渲染,用户体验等于没有,所以必须走 SSE 流式输出。

但流式 Markdown 渲染有个棘手的问题:LLM 吐出来的 token 是碎片化的,前一个 chunk 可能是 ```ts\nconst,下一个是 a = 1\n```。渲染器必须维护一个状态机,追踪当前是否在代码块内部、是否在表格结构中。

核心思路是:每次收到 SSE chunk 就追加到 buffer,按换行符消费完整行,碰到 ``` 就切换代码块状态。代码块内部的不完整行先不渲染,避免 Shiki 语法高亮反复闪烁;普通文本的不完整行可以先输出,让用户看到打字效果。

这个状态机原理不复杂,但实际要处理表格、有序列表、嵌套引用这些多行结构的边界情况。我们在上面花了差不多三天才磨平各种 edge case。

引用溯源:让回答可验证

RAG 生成的回答如果不标注信息来源,信任度会大打折扣。我们的做法是在 system prompt 中要求 LLM 用 [n] 标注引用序号,前端渲染时把这些序号映射成可点击的文档链接。

const systemPrompt = `你是一个前端组件库助手。根据以下文档片段回答用户问题。
回答中引用信息时,用 [n] 标注来源序号。

文档片段:
${retrievedChunks.map((chunk, i) =>
  `[${i + 1}] (${chunk.metadata.component} - ${chunk.metadata.section})\n${chunk.content}`
).join('\n\n')}`

// 前端把 [1] [2] 替换成带跳转的链接
function renderCitation(text: string, chunks: DocChunk[]): string {
  return text.replace(/\[(\d+)\]/g, (_, num) => {
    const chunk = chunks[parseInt(num) - 1]
    if (!chunk) return `[${num}]`
    return `<a href="${chunk.metadata.doc_url}" class="citation-link">[${num}]</a>`
  })
}

用户看到类似"通过 disabledDate 函数实现 [1],支持链式调用 [2]"的回答,点 [1] 直接跳到 DateRangePicker 的 Props 文档。看起来只是锦上添花的小功能,但上线后用户反馈最集中的好评就是这个——"能看到出处,比直接问 ChatGPT 靠谱多了"。

坑五:召回率的最后几个百分点靠 Query 理解

用户查询的意图分类

分析搜索日志后我们发现,用户查询大致分三类,每类适合不同的检索策略。

API 查找类("Table 的 spanMethod 怎么用"):用户知道大概要找什么,BM25 精确匹配最管用,向量权重可以调低。场景探索类("怎么做一个可编辑的表格"):用户描述的是需求而不是 API 名称,这时候语义理解是核心,向量检索权重拉满。问题排查类("DatePicker 选了日期不触发 change"):两者都需要,同时 FAQ 和 Issues 相关的 chunk 要额外加权。

分类器的实现不需要多复杂,正则规则兜底大部分情况,匹配不上的走一次轻量 LLM 判断就行。

Query 扩展

用户搜"表格行合并",只靠这四个字去检索是不够的。我们加了一步 Query Expansion:用 gpt-4o-mini 把原始查询扩展成多个语义等价但表述不同的版本。

async function expandQuery(original: string): Promise<string[]> {
  const expanded = await llm.complete({
    model: 'gpt-4o-mini',
    messages: [{
      role: 'system',
      content: '把用户搜索词扩展成 3 个语义相近但表述不同的查询,输出 JSON 数组。'
    }, { role: 'user', content: original }],
    temperature: 0.2,
    max_tokens: 150
  })
  return [original, ...JSON.parse(expanded)]
}
// "表格行合并" → ["表格行合并", "Table spanMethod 合并单元格",
//   "表格跨行合并 rowspan", "table row merge 行列合并方法"]

四个查询分别做向量检索,结果取并集再 RRF 融合。这一步的代价是多了一次 LLM 调用(约 80ms)和三次并发向量检索(约 50ms),总共多 130ms,完全可以接受。召回率从 89% 涨到了 93%。

各阶段效果汇总

五个坑踩完,Top-5 召回率的变化轨迹:

阶段召回率增幅
基线(纯向量 + ada-002 + 固定切片)52%
换 text-embedding-3-small58%+6%
语义切片(按文档结构拆)68%+10%
微调 Embedding81%+13%
混合检索(向量 + BM25 + 业务规则)89%+8%
Query 理解 + 扩展93%+4%

从 52% 到 93%,差不多翻了一倍。回头看,收益最大的两步是语义切片Embedding 微调,两者加起来贡献了 23 个百分点。

设计权衡:成本和边界在哪

向量数据库的选择

我们用的是 Milvus 自部署,没选 Pinecone 或 Weaviate 的 SaaS。理由很实际:12000 个 chunk 体量很小,Milvus Lite 单机就能扛住,检索 P99 < 30ms;文档涉及内部组件 API,不能传到第三方服务。不过如果你的场景是公开文档库、chunk 不超过 10 万,Pinecone 免费 tier 完全够用,能省不少运维精力。

这套方案不适合什么

对实时性要求极高的场景。 整条链路端到端 2-4 秒延迟,替代不了用户打一个字就出结果的即时搜索。我们的做法是保留原来的 minisearch 做即时搜索,RAG 作为"深度问答"入口并存,两个入口各司其职。

文档频繁变更的场景需要额外投入。 每次文档更新都得重新切片、重新 Embedding、更新向量库。我们接入了 CI/CD 流水线,文档构建时自动触发增量更新——基于文件 hash 判断哪些变了,变了的就删掉旧 chunk、重新切片写入。逻辑不复杂,但 chunk ID 稳定关联、已删除文档的 chunk 清理这些细节,代码量不算小。

从组件文档到平台化

这套架构并不绑死在组件文档上。我们后来把内部 Git commit 规范、API 网关文档、Figma 设计规范的文字说明都接入了同一个向量库,每种文档源各自定制切片策略,检索层和生成层完全复用。

核心抽象就是一个 DocSourceAdapter 接口:定义怎么拉取原始文档、怎么切片、怎么判断增量变更。组件文档一个适配器,OpenAPI 文档一个适配器,Confluence 再一个。三个人的小团队,两周内接入了四种文档源。