一般情况下,我们会用 ES 做关键词搜索,但是ES8也有了向量检索。
今天就用 NestJS + Elasticsearch 实现混合检索案例(关键词 BM25 + 向量 KNN + RRF 融合)
技术栈:
- NestJS
@elastic/elasticsearchES 官方客户端(ES8.x)- BGE-M3 向量模型(可本地调用或调用 Embedding 服务)
- 实现:文档分片入库、关键词检索、向量检索、RRF 混合检索、权限过滤(teamId /isPublic)
在docker里面安装ES,在docker-compose.ymll里面配置
js
services:
# Elasticsearch 8.17.0 + IK 中文分词(内置到镜像)
es:
build: ./elasticsearch # 从本地 Dockerfile 构建镜像(自带IK)
container_name: knowledge_hub_elasticsearch
ports:
- "9200:9200" # ES 访问端口
environment:
- discovery.type=single-node # 单节点运行(开发环境)
- xpack.security.enabled=false # 关闭安全认证,免密码访问
- xpack.security.http.ssl.enabled=false # 关闭 HTTPS 加密
- xpack.security.transport.ssl.enabled=false # 关闭节点传输加密
- ES_JAVA_OPTS=-Xms512m -Xmx512m # JVM 内存配置,避免占用过高
volumes:
- ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/es/data:/usr/share/elasticsearch/data
restart: always
# Kibana 最新稳定版:8.17.0(必须与 ES 版本完全一致)
kibana:
image: kibana:8.17.0
container_name: knowledge_hub_kibana
ports:
- "5601:5601" # Kibana 网页控制台端口
environment:
- ELASTICSEARCH_HOSTS=http://es:9200 # 连接 ES 容器内部地址
volumes:
- ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/kibana:/usr/share/kibana/data
restart: always
depends_on:
- es # 等待 ES 启动完成后再启动 Kibana
networks:
default:
name: common-network
还要在项目里面根目录创建一个文件/elasticsearch/Dockerfile用来初始化的时候安装 ik 分词器
js
# 官方 ES 基础镜像
FROM elasticsearch:8.17.0
# 安装 IK 分词(版本严格和 ES 一致)
RUN elasticsearch-plugin install --batch \
https://release.infinilabs.com/analysis-ik/stable/elasticsearch-analysis-ik-8.17.0.zip
启动ES和Kibana
js
docker compose up -d
1. 安装依赖
bash
npm install @elastic/elasticsearch
1. ES 客户端 Provider(src/providers/es.provider.ts)
javascript
import { Client } from '@elastic/elasticsearch';
export const ES_CLIENT = 'ES_CLIENT';
export const EsClientProvider = {
provide: ES_CLIENT,
useFactory: () => {
const client = new Client({
node: 'http://127.0.0.1:9200',
auth: {
username: 'elastic',
password: '你的es密码',
},
});
return client;
},
};
2. ES Module(src/elasticsearch/elasticsearch.module.ts)
typescript
import { Module } from '@nestjs/common';
import { EsClientProvider } from '../providers/es.provider';
@Module({
providers: [EsClientProvider],
exports: [EsClientProvider], // 导出,别的模块可以注入
})
export class ElasticModule {}
3. Knowledge Service(核心,注入原生 ES Client)
typescript
import { Injectable, Inject } from '@nestjs/common';
import { Client } from '@elastic/elasticsearch';
import { ES_CLIENT } from '../providers/es.provider';
interface KnowledgeChunk {
title: string;
content: string;
documentId: number;
categoryId: number;
teamId: number;
tags: string[];
isPublic: boolean;
embedding: number[];
}
interface SearchDto {
query: string;
teamId: number;
isPublic?: boolean;
size?: number;
}
@Injectable()
export class KnowledgeService {
private readonly INDEX = 'knowledge_chunks';
constructor(@Inject(ES_CLIENT) private readonly esClient: Client) {}
// 模拟Embedding,替换成BGE-M3服务调用
async getEmbedding(text: string): Promise<number[]> {
return Array.from({ length: 1024 }, () => Math.random());
}
// 写入分片
async insertChunk(chunk: KnowledgeChunk) {
await this.esClient.index({
index: this.INDEX,
document: chunk,
});
}
// 1. 关键词BM25检索
async searchKeyword(dto: SearchDto) {
const { query, teamId, isPublic = true, size = 3 } = dto;
const result = await this.esClient.search({
index: this.INDEX,
size,
query: {
bool: {
must: [{ match: { content: query } }],
filter: [
{ term: { teamId } },
{ term: { isPublic } },
],
},
},
});
return result.hits.hits.map((item) => ({
id: item._id,
...item._source,
score: item._score,
}));
}
// 2. 纯向量KNN检索
async searchVector(dto: SearchDto) {
const { query, teamId, isPublic = true, size = 3 } = dto;
const vector = await this.getEmbedding(query);
const result = await this.esClient.search({
index: this.INDEX,
size,
knn: {
field: 'embedding',
query_vector: vector,
k: size,
num_candidates: 10,
filter: [
{ term: { teamId } },
{ term: { isPublic } },
],
},
});
return result.hits.hits.map((item) => ({
id: item._id,
...item._source,
score: item._score,
}));
}
// 3. RRF混合检索(关键词+向量融合,ES >=8.8)
async searchHybridRrf(dto: SearchDto) {
const { query, teamId, isPublic = true, size = 3 } = dto;
const vector = await this.getEmbedding(query);
const result = await this.esClient.search({
index: this.INDEX,
size,
retriever: {
rrf: {
rank_window_size: 20,
retrievers: [
{
standard: {
query: {
bool: {
must: [{ match: { content: query } }],
filter: [
{ term: { teamId } },
{ term: { isPublic } },
],
},
},
},
},
{
knn: {
field: 'embedding',
query_vector: vector,
k: 10,
num_candidates: 20,
filter: [
{ term: { teamId } },
{ term: { isPublic } },
],
},
},
],
},
},
});
return result.hits.hits.map((item) => ({
id: item._id,
...item._source,
score: item._score,
}));
}
}
4. Controller 和 Module 不变
typescript
// knowledge.controller.ts
import { Controller, Post, Body } from '@nestjs/common';
import { KnowledgeService } from './knowledge.service';
class SearchDTO {
query: string;
teamId: number;
isPublic?: boolean;
size?: number;
}
@Controller('knowledge')
export class KnowledgeController {
constructor(private readonly knowledgeService: KnowledgeService) {}
//关键词
@Post('search/keyword')
async keywordSearch(@Body() dto: SearchDTO) {
return this.knowledgeService.searchKeyword(dto);
}
//向量
@Post('search/vector')
async vectorSearch(@Body() dto: SearchDTO) {
return this.knowledgeService.searchVector(dto);
}
//混合检索
@Post('search/hybrid')
async hybridSearch(@Body() dto: SearchDTO) {
return this.knowledgeService.searchHybridRrf(dto);
}
@Post('chunk')
async addChunk(@Body() chunk: any) {
await this.knowledgeService.insertChunk(chunk);
return { success: true };
}
}
typescript
// knowledge.module.ts
import { Module } from '@nestjs/common';
import { KnowledgeService } from './knowledge.service';
import { KnowledgeController } from './knowledge.controller';
import { ElasticModule } from '../elasticsearch/elasticsearch.module';
@Module({
imports: [ElasticModule],
providers: [KnowledgeService],
controllers: [KnowledgeController],
})
export class KnowledgeModule {}
关键词搜索用的是query
向量搜索用的是knn
RRF混合检索,就是将关键词搜索结果和向量检索出来的结果放在一起,去重,按照RRF算法,拿到最优解,返回出来。
