首页 / 资讯中心 / 文章详情

Elasticsearch 智能搜索实战:用 AI Builder 与 Workflow 搭建 ES|QL 查询链路

Elasticsearch 智能搜索实战:用 AI Builder 与 Workflow 搭建 ES|QL 查询链路 ★ FEATURED ARTICLE
1. 从一句自然语言到 ES|QL智能搜索到底难在哪Elasticsearch 智能搜索这件事真正卡住大多数人的不是向量检索本身而是「自然语言 → 结构化查询」这一段。用户说「迈阿密 10 英里内、2 卧 2 卫、中央空调、瓷砖地板、预算 30 万美元以内」你手里拿到的是一句话而 ES|QL 需要的是WHERE bedrooms 2、ST_DISTANCE(location, pt) 16093、MATCH(property_features, central air)这些确定的条件。中间这层翻译过去要么写 Python 调 LLM 抽参数要么自己搭一个 MCP Server两条路都得写代码。我这次想聊的是第三条路用 Elasticsearch 自带的 AI Builder 和 Workflow把「抽参数 → 地理编码 → 拼 ES|QL → 执行 → 展示」串成一条不用写 Python 的链路。它适合谁适合已经有一份结构化索引比如房产、商品、工单想让业务同学直接用自然语言查 ES|QL又不想维护一套抽取服务的团队。核心检索词就三个Elasticsearch 智能搜索、AI Builder、Workflow 编排 ES|QL。先说清楚这条链路的分工。AI Builder 负责「理解」——它把用户那句话拆成bedrooms、bathrooms、home_price_max、property_features、location、distance这些字段并且用outputSchema约束输出类型避免 LLM 返回一堆自由文本。Workflow 负责「编排」——它是一份 YAML定义了触发方式、步骤顺序、步骤之间的数据传递。ES|QL 负责「执行」——最终那条查询语句在 Elasticsearch 里跑返回结构化结果。为什么值得用 Workflow 而不是纯 Python因为 Workflow 的每一步都是可观测的。你在 Kibana 里能看到extract_parameters输出了什么、get_coordinate拿到了什么经纬度、build_esql_query拼出来的语句长什么样。调试智能搜索最痛苦的就是「结果不对但不知道哪一步错了」Workflow 把中间态全暴露出来这一点比黑盒的 Python 函数强太多。还有一个现实问题地理编码。用户说「Miami, Florida」ES|QL 不认识地名只认识经纬度。所以链路里必须有一站把地名转成lat/lon我用的是 Geocoding HTTP 接口。这一步在 Workflow 里就是一个http类型的 step拿到results[0].geometry.location就完事。整条链路下来你不需要写一行 Python只需要填一个 API Key、改几个字段名。下面我会按「准备索引 → 写 Workflow → 跑通验证 → 排错 → 接成 Agent」的顺序展开每一步都给可复制的配置。版本上要注意这套在 9.3 上验证通过9.4 把outputSchema改成了schema如果你升级了集群这个字段名必须跟着改否则 AI step 会直接报 schema 解析失败。2. 前置准备索引映射、AI Builder 与 Workflow 的接入配置在写 Workflow 之前有三样东西必须先就位一份带semantic_text和geo_point的索引映射、一个可用的 AI Builder 连接、以及 Workflow 能调用的 Elasticsearch 连接。这三样缺一个后面的 ES|QL 就跑不起来。先说索引映射。这条链路里 ES|QL 用到了TO_GEOPOINT、ST_DISTANCE、MATCH所以索引里必须有locationgeo_point 类型和body_content_semantic_textsemantic_text 类型。property_features用普通text就行MATCH能直接吃。下面是我用的映射你可以直接照着建PUT properties { mappings: { properties: { title: { type: text }, bedrooms: { type: integer }, bathrooms: { type: integer }, home_price: { type: double }, property_features: { type: text }, body_content_semantic_text: { type: semantic_text }, location: { type: geo_point } } } }注意semantic_text字段需要集群里已经配好 inference endpoint否则写入时会报inference endpoint not found。如果你暂时不想配语义检索可以把body_content_semantic_text换成普通text然后把 ES|QL 里的MATCH(body_content_semantic_text, ...)改成MATCH(title, ...)链路照样能跑通只是少了语义召回。接着是 AI Builder。它本质上是 Workflow 里ai.prompt这个 step 背后的模型连接。你需要在 Kibana 的 AI 设置里确认有一个可用的 connector比如内置的.anthropic或.openai并且它被标记为默认。Workflow 里的ai.prompt不显式指定 connector 时走的就是默认那个。如果你有多个 connector建议在 step 里显式写connector: id避免默认值被改后链路悄悄失效。然后是 Workflow 本身的接入。Workflow 是 Elasticsearch 的一个功能你可以在 Kibana 的 Workflows 界面里创建也可以用 API 创建。创建时它需要一个执行身份能读properties索引、能调 inference endpoint。如果你用的是 API Key确保这个 Key 有manage_workflow和read权限。这一步很多人会漏结果 Workflow 保存成功但一执行就 403。关于模型调用如果你希望把 AI Builder 的模型请求统一走一个网关来管理 Key 和配额可以了解下 TaoToken 的接入方式API 地址是https://taotoken.net/api控制台在https://taotoken.net/consoleAPI Key 在https://taotoken.net/api-keys生成。它的模型对话入口在https://taotoken.net/models接入文档在https://taotoken.net/doc。对于长期跑 Agent 和编码任务的场景Coding Plan 在https://taotoken.net/coding-plan。这些只是可选的统一入口你完全可以直接用 Elasticsearch 内置的 connector不影响本文链路。最后确认一下版本。9.3 的ai.promptstep 用outputSchema9.4 改成了schema。你可以在 Kibana 的 Workflow 编辑器里试跑一个最小 step 来确认steps: - name: probe type: ai.prompt with: prompt: Return the word ok outputSchema: type: object properties: word: { type: string }如果保存时报unknown field [outputSchema]就把字段名换成schema。这个探测花不了一分钟但能省掉后面一堆莫名其妙的报错。3. 可复制的 Workflow 配置从自然语言到 ES|QL 的完整链路这一节是全文的核心我把整份 Workflow YAML 拆开讲每一段都说明它在干什么、哪些地方你必须改。整份配置可以直接粘到 Kibana 的 Workflow 编辑器里改掉 API Key 和字段名就能跑。先看头部。consts里放地理编码的 URLtriggers用manual手动触发输入是一个user_query字符串name: real_estate_esql_workflow enabled: true description: Advanced ES|QL search with Geocoding, WKT construction, and clean console output consts: geocoding_api_url: https://maps.googleapis.com/maps/api/geocode/json?keyYour API key triggers: - type: manual inputs: - name: user_query type: string required: true description: Natural language real estate search requestYour API key换成你自己的地理编码 Key。如果你不想用外部地理编码也可以预先维护一张「城市名 → 经纬度」的索引用一个elasticsearch.searchstep 去查这样就不依赖外部接口了。第一个 step 是extract_parameters用ai.prompt把自然语言拆成结构化字段。关键是outputSchema它把 LLM 的输出锁死成固定字段和类型steps: - name: extract_parameters type: ai.prompt with: temperature: 0.2 outputSchema: type: object properties: query: { type: string } bathrooms: { type: integer } bedrooms: { type: integer } home_price_max: { type: number, minimum: 0 } property_features: { type: string } location: { type: string } distance: { type: string } additionalProperties: false prompt: | Extract structured real estate parameters from: {{ inputs.user_query }}temperature: 0.2是为了让抽取稳定别让它发挥。additionalProperties: false防止模型塞额外字段。9.4 上把outputSchema改成schema。第二个 step 是get_coordinate把location送去地理编码- name: get_coordinate type: http with: url: {{ consts.geocoding_api_url }}address{{ steps.extract_parameters.output.content.location | url_encode }} method: GET注意url_encode过滤器地名里有空格和逗号不编码会拼出非法 URL。第三个 step 是combine_with_coordinates把抽取结果和经纬度合并成一份干净的 JSON。这里用 Liquid 模板做字段清洗比如把10 miles里的miles去掉再转成米- name: combine_with_coordinates type: console with: message: |- {%- assign lat steps.get_coordinate.output.data.results[0].geometry.location.lat | default: 0 -%} {%- assign lon steps.get_coordinate.output.data.results[0].geometry.location.lng | default: 0 -%} {%- assign dist_raw steps.extract_parameters.output.content.distance | default: 0 | replace: miles, | plus: 0 -%} { query: {{ steps.extract_parameters.output.content.query | replace: , \ | strip }}, bathrooms: {{ steps.extract_parameters.output.content.bathrooms }}, bedrooms: {{ steps.extract_parameters.output.content.bedrooms }}, home_price_max: {{ steps.extract_parameters.output.content.home_price_max }}, property_features: {{ steps.extract_parameters.output.content.property_features | replace: , \ | strip }}, longitude: {{ lon }}, latitude: {{ lat }}, distance_meters: {{ dist_raw | times: 1609.34 }} }times: 1609.34是英里转米。这一步的输出是一段 JSON 字符串下一步会json_parse它。第四个 step 是build_esql_query整条链路最核心的地方——动态拼 ES|QL。它先构造一个 WKT 点再用ST_DISTANCE算距离然后按条件逐个追加WHERE- name: build_esql_query type: console with: message: |- {%- assign v steps.combine_with_coordinates.output | json_parse -%} {%- assign q FROM properties METADATA _score | EVAL pt TO_GEOPOINT(CONCAT(POINT(, | append: v.longitude | append: , , | append: v.latitude | append: , ))) | EVAL distance ST_DISTANCE(location, pt) -%} {%- if v.bathrooms ! -%}{%- assign q q | append: | WHERE bathrooms | append: v.bathrooms -%}{%- endif -%} {%- if v.bedrooms ! -%}{%- assign q q | append: | WHERE bedrooms | append: v.bedrooms -%}{%- endif -%} {%- if v.home_price_max ! -%}{%- assign q q | append: | WHERE home_price | append: v.home_price_max -%}{%- endif -%} {%- if v.property_features ! -%} {%- assign q q | append: | WHERE MATCH(property_features, | append: v.property_features | append: ) -%} {%- endif -%} {%- if v.distance_meters 0 -%} {%- assign q q | append: | WHERE distance | append: v.distance_meters -%}{%- endif -%} {%- if v.query ! -%} {%- assign q q | append: | WHERE MATCH(body_content_semantic_text, | append: v.query | append: ) -%} {%- endif -%} {%- assign q q | append: | SORT _score DESC, distance ASC | LIMIT 10 | KEEP title, bedrooms, bathrooms, home_price, property_features, distance -%} {{ q | strip }}这里有几个坑要提前说。TO_GEOPOINT(CONCAT(POINT(, lon, , lat, )))的 WKT 格式是POINT(lon lat)经度在前纬度在后写反了ST_DISTANCE会算出离谱的值。MATCH用在semantic_text字段上走的是语义召回用在普通text上走的是全文匹配两者行为不同。SORT _score DESC, distance ASC让语义相关度优先、距离次之你可以按业务调。第五个 step 执行查询- name: esql_run type: elasticsearch.esql.query with: format: json query: {{ steps.build_esql_query.output | strip }}第六个 step 把结果打印成人能读的格式只取前 3 条- name: display_top_results type: console with: message: |- {%- assign results steps.esql_run.output.values -%} {%- if results.size 0 -%} No properties found matching your criteria. {%- else -%} {%- for row in results limit:3 -%} Title: {{ row[0] }} Bathrooms: {{ row[2] }} Bedrooms: {{ row[1] }} Price: ${{ row[3] }} Features: {{ row[4] }} Distance: {{ row[5] | divided_by: 1609.34 | round: 2 }} miles {% endfor -%} {%- endif -%}row的下标顺序跟KEEP里的字段顺序一致title, bedrooms, bathrooms, home_price, property_features, distance所以row[0]是 title、row[1]是 bedrooms、row[2]是 bathrooms。这个顺序很容易记混改KEEP的时候记得同步改下标。整份配置里你至少要改两处geocoding_api_url里的 Key以及如果你不用语义检索就把body_content_semantic_text换成你的字段名。9.4 用户还要把outputSchema改成schema。4. 验证请求与成功结果跑通一条真实查询配置写完接下来是验证。验证分三层先单独验证 AI 抽取再验证 ES|QL 语句本身最后跑整条 Workflow。分层验证的好处是出错时能立刻定位是哪一层的问题。第一层验证 AI 抽取。在 Workflow 编辑器里单独跑extract_parameters输入那句测试查询Find a home within 10 miles of Miami, Florida that has 2 bedrooms, 2 bathrooms, central air, and tile floors, with a budget up to $300,000.期望输出是一段 JSON字段大致是{ query: central air tile floors, bathrooms: 2, bedrooms: 2, home_price_max: 300000, property_features: central air tile floors, location: Miami, Florida, distance: 10 miles }如果bedrooms返回的是字符串2而不是数字2说明outputSchema没生效检查是不是 9.4 上还写着outputSchema。如果location返回了整句话说明 prompt 不够明确可以在 prompt 里加一句「location 只保留城市和州」。第二层验证 ES|QL 语句。把build_esql_query的输出复制出来直接在 Kibana 的 ES|QL 控制台里跑。拼出来的语句应该长这样FROM properties METADATA _score | EVAL pt TO_GEOPOINT(CONCAT(POINT(, -80.1918, , 25.7617, ))) | EVAL distance ST_DISTANCE(location, pt) | WHERE bathrooms 2 | WHERE bedrooms 2 | WHERE home_price 300000 | WHERE MATCH(property_features, central air tile floors) | WHERE distance 16093.4 | WHERE MATCH(body_content_semantic_text, central air tile floors) | SORT _score DESC, distance ASC | LIMIT 10 | KEEP title, bedrooms, bathrooms, home_price, property_features, distance在控制台里跑这条如果返回结果说明 ES|QL 层没问题。如果报Unknown function TO_GEOPOINT检查集群版本这个函数需要较新的版本。如果报mapping type [geo_point] expected说明location字段类型不对回去改映射。第三层跑整条 Workflow。触发时传入user_query观察每一步的输出。成功的话display_top_results会打印类似Title: Sunny 2BR Condo in Brickell Bathrooms: 2 Bedrooms: 2 Price: $285000 Features: central air, tile floors Distance: 3.42 miles我实测下来从触发到出结果大概几秒主要耗时在地理编码那一步。如果你连续跑多次同样的地名可以考虑把地理编码结果缓存到一个索引里减少外部调用。再换一个查询验证泛化能力Find a home within 10 miles of DeBary, Florida with 5 bedrooms, at least 2 bathrooms, central air, and a garage, with a budget up to $600,000.这次bedrooms是 5、home_price_max是 600000、location是DeBary, Florida。如果两次都能正确抽取并返回结果说明链路是稳的。注意「at least 2 bathrooms」这种表述AI 应该抽成bathrooms: 2因为 ES|QL 里用的是。如果它抽成了别的在 prompt 里补一句「bathrooms 和 bedrooms 表示最小值」。验证通过后你可以把这条 Workflow 挂到一个定时触发或 API 触发上让业务系统直接调用。Workflow 的 API 触发会返回一个 endpointPOST 一个 JSON body 就能跑这样它就从「手动调试」变成了「可复用服务」。5. 常见报错排查401、local proxy failed 与 reading choices智能搜索链路跑不通报错往往集中在几个固定位置。这一节我把踩过的坑按报错原文列出来对照着查能省不少时间。第一个高频报错是401 Unauthorized。它可能出现在两个地方地理编码接口和 AI connector。地理编码的 401 通常是 Key 没填、填错、或者 Key 没开通 Geocoding API。检查consts.geocoding_api_url里的key后面是不是你的真实 Key以及这个 Key 在对应平台是否启用了地理编码服务。AI connector 的 401 则是模型侧的 Key 失效去 Kibana 的 connector 设置里重新填一次。如果你把模型请求统一走网关确认https://taotoken.net/api的 Key 是在https://taotoken.net/api-keys生成的、且额度充足。第二个报错是local proxy failed。这个通常出现在 Workflow 的httpstep 访问外部地址时说明执行环境到目标地址的网络不通。先确认geocoding_api_url是公网可达的 HTTPS 地址再确认 Workflow 的执行身份没有被网络策略限制。如果你在内网环境外部地理编码可能根本出不去这时候就换成前面说的「本地城市经纬度索引」方案用elasticsearch.searchstep 替代httpstep。第三个报错是reading choices。这个来自 AI 响应解析阶段意思是模型返回的结构和outputSchema对不上解析器读不到choices字段。常见原因有三个模型返回了 markdown 代码块包裹的 JSON、返回了额外字段、或者字段类型不符。解决办法是收紧 prompt明确要求「只返回 JSON不要 markdown 代码块」同时保持additionalProperties: false。如果换了模型后突然出现这个错多半是新模型对 schema 的遵循度不如旧模型把temperature再调低一点。第四个报错是OAuth相关。如果你用的是需要 OAuth 的 connectortoken 过期后会报 OAuth 错误。去 connector 设置里重新授权即可。这类错误的特点是「昨天还好好的今天突然全挂」遇到这种时间特征先查 token 有效期。第五个报错是 ES|QL 层的Unknown column或Unknown function。Unknown column说明KEEP或WHERE里引用的字段在索引里不存在对照映射检查拼写。Unknown function说明集群版本不支持某个函数比如TO_GEOPOINT或ST_DISTANCE在旧版本上没有。这时候要么升级集群要么改用geo_distance查询但那就不在 ES|QL 里了。还有一个隐蔽的坑distance_meters算出来是 0。这通常是distance字段抽取成了10而不是10 miles导致replace: miles, 没匹配上plus: 0之后还是 0于是WHERE distance 0把所有结果过滤掉了。检查extract_parameters的输出里distance的原始值必要时在 prompt 里明确「distance 保留单位如 10 miles」。排查时有个通用技巧把每个 step 的type临时改成console把上一步的输出打印出来。Workflow 的 step 输出在 Kibana 里本来就能看但改成console后你能在日志里连续看到整条数据流定位断点特别快。定位完再改回原来的type。6. 从 Workflow 到 AI Agent把查询链路接成可对话的工具Workflow 跑通之后最后一步是把它变成一个 AI Agent 能调用的工具。这样用户就不用记 Workflow 的输入格式直接对话就行。这一步在 Kibana 的 Agent 界面里操作不需要写代码。先创建一个 tool。tool 的本质是「告诉 Agent 有这么个能力以及怎么调」。在 Agent 的 tool 配置里把刚才的 Workflow 注册进去描述写清楚它的用途比如「根据自然语言房产需求查询匹配房源输入是一句完整的英文或中文需求描述」。参数就一个user_query类型 string必填。描述写得越具体Agent 越知道什么时候该调它。然后创建一个 Agent把上面这个 tool 加进去。Agent 的 system prompt 可以写「你是一个房产搜索助手用户用自然语言描述需求时调用 real_estate_esql_workflow 工具并把用户的原始描述作为 user_query 传入」。这样 Agent 不会自作主张去改写查询而是原样透传保证抽取质量。测试时用之前那两句Find a home within 10 miles of Miami, Florida that has 2 bedrooms, 2 bathrooms, central air, and tile floors, with a budget up to $300,000.Find a home within 10 miles of DeBary, Florida with 5 bedrooms, at least 2 bathrooms, central air, and a garage, with a budget up to $600,000.Agent 应该会调用 tool然后返回 Workflow 的结果。如果 Agent 没调 tool 而是自己编答案检查 tool 的描述是不是太模糊或者 system prompt 里有没有明确要求「必须调用工具」。到这里整条链路就闭环了自然语言进ES|QL 出结果结构化返回全程没有 Python。你可以在https://taotoken.net/models里对比不同模型在参数抽取上的表现也可以在https://taotoken.net/doc看接入细节。如果要把这套链路接到自己的编码或 Agent 工作流里长期跑https://taotoken.net/coding-plan是个可选的统一入口。最后留一个实用技巧把build_esql_query的输出单独存一份到索引里每次查询都记一条。跑一段时间后你会发现某些查询模式反复出现这时候就可以把它们固化成模板减少 AI 抽取的调用次数既省钱又提速。这套链路的价值不在于「一次跑通」而在于它可观测、可复用、可迭代——出问题能定位到具体 step改一个字段就能适配新业务。
阅读完成 · 觉得有帮助?
咨询建站