ai agent --- Elasticsearch 混合检索

一般情况下,我们会用 ES 做关键词搜索,但是ES8也有了向量检索。

今天就用 NestJS + Elasticsearch 实现混合检索案例(关键词 BM25 + 向量 KNN + RRF 融合)

技术栈:

  • NestJS
  • @elastic/elasticsearch ES 官方客户端(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算法,拿到最优解,返回出来。

相关推荐
java1234_小锋1 小时前
【技术专题】HTML5 网页技术 - HTML5 文本与排版标签
前端·html·html5
Wokoo71 小时前
Selenium:元素定位、动态适配与性能优化(二)
前端·selenium·自动化
用户2788607285671 小时前
把 restore 当撤销会发生什么?长任务助手的会话与业务状态
前端
℘团子এ1 小时前
vue3中自定义指令的使用
前端·javascript·vue.js
用户2788607285671 小时前
别把 AI 查询结果只塞进聊天框:SessionView 与业务页面接线
前端
程序员鱼皮1 小时前
Personal Agent 是什么?为什么大厂都抢着做?连 DeepSeek Bot 都来了。。
前端·后端·ai编程
用户4402818278071 小时前
一个人 + TRAE,从 0 到 1 交付一个政务志愿服务平台的全链路实战
前端
代码猎人1 小时前
微信小程序是什么
前端
m0_587383001 小时前
碰碰好评卡功能落地全流程实战指南 附核心逻辑与常见问题解决方案
java·前端·数据库·后端·struts·需求分析