Skip to content

课 1 · ES 索引与 BM25 全文检索

本课目标

理解 BM25 全文检索的工作原理,在 Elasticsearch 里建立文档索引,实现关键词检索能力。

先理解两种检索的本质区别:向量检索找"语义相关的",关键词检索找"包含这些字的"。 两者互补。

纯向量检索的局限

基础课用 sqlite-vec 做了纯向量检索,它有一个典型失效场景:

查询向量检索结果问题
"RFC-2119 规范"返回"协议标准文档"相关内容正确
"GPT-4o 的上下文窗口是多少"返回"大语言模型能力"相关内容数字/型号不精确
"错误代码 E-1024 处理方法"返回"错误排查指南"相关内容特定编号完全无匹配

对于专有名词、版本号、错误代码、人名、产品型号,向量检索往往找不准,因为 Embedding 模型不一定对这些字符串有良好的向量表示。这时候关键词检索(BM25)更可靠。

BM25 原理(够用的理解)

BM25 是 Elasticsearch 的默认相关性算法,核心公式:

score(q,d)=i=1nIDF(qi)f(qi,d)(k1+1)f(qi,d)+k1(1b+b|d|avgdl)

不需要记公式,理解三个核心概念就够:

  • TF(词频):词在文档中出现越多,得分越高
  • IDF(逆文档频率):词在所有文档中越罕见,权重越高("的"权重低,"RFC-2119"权重高)
  • 文档长度归一化:避免长文档因为词多而得分虚高

安装 ES 客户端

bash
pnpm add @elastic/elasticsearch

索引设计

typescript
// packages/rag-core/src/es/index-manager.ts
import { Client } from '@elastic/elasticsearch'

export const esClient = new Client({
  node: process.env.ELASTICSEARCH_URL ?? 'http://localhost:9200',
})

const INDEX_NAME = 'knowledge-chunks'

export async function ensureIndex() {
  const exists = await esClient.indices.exists({ index: INDEX_NAME })
  if (exists) return

  await esClient.indices.create({
    index: INDEX_NAME,
    mappings: {
      properties: {
        jobId: { type: 'keyword' },          // 精确匹配,不分词
        chunkIndex: { type: 'integer' },
        content: {
          type: 'text',
          analyzer: 'ik_smart',              // 中文分词(需安装 IK 插件)
          fields: {
            keyword: { type: 'keyword' },    // 同时支持精确匹配
          },
        },
        fileName: { type: 'keyword' },
        pageNumber: { type: 'integer' },
        createdAt: { type: 'date' },
      },
    },
    settings: {
      number_of_shards: 1,
      number_of_replicas: 0,                 // 开发环境单节点,不需要副本
    },
  })
}

文档写入

Worker 处理完 Embedding 后,同时写入 ES:

typescript
// packages/rag-core/src/es/document-store.ts

export async function indexChunk(chunk: {
  id: string
  jobId: string
  chunkIndex: number
  content: string
  fileName: string
  pageNumber?: number
}) {
  await esClient.index({
    index: INDEX_NAME,
    id: chunk.id,                // 同向量库的 ID,保持一致
    document: {
      jobId: chunk.jobId,
      chunkIndex: chunk.chunkIndex,
      content: chunk.content,
      fileName: chunk.fileName,
      pageNumber: chunk.pageNumber,
      createdAt: new Date().toISOString(),
    },
  })
}

BM25 关键词检索

typescript
// packages/rag-core/src/es/search.ts

export async function keywordSearch(query: string, topK = 5) {
  const result = await esClient.search({
    index: INDEX_NAME,
    query: {
      multi_match: {
        query,
        fields: [
          'content^2',        // content 字段权重翻倍
          'fileName',
        ],
        type: 'best_fields',
      },
    },
    size: topK,
    _source: ['jobId', 'chunkIndex', 'content', 'fileName', 'pageNumber'],
  })

  return result.hits.hits.map((hit) => ({
    id: hit._id!,
    score: hit._score ?? 0,
    content: (hit._source as any).content as string,
    fileName: (hit._source as any).fileName as string,
    pageNumber: (hit._source as any).pageNumber as number | undefined,
  }))
}

中文分词插件(IK)

ES 默认对中文按单字切分,效果差。需要安装 IK 分词插件:

bash
# 通过 Docker Compose 在启动时安装
# 修改 docker-compose.dev.yml 的 elasticsearch 服务:
# command: >
#   bash -c "
#     elasticsearch-plugin install https://get.infini.cloud/elasticsearch/analysis-ik/8.13.0 &&
#     docker-entrypoint.sh elasticsearch
#   "

或者用 analyzer: 'standard' 也可以跑通演示,只是中文分词不准。

本节产物

packages/rag-core/src/es/
  index-manager.ts      # 索引创建与管理
  document-store.ts     # 文档写入 ES
  search.ts             # BM25 关键词检索

面试追问

全文检索和向量检索分别适合什么问题?

全文检索(BM25)适合:精确词匹配(产品型号、代码、错误信息)、用户知道确切关键词、追求可解释性。向量检索适合:语义相似(用户描述的意思相同但用词不同)、多语言、图片/代码等非文本场景。两者互补,不是替代关系。

ES 的 keywordtext 类型有什么区别?

text 会对内容分词并建立倒排索引,适合全文搜索。keyword 不分词,存储原始字符串,适合精确匹配、聚合统计、排序。fileNamekeyword 是因为我们要精确匹配文件名,而不是搜索文件名里的词。

面向前端工程师和独立开发者的 AI 应用工程课程