
最近,和许多人一样,我们在Elastic也全力投入到聊天、代理和RAG的开发中。在搜索部门,我们最近正在开发一个代理构建器和工具注册系统,旨在让您可以轻松地与Elasticsearch中的数据进行“对话”。
想了解这项工作的“全景”,请阅读使用Elasticsearch构建AI代理工作流的博客;如果想要更实用的入门指南,请查阅您的第一个Elastic代理:从单一查询到AI驱动的聊天。
然而,在这篇文章中,我们将深入探讨开始对话时发生的第一件事,并介绍我们最近所做的一些改进。

当您与Elasticsearch数据进行对话时,我们的默认AI代理会遵循以下标准流程:
这看起来并不新鲜——这就是检索增强生成(RAG)。如您所料,响应的质量在很大程度上取决于初始搜索结果的相关性。因此,当我们致力于提高响应质量时,我们特别关注步骤3中生成的查询和步骤4中运行的查询。我们发现了一个有趣的模式。
通常,当我们的首次响应“不佳”时,并不是因为我们运行了糟糕的查询,而是因为我们选择了错误的索引进行查询。步骤3和4通常不是我们的问题所在——而是步骤2。
我们最初的实现很简单。我们构建了一个工具(称为index_explorer),它会有效地执行_cat/indices来列出所有可用的索引,然后让LLM识别出这些索引中哪个最适合用户的消息/问题/提示。您可以在这里看到这个原始实现。
你是Elasticsearch公司的AI助手。
根据用户的自然语言查询,您的任务是从索引列表中选择最多${limit}个最相关的索引。
*自然语言查询为:* ${nlQuery}
*索引列表:*
${indices.map((index) => `- ${index.index}`).join('\n')}
根据这些信息,请返回最相关的索引并说明理由。
记住,您最多只能选择${limit}个索引。这个方法效果如何呢?我们不太确定!我们有明确的例子表明它效果不佳,但我们面对的第一个挑战是量化我们当前的状态。
我们需要一个黄金数据集来测量工具在给定用户提示和现有索引集时选择正确索引的有效性。但我们手头没有这样的数据集。因此,我们生成了一个。
请注意:我们知道这不是“最佳实践”。但有时候,前进比纠结细节更好。进步,简单完美。
我们使用这个提示为几个不同领域生成了种子索引。然后,为每个生成的领域,我们使用这个提示生成了几个更多的索引(目的是通过困难的负例和难以分类的例子来增加LLM的混淆)。接下来,我们手动编辑了每个生成的索引及其描述。最后,我们使用这个提示生成了测试查询。这样,我们得到了如下的示例数据:

以及测试用例:

接下来的过程非常简单。编写一个工具,可以:
index_explorer工具(很方便,我们有一个执行工具API)。最初的结果并不意外,表现平平。

总体上,识别正确索引的准确率为77.14%。而且这是在“最佳情况”场景下,即所有索引都具有良好、语义上有意义的名称。任何曾经执行过PUT test2/_doc/foo {...}的人都知道,您的索引并不总是有意义的名称。
所以,我们有了一个基准,它显示出许多改进的空间。现在是进行一些科学实验的时候了!🧪
目标是识别一个包含与原始提示相关数据的索引。描述索引所包含数据的最佳部分就是索引的映射。即使不获取任何索引内容样本,知道索引有一个类型为double的价格字段就意味着数据代表某种待售商品。一个类型为text的作者字段则意味着一些非结构化的语言数据。这两者结合可能意味着数据是书籍/故事/诗歌。通过了解索引的属性,我们可以获得很多语义线索。因此,在一个本地分支中,我调整了我们的.index_explorer工具,以便将索引的完整映射(及其名称)发送给LLM以做出决定。
来自Kibana日志的结果:
[2025-09-05T11:01:21.552-05:00][ERROR][plugins.onechat] Error: Error calling connector: event: error
data: {"error":{"code":"request_entity_too_large","message":"Received a content too large status code for request from inference entity id [.rainbow-sprinkles-elastic] status [413]","type":"error"}}
at createInferenceProviderError (errors.ts:90:10)
at convertUpstreamError (convert_upstream_error.ts:39:38)
at handle_connector_response.ts:26:33
at Observable.init [as _subscribe] (/Users/seanstory/Desktop/Dev/kibana/node_modules/rxjs/src/internal/observable/throwError.ts:123:68)...工具的初始作者已经预见到了这一点。虽然索引的映射是信息的金矿,但它也是一个非常冗长的JSON块。在一个现实的场景中,当你比较多个索引时(我们的评估数据集定义了20个),这些JSON块会堆积起来。因此,我们希望为LLM的决策提供比所有选项的索引名称更多的上下文,但不能多到每个索引的完整映射。
我们起初的假设是索引创建者会使用语义上有意义的索引名称。那么如果我们将这一假设扩展到字段名称呢?我们之前的实验失败是因为映射JSON包含了很多无用的元数据和样板代码。
"description_text": { "type": "text", "fields": { "keyword": { "type": "keyword" } }, "copy_to": [ "description_semantic" ] },例如,上述块有236个字符,只定义了Elasticsearch映射中的一个字段。而字符串“description_text”只有16个字符。这几乎是字符数的15倍增加,却没有在描述该字段暗示的数据方面带来有意义的语义改进。假如我们获取所有索引的映射,但在发送给LLM之前,仅将其“平面化”为它们的字段名称列表?
我们尝试了一下。

太好了!全方位的改善。但我们还能做得更好吗?
如果仅有字段名称且没有额外上下文就引起了这么大的提升,那么增加实质性的上下文应该会更好!虽然不是每个索引都有附加描述的惯例,但可以将任何类型的索引级元数据添加到映射的_meta对象中。我们回到生成的索引,并为数据集中的每个索引添加了描述。只要描述不是特别长,它们所用的tokens应该比完整映射更少,并能显著提高对索引中所含数据的理解。我们的实验验证了这一假设。

有适度的提升,现在我们在所有方面的准确率都超过了90%。
字段名称提高了我们的结果。描述也提高了我们的结果。那么,利用描述和字段名称应该会显示出更好的结果,对吧?

数据表示“没有”(与前一个实验没有变化)。这里的主要理论是,由于描述最初是从索引字段/映射生成的,因此在这些上下文片段之间没有足够的不同信息来帮助添加任何“新”的东西。此外,我们为20个测试索引发送的负载变得相当大。到目前为止,我们遵循的思路并不具备可扩展性。实际上,有充分理由相信,到目前为止,我们的任何实验都无法在需要选择成百上千个索引的Elasticsearch集群上工作。任何随着总索引数量的增加而线性增加发送给LLM的信息大小的方法可能都不是一种可以普遍化的策略。
我们真正需要的是一种方法来帮助我们从大量候选项中筛选出最相关的选项……
我们在这里面对的是一个搜索问题。
如果索引的名称具有语义意义,那么可以将其存储为向量并进行语义搜索。
如果索引的字段名称具有语义意义,那么可以将其存储为向量,并进行语义搜索。
如果索引有一个具有语义意义的描述,它也可以存储为向量,并进行语义搜索。
目前,Elasticsearch索引没有使这些信息可搜索(也许我们应该!),但拼凑出一个能解决这个问题的东西其实相当简单。利用Elastic的连接器框架,我构建了一个连接器,它会为集群中的每个索引输出一个文档。输出文档类似于:
doc = {
"_id": index_name,
"index_name": index_name,
"meta_description": description,
"field_descriptions": field_descriptions,
"mapping": json.dumps(mapping),
"source_cluster": self.es_client.configured_host,
}我将这些文档发送到一个新索引,在那里我手动定义了映射为:
{
"mappings": {
"properties": {
"semantic_content": {
"type": "semantic_text"
},
"index_name": {
"type": "text",
"copy_to": "semantic_content"
},
"mapping": {
"type": "keyword",
"copy_to": "semantic_content"
},
"source_cluster": {
"type": "keyword"
},
"meta_description": {
"type": "text",
"copy_to": "semantic_content"
},
"field_descriptions": {
"type": "text",
"copy_to": "semantic_content"
}
}
}
}这会创建一个单一的semantic_content字段,其中每个具有语义意义的字段都会被分块并索引。搜索此索引变得很简单,只需执行:
GET indexed-indices/_search
{
"query": {
"semantic": {
"field": "semantic_content",
"query": "$query"
}
}
}修改后的index_explorer工具现在快得多,因为它不需要向LLM发出请求,而是可以请求给定查询的单个嵌入并执行高效的向量搜索操作。选取命中率最高的结果作为我们选择的索引,我们得到了以下结果:

这种方法是可扩展的。这种方法是高效的。但这种方法仅比我们的基线稍好。这并不意外;这里的搜索方法非常简单。没有细微差别。没有识别出索引的名称和描述应该比索引包含的任意字段名称更重要。没有能力将精确的词汇匹配权重高于同义词匹配。然而,构建一个高度精细的查询需要对手头的数据做出很多假设。到目前为止,我们已经对索引和字段名称具有语义意义做出了一些重大假设,但我们需要进一步假设它们有多大的意义以及它们彼此之间的关系。如果不这样做,我们可能无法可靠地将最佳匹配识别为我们的首选结果,但更有可能说最佳匹配位于前N个结果中。我们需要一些能够在其存在的上下文中消费语义信息的东西,比较与另一个可能以语义上不同的方式呈现自身的实体,并在它们之间进行判断。比如LLM。
还有许多实验我会略过,但关键的突破在于放弃仅仅依赖语义搜索选择最佳匹配的愿望,而是利用语义搜索作为一种过滤器,从LLM的考虑中剔除不相关的索引。我们结合线性检索器、RRF的混合搜索和semantic_text进行搜索,并将结果限制为前5个匹配的索引。
然后,对于每个匹配项,我们将索引的名称、描述和字段名称添加到LLM的信息中。结果非常出色:

这是迄今为止准确率最高的实验!由于这种方法不会使信息量随索引总数的增加而成比例增加,因此这种方法更具可扩展性。

第一个明显的结果是,我们的基线是可以改进的。这在事后看来似乎很明显,但在实验开始之前,我们曾经认真讨论过是否应该完全放弃index_explorer工具,转而依赖用户的显式配置来限制搜索空间。虽然这仍然是一个可行且有效的选项,但这项研究表明,当用户输入不可用时,自动化索引选择仍有希望的前进路径。
下一个明显的结果是,仅仅通过增加描述字符来解决问题的效果有限。在这项研究之前,我们曾在讨论是否应该投资于扩展Elasticsearch存储字段级元数据的能力。如今,这些meta值被限制在50个字符之内,有人认为我们需要增加这个值以便能够从我们的字段中推导出语义理解。显然并非如此,LLM似乎仅靠字段名称就能相当出色。我们可能会在以后进一步调查,但目前这不再显得紧迫。
相反,这提供了明确的证据,证明了拥有“可搜索”索引元数据的重要性。在这些实验中,我们临时构建了一个索引的索引。但这是我们可以直接在Elasticsearch中构建的东西,构建API来管理,或者至少建立一个惯例。我们将权衡选择并进行内部讨论,敬请关注。
最后,这项工作确认了我们花时间进行实验和做出数据驱动决策的价值。事实上,这帮助我们重新确认我们的代理构建器产品需要一些强大的、内置的评估能力。如果我们需要为一个选择索引的工具构建一个完整的测试框架,那么我们的客户在对他们的自定义工具进行迭代调整时,绝对需要有方法来定性评估这些工具。
我很期待看到我们会构建出什么,希望您也是!
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。