Appearance
课 1 · ES 索引与 BM25 全文检索
本课目标
理解 BM25 全文检索的工作原理,在 Elasticsearch 里建立文档索引,实现关键词检索能力。
先理解两种检索的本质区别:向量检索找"语义相关的",关键词检索找"包含这些字的"。 两者互补。
纯向量检索的局限
基础课用 sqlite-vec 做了纯向量检索,它有一个典型失效场景:
| 查询 | 向量检索结果 | 问题 |
|---|---|---|
| "RFC-2119 规范" | 返回"协议标准文档"相关内容 | 正确 |
| "GPT-4o 的上下文窗口是多少" | 返回"大语言模型能力"相关内容 | 数字/型号不精确 |
| "错误代码 E-1024 处理方法" | 返回"错误排查指南"相关内容 | 特定编号完全无匹配 |
对于专有名词、版本号、错误代码、人名、产品型号,向量检索往往找不准,因为 Embedding 模型不一定对这些字符串有良好的向量表示。这时候关键词检索(BM25)更可靠。
BM25 原理(够用的理解)
BM25 是 Elasticsearch 的默认相关性算法,核心公式:
不需要记公式,理解三个核心概念就够:
- 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 的 keyword 和 text 类型有什么区别?
text 会对内容分词并建立倒排索引,适合全文搜索。keyword 不分词,存储原始字符串,适合精确匹配、聚合统计、排序。fileName 用 keyword 是因为我们要精确匹配文件名,而不是搜索文件名里的词。