RAG 语义检索改造前端组件文档站:从 52% 到 93% 召回率的实战记录
组件库文档搜不出东西,这个痛点你大概率体验过。我们团队维护着一套 280+ 组件的内部 UI 库,Vue 3 + Vite 构建,文档站跑在 VitePress 上。组件数量膨胀到这个量级之后,左侧导航栏已经叠了五层折叠菜单,新人入职第一周最高频的问题不是"这个组件怎么用",而是"这个组件到底叫什么"。
VitePress 自带的 minisearch 做的是全文检索——输入"表格行合并"搜不到 TableMergeCell,输入"上传进度条"搜不到 FileUploader 的 progress 属性。关键词搜索和语义搜索,本质上是两个物种。
我们的改造方向是给文档站接入 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:
| 用户查询 | 期望召回 | 实际召回 |
|---|---|---|
| 表格行合并 | Table 的 spanMethod | MergeCell 组件(不存在) |
| 弹窗关闭回调 | Dialog 的 closed 事件 | Modal 的 visible 属性 |
| 下拉菜单搜索过滤 | Select 的 filterable | Dropdown 的 trigger |
问题在于,通用模型把"表格行合并"映射到的向量,跟文档中"通过 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?因为用户不会每次都精准输入组件名。"怎么做一个可以拖拽排序的列表"——这句话里没有 DraggableSortList 或 Sortable 任何一个关键词,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-small | 58% | +6% |
| 语义切片(按文档结构拆) | 68% | +10% |
| 微调 Embedding | 81% | +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 再一个。三个人的小团队,两周内接入了四种文档源。