我们知道在 DSL 中我们可以轻松地使用 RRF 及线性来针对多路搜索进行混合搜索。我们可以使用如下的命令来进行 RRF 混合搜索:

GET /imdb_movies/_search?pretty
{
  "retriever": {
    "rrf": {
      "retrievers": [
        {
          "standard": {
            "query": {
              "term": {
                "overview": "clueless slackers"
              }
            }
          }
        },
        {
          "knn": {
            "field": "overview_dense",
            "query_vector_builder": {
              "text_embedding": {
                "model_id": ".multilingual-e5-small_linux-x86_64",
                "model_text": "clueless slackers"
              }
            },
            "k": 5,
            "num_candidates": 5
          }
        },
        {
          "standard": {
            "query": {
              "text_expansion": {
                "overview_sparse": {
                  "model_id": ".elser_model_2_linux-x86_64",
                  "model_text": "clueless slackers"
                }
              }
            }
          }
        }
      ],
      "rank_window_size": 5,
      "rank_constant": 1
    }
  },
  "size": 3,
  "fields": [
    "names",
    "overview"
  ],
  "_source": false
}

请详细阅读之前的文章 “Elasticsearch:介绍 retrievers - 搜索一切事物”。在上面,我们使用了 RRF。有关 RRF 的介绍,请阅读文章 “Elasticsearch:倒数排序融合 - Reciprocal rank fusion (RRF)”。

我们也可以使用线性组合来平衡混合搜索。请详细阅读文章 “平衡尺度:利用权重使倒数排序融合 (RRF) 更加智能”。

{
  "retriever": {
    "rrf": {
      "retrievers": [
        {
          "retriever": {
            "standard": {
              "query": {
                "match": {
                  "cuisine_type": "Italian"
                }
              }
            }
          },
          "weight": 0.4
        },
        {
          "retriever": {
            "standard": {
              "query": {
                "match": {
                  "menu_items": "cacio e pepe"
                }
              }
            }
          },
          "weight": 0.6
        }
      ]
    }
  }
}

随着 ES|QL 的推出,越来越多的查询会使用 ES|QL 来做查询,那么我们该如何实现混合搜索呢?

Elastic 在 9.1 中开始推出 FORK 及 FUSE 命令来帮助我们来实现这种混合搜索。

准备数据

我们首先创建如下的数据集:

PUT /people
{
  "mappings": {
    "properties": {
      "id": {
        "type": "integer"
      },
      "name": {
        "type": "text"
      },
      "description": {
        "type": "text",
        "copy_to": "des_semantic"
      },
      "des_semantic": {
        "type": "semantic_text",
        "inference_id": ".multilingual-e5-small-elasticsearch"
      },
      "sex": {
        "type": "keyword"
      },
      "age": {
        "type": "integer"
      },
      "address": {
        "type": "text"
      },
      "location": {
        "type": "geo_point"
      }
    }
  }
}
POST /_bulk
{ "index" : { "_index" : "people", "_id" : "1" } }
{ "id": 1, "name" : "John Doe", "description" : "A software developer", "sex" : "Male", "age" : 30, "address" : "123 Elm Street, Springfield", "location": {"lat": 37.7749, "lon": -122.4194} }
{ "index" : { "_index" : "people", "_id" : "2" } }
{ "id": 2, "name" : "Jane Smith", "description" : "A project manager", "sex" : "Female", "age" : 28, "address" : "456 Maple Avenue, Anytown", "location": {"lat": 40.7128, "lon": -74.0060} }
{ "index" : { "_index" : "people", "_id" : "3" } }
{ "id": 3, "name" : "Alice Johnson", "description" : "A graphic designer", "sex" : "Female", "age" : 26, "address" : "789 Oak Lane, Metropolis", "location": {"lat": 34.0522, "lon": -118.2437} }
{ "index" : { "_index" : "people", "_id" : "4" } }
{ "id": 4, "name" : "Bob Brown", "description" : "A marketing specialist", "sex" : "Male", "age" : 32, "address" : "321 Pine Street, Gotham", "location": {"lat": 41.8781, "lon": -87.6298} }
{ "index" : { "_index" : "people", "_id" : "5" } }
{ "id": 5, "name" : "Charlie Davis", "description" : "An IT analyst", "sex" : "Male", "age" : 29, "address" : "654 Cedar Blvd, Star City", "location": {"lat": 29.7604, "lon": -95.3698} }
{ "index" : { "_index" : "people", "_id" : "6" } }
{ "id": 6, "name" : "Diana Prince", "description" : "A diplomat", "sex" : "Female", "age" : 35, "address" : "987 Birch Road, Themyscira", "location": {"lat": 39.9526, "lon": -75.1652} }
{ "index" : { "_index" : "people", "_id" : "7" } }
{ "id": 7, "name" : "Evan Wright", "description" : "A journalist", "sex" : "Male", "age" : 27, "address" : "213 Willow Lane, Central City", "location": {"lat": 33.4484, "lon": -112.0740} }
{ "index" : { "_index" : "people", "_id" : "8" } }
{ "id": 8, "name" : "Fiona Gallagher", "description" : "A nurse", "sex" : "Female", "age" : 31, "address" : "546 Spruce Street, South Side", "location": {"lat": 32.7157, "lon": -117.1611} }
{ "index" : { "_index" : "people", "_id" : "9" } }
{ "id": 9, "name" : "George King", "description" : "A teacher", "sex" : "Male", "age" : 34, "address" : "879 Elm St, Smallville", "location": {"lat": 39.7392, "lon": -104.9903} }
{ "index" : { "_index" : "people", "_id" : "10" } }
{ "id": 10, "name" : "Helen Parr", "description" : "A full-time superhero", "sex" : "Female", "age": 37, "address" : "123 Metro Avenue, Metroville", "location": {"lat": 47.6062, "lon": -122.3321} }

如上所示,我们的 people 索引含有一个 description 字段。它是 text 类型的。而另外一个字段 des_semantic 其实是一个密集向量。它的类型是 semantic_text。其向量值由 E3 多语言模型产生。

我们可以针对 des_semantic 字段做如下的向量查询:

POST _query?format=txt
{
  "query": """
     FROM people METADATA _score 
     | WHERE MATCH(des_semantic, "coder")
     | SORT _score DESC | LIMIT 2
     | KEEP description
  """
}

我们做如上的查询:

FORK

FORK 处理命令会创建多个执行分支,对相同的输入数据进行操作,并将结果合并为一个输出表。

FORK ( <processing_commands> ) ( <processing_commands> ) ... ( <processing_commands> )

说明

FORK 处理命令会创建多个执行分支,对相同的输入数据进行操作,并将结果合并为一个输出表。会添加一个区分列(_fork)来标识每一行来自哪个分支。

结合 FUSE 命令,FORK 可以实现混合搜索,用来合并并给多个查询的结果打分。想要了解更多关于使用 ES|QL 做搜索的内容,请参考 ES|QL for search。

例子:

POST _query?format=txt
{
  "query": """
     FROM people METADATA _score 
     | FORK (WHERE MATCH(des_semantic, "coder"))
            (WHERE MATCH(description, "journalist"))
     | SORT _score DESC
     | KEEP description
     | LIMIT 2
  """
}

很显然,我们得到了搜索既是 jounalist 也是是 coder 的搜索结果。虽然这种结果是很好,返回了多路查询的结果,但是毕竟 WHERE MATCH(description, "journalist") 使用的是 BM25 的搜索方法,而 HERE MATCH(des_semantic, "coder") 使用的是向量搜索的方法。两种打分的方式是不一样的。相似性的分数在 0-1 之间。

我们再次修改我们的查询方法:

POST _query?format=txt
{
  "query": """
     FROM people METADATA _score 
     | FORK (WHERE MATCH(des_semantic, "coder"))
            (WHERE MATCH(description, "journalist"))
     | KEEP description, _score
     | LIMIT 2
  """
}

在上面,我们保留了 _score 的值:

FUSE 命令

从上面的结果中,我们可以看出来,FORK 命令虽然可以帮我进行多路召回,但是他们各自的打分体现是不同的。我们需要使用一种方法把多路查询的结果来进行统一打分,并最终给出结果。 FUSE 就是为这种混合搜索而生。FUSE 处理命令会合并多个结果集的行,并分配新的相关性分数。

FUSE 与 FORK 命令结合,可以实现混合搜索,用来合并并给多个查询的结果打分。

FUSE 的工作方式包括:

  • 合并具有匹配 <key_columns> 值的行

  • 使用指定的 <fuse_method> 算法,根据 <group_column> 和 <score_column> 的值分配新的相关性分数

提示:FUSE 用于搜索用例:它会合并已排序的结果集并计算相关性。想了解更多 ES|QL 中搜索的工作方式。

示例:

使用 RRF

POST _query?format=txt
{
  "query": """
     FROM people METADATA _id, _index, _score 
     | FORK (WHERE des_semantic: "coder" | SORT _score DESC)
            (WHERE description: "journalist" | SORT _score DESC)
     | FUSE
     | KEEP description, _score
     | LIMIT 2
  """
}

注意:针对上面的搜索,我们必须在 METADATA 里指名 _id, _index 及 _score。否则会有错误!

上面的搜索是针对两种不同的职业来进行搜索的。比较少见。我们直接针对 coder 来进行搜索:

POST _query?format=txt
{
  "query": """
     FROM people METADATA _id, _index, _score 
     | FORK (WHERE des_semantic: "coder" | SORT _score DESC)
            (WHERE description: "coder" | SORT _score DESC)
     | FUSE
     | KEEP description, _score
     | LIMIT 2
  """
}

为了更加清楚地说明问题,我们添加一个 search_type 来展示:

POST _query?format=txt
{
  "query": """
     FROM people METADATA _id, _index, _score 
     | FORK (WHERE des_semantic: "coder" | SORT _score DESC | EVAL search_type = "semantic" )
            (WHERE description: "coder" | SORT _score DESC | EVAL search_type = "bm25")
     | FUSE
     | KEEP description, _score, search_type
     | LIMIT 2
  """
}

我们可以直接查询 software developer:

POST _query?format=txt
{
  "query": """
     FROM people METADATA _id, _index, _score 
     | FORK (WHERE des_semantic: "software developer" | SORT _score DESC | EVAL search_type = "semantic" )
            (WHERE description: "software developer" | SORT _score DESC | EVAL search_type = "bm25")
     | FUSE
     | KEEP description, _score, search_type
     | LIMIT 2
  """
}

使用线性组合

FUSE 也可以使用 线性 分数组合:

POST _query?format=txt
{
  "query": """
     FROM people METADATA _id, _index, _score 
     | FORK (WHERE des_semantic: "software developer" | SORT _score DESC | EVAL search_type = "semantic" )
            (WHERE description: "software developer" | SORT _score DESC | EVAL search_type = "bm25")
     | FUSE LINEAR
     | KEEP description, _score, search_type
     | LIMIT 2
  """
}

我们也可以使用定制的 weights。FUSE 允许你基于 _fork 列的值为分数指定不同的权重,让你可以控制每个查询分支在最终结果中的相对重要性。

POST _query?format=txt
{
  "query": """
     FROM people METADATA _id, _index, _score 
     | FORK (WHERE des_semantic: "software developer" | SORT _score DESC | EVAL search_type = "semantic" )
            (WHERE description: "software developer" | SORT _score DESC | EVAL search_type = "bm25")
     | FUSE LINEAR WITH { "weights": { "fork1": 0.7, "fork2": 0.3 }, "normalizer": "minmax" }
     | KEEP description, _score, search_type
     | LIMIT 2
  """
}

归一化 分数

当使用 线性 组合将语义查询和词法查询的结果合并时,我们建议先对每个结果集的分数进行归一化。

下面的示例使用 minmax 分数归一化。这意味着在合并行之前,分数会归一化为 0 到 1 之间的值:

POST _query?format=txt
{
  "query": """
     FROM people METADATA _id, _index, _score 
     | FORK (WHERE des_semantic: "software developer" | SORT _score DESC | EVAL search_type = "semantic" )
            (WHERE description: "software developer" | SORT _score DESC | EVAL search_type = "bm25")
     | FUSE LINEAR WITH { "normalizer": "minmax" }
     | KEEP description, _score, search_type
     | LIMIT 2
  """
}

Logo

助力广东及东莞地区开发者,代码托管、在线学习与竞赛、技术交流与分享、资源共享、职业发展,成为松山湖开发者首选的工作与学习平台

更多推荐