从零手写 JS 向量数据库与 RAG 引擎:语义分块、余弦相似度与混合检索
本节目标
- 掌握 RAG(检索增强生成)在 Agent 架构中的核心定位:将知识检索封装为 Agent 可主动调用的搜寻工具(Agentic RAG)
- 理解为什么按固定字数硬切文档会破坏代码函数与表格语义,并手写递归语义分块器(Recursive Markdown Chunker)
- 用原生
Float32Array实现高性能余弦相似度向量检索 + BM25 关键词检索 + RRF(倒数秩融合)混合排序引擎
传统 RAG vs Agentic RAG
传统的朴素 RAG(Naive RAG)是死板的流水线:用户问一句话 → 向量库搜 Top 3 → 拼进 Prompt 回答。如果第一次搜出来的片段不够回答问题,它毫无办法。
而在 Agentic RAG 中,向量与全文检索引擎被注册为一个标准工具(如 searchKnowledgeBase)。Agent 可以自主改写查询词、针对不同子问题发起多次检索,甚至结合关键词精确匹配(找报错码/函数名)与语义向量匹配(找概念解释)。
为什么纯向量检索搜不准函数名和错误码?
Embedding 向量擅长捕捉模糊的语义相似性(例如「如何给请求限流」能匹配到「令牌桶算法」),但对精确专有名词(如错误码 ERR_SSL_PROTOCOL_ERROR 或函数名 useSyncExternalStore)非常迟钝。生产级 RAG 引擎必须采用 Hybrid Search(向量检索 + 关键词词频检索),再用 RRF(Reciprocal Rank Fusion,倒数秩融合) 合并两路排名:
$$ ext{RRF_Score}(d) = rac{1}{k + ext{rank}{ ext{vector}}(d)} + rac{1}{k + ext{rank}{ ext{keyword}}(d)} \quad (k = 60)$$
手写原生 JS 语义分块与混合检索引擎
// 1. 递归语义分块器:优先按 Markdown 二三级标题、双换行段落切分,保留重叠窗口(Overlap)
export function chunkMarkdown(text, { maxChars = 600, overlapChars = 100 } = {}) {
const separators = ['\n## ', '\n### ', '\n\n', '\n', '。', ' ']
function splitRecursively(str, sepIdx = 0) {
if (str.length <= maxChars || sepIdx >= separators.length) {
return [str.trim()].filter(Boolean)
}
const sep = separators[sepIdx]
const parts = str.split(sep)
const chunks = []
let current = ''
for (let i = 0; i < parts.length; i++) {
const piece = i === 0 ? parts[i] : sep + parts[i]
if ((current + piece).length <= maxChars) {
current += piece
} else {
if (current.trim()) chunks.push(...splitRecursively(current, sepIdx + 1))
// 保留尾部 overlap 上下文,防止跨块句子断裂
const tail = current.slice(-overlapChars)
current = tail + piece
}
}
if (current.trim()) chunks.push(...splitRecursively(current, sepIdx + 1))
return chunks
}
return splitRecursively(String(text || ''))
}
// 2. 基于 Float32Array 的余弦相似度计算
export function cosineSimilarity(vecA, vecB) {
let dot = 0
let normA = 0
let normB = 0
for (let i = 0; i < vecA.length; i++) {
const a = vecA[i]
const b = vecB[i]
dot += a * b
normA += a * a
normB += b * b
}
const denom = Math.sqrt(normA) * Math.sqrt(normB)
return denom === 0 ? 0 : dot / denom
}
// 3. 支持向量 + 关键词 RRF 混合重排的内存检索引擎
export class HybridRagStore {
constructor({ embedder }) {
this.embedder = embedder // async (text) => number[]
this.docs = [] // { id, text, metadata, vector: Float32Array, tokens: Set }
}
#tokenize(text) {
return (
String(text)
.toLowerCase()
.match(/[a-z0-9_]+|[\u4e00-\u9fa5]{2,}/g) || []
)
}
async addDocument(id, text, metadata = {}) {
const rawVec = await this.embedder(text)
this.docs.push({
id,
text,
metadata,
vector: new Float32Array(rawVec),
tokens: this.#tokenize(text)
})
}
async hybridSearch(query, { topK = 5, rrfK = 60 } = {}) {
const queryVec = new Float32Array(await this.embedder(query))
const queryTerms = this.#tokenize(query)
// 第 1 路:向量余弦相似度排序
const byVector = this.docs
.map((doc) => ({ id: doc.id, score: cosineSimilarity(queryVec, doc.vector) }))
.sort((a, b) => b.score - a.score)
// 第 2 路:关键词命中覆盖度(轻量 BM25 近似)排序
const byKeyword = this.docs
.map((doc) => {
const tokenSet = new Set(doc.tokens)
const hits = queryTerms.filter((t) => tokenSet.has(t)).length
return { id: doc.id, score: queryTerms.length ? hits / queryTerms.length : 0 }
})
.sort((a, b) => b.score - a.score)
// 第 3 步:RRF 倒数秩融合(无需对不同量纲的分数做复杂的归一化)
const rrfScores = new Map()
byVector.forEach((item, rank) => {
rrfScores.set(item.id, (rrfScores.get(item.id) || 0) + 1 / (rrfK + rank + 1))
})
byKeyword.forEach((item, rank) => {
rrfScores.set(item.id, (rrfScores.get(item.id) || 0) + 1 / (rrfK + rank + 1))
})
return [...this.docs]
.map((doc) => ({
id: doc.id,
text: doc.text,
metadata: doc.metadata,
rrfScore: rrfScores.get(doc.id) || 0
}))
.sort((a, b) => b.rrfScore - a.rrfScore)
.slice(0, topK)
}
}课后练习
练习 1:在合并向量检索分数(余弦相似度范围 [-1, 1])与关键词检索分数(如 BM25 范围 [0, +∞))时,为什么工业界普遍使用 RRF(倒数秩融合) 而不是直接将两个分数相加?
答案:余弦相似度与 BM25 词频得分处于完全不同的数值量纲与分布区间,直接线性相加会导致数值较大的一方彻底淹没另一方;RRF 仅利用文档在各自榜单中的**名次(Rank)**计算 1 / (k + rank),天然消除了量纲差异,且对极端异常分值极度稳健。
练习 2:为什么 chunkMarkdown 在切分文档块时要保留 overlapChars(相邻块尾部重叠窗口)?
答案:如果一句话或一段上下文因果关系恰好落在切分边界上,不带重叠的硬切分会让上半句和下半句分属两个孤立的 Chunk,导致两边都丢失完整语义;保留 10%~15% 的重叠字符能确保边界处的语义连贯性。