从零手写 JS 向量数据库与 RAG 引擎:语义分块、余弦相似度与混合检索

本节目标

传统 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% 的重叠字符能确保边界处的语义连贯性。