在 elasticsearch 过滤器中实现 Array.Except(Array2) > 0 查询?

Implementing Array.Except(Array2) > 0 query in elasticsearch filter?

假设我将以下文档编入索引:

[
    {
        "Id": 1,
        "Numbers": [1, 2, 3]
    },
    {
        "Id": 2,
        "Numbers": [4, 5]
    }    
]

我有一个参数 [1,2,4,5],它定义了我不允许看到的数字 - 我想找到 "Numbers" 数组至少包含一个元素不在我的文档中的文档输入数组(因此在本例中应返回第一个文档)。

真实场景用于查找不包含属于特定产品类型的产品的组(或谁的子组)。我已经递归索引了产品类型 ID(在示例中表示为数字),我想找到包含不属于我的输入参数的产品的组(我的输入参数是我不允许看到的产品类型 ID 数组)

我应该使用哪个 query/filter 以及它应该如何构建?我考虑了以下几点:

        return desc.Bool(b => b
            .MustNot(mn => mn.Bool(mnb => mnb.Must(mnbm => mnbm.Terms(t => t.ItemGroups, permissions.RestrictedItemGroups) && mnbm.Term(t => t.ItemGroupCount, permissions.RestrictedItemGroups.Count())))));

但问题是如果我有 6 个受限项目组,其中一个给定组包含 3 个受限组,那么我将找不到任何匹配项,因为计数不匹配。现在这很有意义。作为一种解决方法,我在 C# 中实现了 Results.Except(Restricted) 以过滤出受限的组 post-search,但我很乐意在 elasticsearch 中实现它。

新答案

我将在下面留下较旧的答案,因为它可能对其他人有用。在您的情况下,您想过滤掉不匹配的文档,而不仅仅是标记它们。所以,下面的查询会得到你所期望的,即只有第一个文档:

POST test/_search
{
  "query": {
    "script": {
      "script": {
        "source": """
          // copy the doc values into a temporary list
          def tmp = new ArrayList(doc.Numbers.values);

          // remove all ids from the params
          tmp.removeIf(n -> params.ids.contains((int)n));

          // return true if the array still contains ids, false if not
          return tmp.size() > 0;
        """,
        "params": {
          "ids": [
            1,
            2,
            4,
            5
          ]
        }
      }
    }
  }
}

较早的回答

解决此问题的一种方法是使用脚本字段,该字段将 return 真或假取决于您的条件:

POST test/_search
{
  "_source": true,
  "script_fields": {
    "not_present": {
      "script": {
        "source": """
      // copy the numbers array
      def tmp = params._source.Numbers;

      // remove all ids from the params
      tmp.removeIf(n -> params.ids.contains(n));

      // return true if the array still contains data, false if not
      return tmp.length > 0;
""",
        "params": {
          "ids": [ 1, 2, 4, 5 ]
        }
      }
    }
  }
}

结果如下所示:

  "hits" : {
    "total" : 2,
    "max_score" : 1.0,
    "hits" : [
      {
        "_index" : "test",
        "_type" : "doc",
        "_id" : "2",
        "_score" : 1.0,
        "_source" : {
          "Id" : 2,
          "Numbers" : [
            4,
            5
          ]
        },
        "fields" : {
          "not_present" : [
            false                           <--- you don't want this doc
          ]
        }
      },
      {
        "_index" : "test",
        "_type" : "doc",
        "_id" : "1",
        "_score" : 1.0,
        "_source" : {
          "Id" : 1,
          "Numbers" : [
            1,
            2,
            3
          ]
        },
        "fields" : {
          "not_present" : [
            true                            <--- you want this one, though
          ]
        }
      }
    ]
  }
}

terms_set query 似乎很适合这个;它类似于 terms 查询,但有一个额外的区别,即您可以指定有多少术语必须与从输入或每个文档派生的动态值匹配。

在您的情况下,您希望获得文档的倒数,其中 Numbers 数组中的所有数字都在输入项中,即如果 Numbers 数组包含至少一个值,即不在输入项中,则应视为匹配。

像下面这样的东西会起作用

private static void Main()
{
    var defaultIndex = "my_index";
    var pool = new SingleNodeConnectionPool(new Uri("http://localhost:9200"));

    var settings = new ConnectionSettings(pool)
        .DefaultIndex(defaultIndex)
        .DefaultFieldNameInferrer(f => f);

    var client = new ElasticClient(settings);

    if (client.IndexExists(defaultIndex).Exists)
        client.DeleteIndex(defaultIndex);

    var createIndexResponse = client.CreateIndex(defaultIndex, c => c
        .Settings(s => s
            .NumberOfShards(1)
            .NumberOfReplicas(0)
        )
        .Mappings(m => m
            .Map<MyDocument>(mm => mm.AutoMap())
        )
    );

    var bulkResponse = client.Bulk(b => b
        .IndexMany(new []
        {
            new MyDocument { Id = 1, Numbers = new int[] { 1, 2, 3 }},
            new MyDocument { Id = 2, Numbers = new int[] { 4, 5 }},
            new MyDocument { Id = 3, Numbers = new int[] { }},
        })
        .Refresh(Refresh.WaitFor)
    );

    var searchResponse = client.Search<MyDocument>(s => s
        .Query(q => (!q
            .TermsSet(ts => ts
                .Field(f => f.Numbers)
                .Terms(1, 2, 4, 5)
                .MinimumShouldMatchScript(sc => sc
                    .Source("doc['Numbers'].size()")
                )
            )) && q
            .Exists(ex => ex
                .Field(f => f.Numbers)
            )
        )
    );
}

public class MyDocument 
{
    public int Id { get; set; }
    public int[] Numbers { get; set; }
}

生成的搜索请求看起来像

{
  "query": {
    "bool": {
      "must": [
        {
          "exists": {
            "field": "Numbers"
          }
        }
      ],
      "must_not": [
        {
          "terms_set": {
            "Numbers": {
              "minimum_should_match_script": {
                "source": "doc['Numbers'].size()"
              },
              "terms": [
                1,
                2,
                4,
                5
              ]
            }
          }
        }
      ]
    }
  }
}

结果是

{
  "took" : 3,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : 1,
    "max_score" : 1.0,
    "hits" : [
      {
        "_index" : "my_index",
        "_type" : "mydocument",
        "_id" : "1",
        "_score" : 1.0,
        "_source" : {
          "Id" : 1,
          "Numbers" : [
            1,
            2,
            3
          ]
        }
      }
    ]
  }
}

terms_set 查询位于 must_not 子句中以反转匹配项,其中 Numbers 中的所有值都在术语输入中,并与 exists 查询相结合在 Numbers 上排除没有 Numbers 值的文档,如 ID 为 3 的示例文档。

这可以通过在文档的另一个字段中索引 Numbers 数组的长度,然后使用 MinimumShouldMatchField(...) 而不是脚本来更好地执行。只需要确保这两个属性然后保持同步,这在具有 属性 getter returns Numbers 数组的 C# POCO 中相当容易做到长度值。