尧图精选

Docker Compose + ElasticSearch + IK + BM25:从零搭建 AI Agent 混合检索底座

🕒 发布时间:2026/9/28 6:38:40 📁 来源:尧图网络
1. 从零搭建 AI Agent 的检索底座为什么是 Docker Compose ElasticSearch IK BM25做 AI Agent 的人迟早会撞上一堵墙模型本身很聪明但它不知道你私有的那堆文档里写了什么。你给它接一个向量库吧语义召回确实强可一旦用户问的是订单号、错误码、产品型号这种精确串向量检索就开始飘。反过来纯关键词检索呢又搞不定“我想查一下上次那个关于磁盘写入慢的问题”这种口语化表达。所以真正能打的 Agent 检索层基本都是混合检索一路走向量做语义召回一路走 ElasticSearch 做关键词召回最后用 BM25 或者 RRF 把两路结果融合排序。这套东西听起来玄乎落到工程上其实就四块砖Docker Compose负责把环境一键拉起来ElasticSearch当检索引擎IK分词器解决中文切词BM25是 ES 默认的相关性打分算法。我见过太多人卡在第一步——本地装个 ES 折腾一整天Java 版本不对、内存爆了、分词器装不上、容器起不来。这篇就把我踩过的坑和最终跑通的方案完整摊开讲从 Compose 文件怎么写、IK 怎么挂载、BM25 参数怎么调一直到“写入慢到底是磁盘问题还是别的问题”这种排查思路。适合正在给 Agent 搭 RAG 检索层、或者单纯想把 ES 用明白的后端同学。2. 整体架构设计与选型思路拆解2.1 为什么用 Docker Compose 而不是裸装先说结论本地开发和中小规模部署Compose 是性价比最高的选择。裸装 ES 的问题在于它是个 Java 应用对 JVM 参数、系统vm.max_map_count、文件句柄数都有要求你换台机器就得重来一遍。而 Compose 把镜像、端口、卷、环境变量、依赖关系全部声明在一个 YAML 里docker compose up -d一条命令搞定团队里谁拉下来都能跑出一样的环境。有人会问那为什么不用 K8s。K8s 当然好KubeSphere 部署 ElasticSearch 也是很多团队的选择但那是生产集群规模的事。你本地调个分词器、试个 BM25 参数上 K8s 纯属杀鸡用牛刀光是写 StatefulSet、PVC、Service 就够你喝一壶。开发阶段用 Compose生产再考虑 K8s Operator这个节奏最舒服。还有个现实问题docker compose和docker-compose这两个命令经常让人懵。新版 Docker 把 Compose 做成了插件命令是docker compose中间空格老版本是独立的 Python 脚本命令是docker-compose中间横杠。如果你敲docker: unknown command: docker compose八成是 Docker 版本太老或者插件没装。Ubuntu 上装 Compose 插件最稳的方式是走官方 apt 源而不是 pip 装那个老掉牙的docker-compose。2.2 ElasticSearch 在 Agent 里扮演什么角色很多人对 ES 的印象还停留在“日志检索”其实它在 Agent 里的定位是结构化 全文 向量三合一的检索层。8.x 版本之后 ES 原生支持dense_vector字段和 kNN 检索也就是说你不需要额外引入 Milvus、Qdrant 这些专用向量库一个 ES 就能同时存原文、倒排索引和向量。对 Agent 来说这意味着检索链路更短少一个组件就少一份运维负担。具体到混合检索典型流程是这样的用户 query 进来一路走match查询命中 BM25 关键词分数一路走knn查询命中向量相似度然后用rank或者rrf把两路合并。ES 8.8 之后内置了 RRFReciprocal Rank Fusion不用自己写融合逻辑这点非常省事。2.3 IK 分词器中文检索绕不过去的坎ES 默认的分词器对中文是按字切的也就是“磁盘写入慢”会被切成“磁”“盘”“写”“入”“慢”五个单字。这样检索“磁盘”的时候虽然能匹配上但 BM25 算出来的相关性会很难看因为单字粒度太细词频统计失真。IK 分词器就是干这个的它内置了中文词典能把“磁盘写入慢”切成“磁盘”“写入”“慢”这种合理的词粒度。IK 有两个模式ik_smart做粗粒度切分ik_max_word做细粒度穷举。建索引的时候用ik_max_word提高召回查询的时候用ik_smart提高精度这是标准搭配。当然你也可以自定义词典把业务专有名词比如产品型号、内部术语加进去否则这些词会被切碎。2.4 BM25 到底在算什么BM25 是 TF-IDF 的改进版核心思想是一个词在当前文档里出现越多越相关TF但这个词在整个语料库里越常见就越不值钱IDF同时还要对文档长度做归一化避免长文档靠堆词刷分。公式长这样score IDF(q) * (TF(q,D) * (k1 1)) / (TF(q,D) k1 * (1 - b b * |D| / avgdl))其中k1控制词频饱和速度默认 1.2b控制文档长度归一化强度默认 0.75。这两个参数是调优的重点如果你的文档长度差异很大可以把b调高一点如果短查询为主k1可以适当降低。ES 里可以在 mapping 的similarity里自定义这些值不用改全局。3. 核心细节解析与实操要点3.1 Compose 文件的关键配置逐行拆解先上一份我实际在用的docker-compose.yml单节点 ES 8.x带 IK 插件services: elasticsearch: image: docker.elastic.co/elasticsearch/elasticsearch:8.13.4 container_name: es-node environment: - discovery.typesingle-node - xpack.security.enabledfalse - ES_JAVA_OPTS-Xms1g -Xmx1g - bootstrap.memory_locktrue ulimits: memlock: soft: -1 hard: -1 volumes: - es-data:/usr/share/elasticsearch/data - ./plugins/ik:/usr/share/elasticsearch/plugins/ik ports: - 9200:9200 - 9300:9300 healthcheck: test: [CMD-SHELL, curl -s http://localhost:9200/_cluster/health | grep -q green] interval: 10s timeout: 5s retries: 10 volumes: es-data:几个关键点必须说清楚。discovery.typesingle-node是单节点模式省去集群发现配置本地开发必开。xpack.security.enabledfalse关掉安全认证否则你每次请求都要带账号密码调试很烦——但生产环境绝对不能关。ES_JAVA_OPTS设堆内存建议不超过物理内存的一半且不要超过 31g超过 31g JVM 会失去指针压缩优化反而更慢。bootstrap.memory_locktrue配合ulimits.memlock是防止 ES 内存被换出到磁盘这对检索性能影响很大。IK 插件的挂载方式有两种一种是打进自定义镜像一种是直接挂载目录。挂载目录更灵活改词典不用重建镜像。但要注意插件目录的权限和版本必须和 ES 主版本严格对应8.13.4 的 ES 就得配 8.13.4 编译的 IK版本错一位都可能启动失败。3.2 IK 分词器的安装与自定义词典IK 的安装本质就是把编译好的插件包解压到plugins/ik目录。目录结构长这样plugins/ik/ ├── plugin-descriptor.properties ├── elasticsearch-analysis-ik-8.13.4.jar └── config/ ├── IKAnalyzer.cfg.xml ├── main.dic ├── stopword.dic └── custom/ └── my.dicIKAnalyzer.cfg.xml里可以配置扩展词典和停用词典的路径?xml version1.0 encodingUTF-8? !DOCTYPE properties SYSTEM http://java.sun.com/dtd/properties.dtd properties commentIK Analyzer 扩展配置/comment entry keyext_dictcustom/my.dic/entry entry keyext_stopwordscustom/stop.dic/entry /propertiesmy.dic里一行一个词比如你的业务里有“智能体编排”“向量召回”这种词加进去就不会被切碎。改完词典要重启 ES 或者调用_reload接口热更新热更新命令是POST /_analyze配合POST /index/_close再_open比较绕本地开发直接重启容器最省事。验证分词效果curl -X POST http://localhost:9200/_analyze -H Content-Type: application/json -d { analyzer: ik_max_word, text: 磁盘写入慢怎么排查 }返回的 tokens 里如果看到“磁盘”“写入”“排查”这种词说明 IK 生效了。如果还是单字那就是插件没加载成功去看 ES 启动日志里的plugin相关报错。3.3 索引 Mapping 与 BM25 参数配置建索引的时候要把字段类型和分词器定好mapping 一旦建好字段类型不能改只能重建索引所以这一步要慎重。一个典型的混合检索索引PUT /agent_docs { settings: { number_of_shards: 1, number_of_replicas: 0, similarity: { custom_bm25: { type: BM25, k1: 1.2, b: 0.75 } } }, mappings: { properties: { title: { type: text, analyzer: ik_max_word, search_analyzer: ik_smart, similarity: custom_bm25 }, content: { type: text, analyzer: ik_max_word, search_analyzer: ik_smart }, embedding: { type: dense_vector, dims: 768, index: true, similarity: cosine }, created_at: { type: date } } } }number_of_replicas本地设 0省资源生产至少 1。similarity里自定义了custom_bm25k11.2、b0.75是默认值你可以根据实际语料调。如果文档普遍很短比如标题检索把b降到 0.3 左右效果更好因为短文档不需要那么强的长度归一化。3.4 混合检索的查询写法ES 8.8 之后可以用retriever语法做 RRF 融合POST /agent_docs/_search { retriever: { rrf: { retrievers: [ { standard: { query: { match: { content: 磁盘写入慢 } } } }, { knn: { field: embedding, query_vector: [0.1, 0.2, ...], k: 10, num_candidates: 100 } } ], rank_window_size: 50, rank_constant: 60 } } }rank_constant默认 60控制融合时高排名文档的权重越小越偏向头部结果。num_candidates是每个分片预取的候选数调大召回更全但更慢。这套写法比手写两路查询再自己融合省心太多。4. 实操过程与核心环节实现4.1 从零启动一套可用的检索环境第一步确认 Docker 和 Compose 插件都在。Ubuntu 上docker --version docker compose version如果第二条报unknown command说明 Compose 插件没装。走官方源sudo apt-get update sudo apt-get install docker-compose-plugin装完再验证一次。别用pip install docker-compose那个是老版本和新的 Docker 引擎配合经常出问题。第二步准备目录结构mkdir -p es-agent/plugins/ik/config/custom cd es-agent把 IK 插件包解压到plugins/ik确认plugin-descriptor.properties里的elasticsearch.version和你的镜像版本一致。第三步处理系统参数。ES 需要vm.max_map_count至少 262144sudo sysctl -w vm.max_map_count262144想持久化就写进/etc/sysctl.conf。这一步不做ES 启动会直接报max virtual memory areas vm.max_map_count [65530] is too low。第四步docker compose up -d然后docker compose logs -f elasticsearch看启动日志。看到started字样就成功了。访问http://localhost:9200应该返回集群信息。4.2 写入性能的观测与磁盘问题判断热词里有个很实在的问题“ES 怎么判断写入慢是磁盘问题还是别的”。这个问题我踩过分享一套排查路径。先看几个核心指标都在_nodes/stats里指标含义异常信号indices.indexing.index_time_in_millis索引耗时持续增长说明写入变慢indices.indexing.throttle_time_in_millis限流时间大于 0 说明触发了 merge 限流thread_pool.write.queue写入队列持续大于 0 说明写入压力大fs.io_stats磁盘 IOio_time_in_millis高说明磁盘忙indices.merges.total_time_in_millismerge 耗时占比高说明段合并是瓶颈判断是不是磁盘问题重点看fs.io_stats里的io_time_in_millis和write_time_in_millis。如果这两个值随写入线性增长而 CPU 和 JVM 堆都很闲那基本就是磁盘 IO 到顶了。机械盘跑 ES 是灾难SSD 是底线NVMe 更好。如果不是磁盘问题常见原因还有refresh_interval 太短默认 1s每次 refresh 都生成新段写入量大时把 refresh 调到 30s 甚至 -1 再手动 refresh、translog 同步策略太严index.translog.durability设成async能大幅提升写入吞吐代价是掉电可能丢几秒数据、分片数太多导致 merge 压力大。4.3 SpringBoot 接入 ES 的版本坑热词里有个报错很典型this version of the jdbc driver is only compatible with elasticsearch version。这是 ES 的 JDBC 驱动和 ES 服务端版本不匹配。SpringBoot 2.x 默认带的spring-boot-starter-data-elasticsearch版本往往比较老连 ES 8.x 会出各种幺蛾子。我的建议是别用 Spring Data 那套封装直接用官方elasticsearch-java客户端版本和服务端对齐dependency groupIdco.elastic.clients/groupId artifactIdelasticsearch-java/artifactId version8.13.4/version /dependency配置连接RestClient restClient RestClient.builder( new HttpHost(localhost, 9200)).build(); ElasticsearchTransport transport new RestClientTransport( restClient, new JacksonJsonpMapper()); ElasticsearchClient client new ElasticsearchClient(transport);这样版本一致不会有驱动兼容问题。如果你非要用 Spring Data那就把spring-boot-starter-data-elasticsearch的版本显式覆盖成和 ES 服务端一致。4.4 用 DBeaver 连 ES 做可视化查询DBeaver 连 ES 需要走 JDBC 驱动配置的时候注意几点URL 格式是jdbc:elasticsearch://localhost:9200驱动类选org.elasticsearch.xpack.sql.jdbc.EsDriver。DBeaver 连 ES 只能跑 SQL 风格的查询复杂的 DSL 查询它支持不了所以它更适合做数据浏览和简单统计真正的检索调试还是用 Kibana 或者 curl。5. 常见问题与排查技巧实录5.1 启动类问题速查现象原因解决vm.max_map_count too low系统参数没设sysctl -w vm.max_map_count262144容器起来就退出内存不够调小ES_JAVA_OPTS检查宿主机内存IK 不生效版本不匹配或目录权限核对版本号chmod -R 755 plugins/ikdocker: unknown commandCompose 插件没装apt install docker-compose-plugin9200 端口连不上端口没映射或防火墙检查ports配置和ufw规则5.2 检索效果类问题召回不全先看分词对不对用_analyze验证。如果分词没问题检查是不是search_analyzer用了ik_smart导致查询词被切得太粗。可以试试查询时也用ik_max_word召回会高但精度下降。相关性排序差调 BM25 的k1和b。另外可以给字段加boost比如标题权重设 2内容设 1。ES 8.x 还支持rank_feature可以把点击率、时效性这些信号融进打分。向量检索慢num_candidates调小或者给dense_vector建 HNSW 索引参数调优。m和ef_construction越大索引越准但越慢。5.3 我踩过的几个坑第一个坑IK 词典改了不生效。IK 的词典是启动时加载进内存的改文件不会自动重载。要么重启要么调_reload接口但_reload需要先 close 索引再 open生产环境慎用。第二个坑单节点 ES 设了 replicas 导致集群 yellow。单节点模式下副本分片无处分配集群状态一直是 yellow。本地开发把number_of_replicas设 0别看着 yellow 难受。第三个坑translog 把磁盘写满。默认 translog 会保留到下次 flush写入量大的时候 translog 能涨到几个 G。可以设index.translog.flush_threshold_size控制大小或者定期手动 flush。第四个坑Docker 卷权限问题。ES 容器里跑的是elasticsearch用户uid 1000挂载的宿主机目录如果属主不对ES 会报AccessDeniedException。解决办法是chown -R 1000:1000 es-data。5.4 性能调优的几个实用参数environment: - indices.memory.index_buffer_size20% - thread_pool.write.queue_size1000 - indices.query.bool.max_clause_count4096index_buffer_size控制索引缓冲写入量大可以调到 20%~30%。write.queue_size调大能扛住突发写入但太大反而增加延迟。max_clause_count是 bool 查询的子句上限做大规模过滤的时候容易撞到这个限制。6. 关于这套方案我个人的几点体会这套 Compose ES IK BM25 的组合我从去年开始陆续在几个 Agent 项目里用最大的感受是别一上来就追求完美。很多人卡在选型阶段纠结用 Milvus 还是 ES、用 RRF 还是加权融合结果环境都没跑起来。我的做法是先跑通最小闭环Compose 起 ESIK 装上建个索引塞几条数据用 curl 查一下能出结果然后再逐步加向量、加融合、调参数。另一个体会是观测比调优重要。你不知道瓶颈在哪瞎调参数就是碰运气。_nodes/stats、_cat/indices?v、_cat/thread_pool?v这几个接口要养成随手看的习惯写入慢、查询慢、merge 慢指标都会告诉你答案。磁盘问题尤其要看fs.io_stats别一慢就怪 ES很多时候是底层存储拖后腿。最后说个扩展方向等这套单节点跑顺了可以往两个方向走。一是上 K8s用 ECK Operator 或者 KubeSphere 管理 ES 集群做高可用和弹性伸缩二是把检索层抽象成独立服务Agent 通过 HTTP 调它这样检索逻辑和 Agent 逻辑解耦各自迭代互不影响。我现在的项目就是第二种架构检索服务单独部署Agent 只管调接口维护起来清爽很多。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →