在Elasticsearch中通过pHash距离搜索类似的图像

时间:2015-09-25 15:37:48

标签: image elasticsearch hamming-distance phash

类似图片搜索问题

  • 数百万张图片pHash已存储并存储在Elasticsearch中。
  • 格式为" 11001101 ... 11" (长度64),但可以改变(最好不要)。

给定主题图像" 100111..10"我们希望在海明距离为8 的Elasticsearch索引中找到所有类似的图像哈希值。

当然,查询可以返回距离大于8的图像,Elasticsearch或外部的脚本可以过滤结果集。但总搜索时间必须在1秒左右。

我们当前的映射

每个文档都有嵌套的images字段,其中包含图像哈希:

{
  "images": {
    "type": "nested", 
    "properties": {
      "pHashFingerprint": {"index": "not_analysed", "type": "string"}
    }
  }
}

我们糟糕的解决方案

事实: Elasticsearch模糊查询仅支持最大2的Levenshtein距离。

我们使用自定义标记生成器将64位字符串拆分为4组16位,并使用4个模糊查询进行4组搜索。

分析仪:

{
   "analysis": {
      "analyzer": {
         "split4_fingerprint_analyzer": {
            "type": "custom",
            "tokenizer": "split4_fingerprint_tokenizer"
         }
      },
      "tokenizer": {
         "split4_fingerprint_tokenizer": {
            "type": "pattern",
            "group": 0,
            "pattern": "([01]{16})"
         }
      }
   }
}

然后新的字段映射:

"index_analyzer": "split4_fingerprint_analyzer",

然后查询:

{
   "query": {
      "filtered": {
         "query": {
            "nested": {
               "path": "images",
               "query": {
                  "bool": {
                     "minimum_should_match": 2,
                     "should": [
                        {
                           "fuzzy": {
                              "phashFingerprint.split4": {
                                 "value": "0010100100111001",
                                 "fuzziness": 2
                              }
                           }
                        },
                        {
                           "fuzzy": {
                              "phashFingerprint.split4": {
                                 "value": "1010100100111001",
                                 "fuzziness": 2
                              }
                           }
                        },
                        {
                           "fuzzy": {
                              "phashFingerprint.split4": {
                                 "value": "0110100100111001",
                                 "fuzziness": 2
                              }
                           }
                        },
                        {
                           "fuzzy": {
                              "phashFingerprint.split4": {
                                 "value": "1110100100111001",
                                 "fuzziness": 2
                              }
                           }
                        }
                     ]
                  }
               }
            }
         },
         "filter": {}
      }
   }
}

请注意,我们会返回具有匹配图像的文档,而不是图像本身,但这不会改变很多事情。

问题是,即使在添加其他特定于域的过滤器以减少初始设置之后,此查询也会返回数十万个结果。脚本有太多的工作来再次计算汉明距离,因此查询可能需要几分钟。

正如预期的那样,如果将minimum_should_match增加到3和4,则只返回必须找到的图像子集,但结果集很小且很快。 95%的所需图片会在minimum_should_match == 3时返回,但我们需要100%(或99.9%)与minimum_should_match == 2一样。

我们用n-gram尝试了类似的方法,但仍然没有太多成功的类似方式。

其他数据结构和查询的任何解决方案?

修改

我们注意到,我们的评估过程中存在错误,minimum_should_match == 2会返回100%的结果。但是,之后的处理时间平均为5秒。我们将看看脚本是否值得优化。

7 个答案:

答案 0 :(得分:16)

我已经模拟并实现了一个可能的解决方案,避免了所有昂贵的“模糊”查询。而是在索引时,您从这64位中取NM位的随机样本。我想这是Locality-sensitive hashing的一个例子。因此,对于每个文档(以及查询时),始终从相同的位位置获取样本编号x,以便在文档之间进行一致的散列。

查询使用term的{​​{1}}子句中的bool query过滤器,其should阈值相对较低。较低的阈值对应于较高的“模糊性”。不幸的是,您需要重新索引所有图像以测试此方法。

我认为minimum_should_match个查询效果不佳,因为平均每个过滤条件都匹配{ "term": { "phash.0": true } }个文档。每个样本使用16位/样本匹配50%个文档。

我使用以下设置运行测试:

  • 1024个样本/哈希(存储到文档字段2^-16 = 0.0015% - "0"
  • 16位/样本(存储到"ff"类型,short
  • 4个分片和100万个哈希/索引,大约17.6 GB的存储空间(可以通过不存储doc_values = true和样本来最小化,只有原始的二进制哈希值)
  • _source = 150(满分1024)
  • 基准400万个文档(4个索引)

您可以使用更少的样本获得更快的速度和更低的磁盘使用率,但是汉明距离8和9之间的文档分离得不是很好(根据我的模拟)。 1024似乎是minimum_should_match子句的最大数量。

测试在单个Core i5 3570K,24 GB RAM,8 GB for ES,版本1.7.1上运行。来自500个查询的结果(请参阅下面的说明,结果过于乐观)

should

我将测试它如何扩展到1500万个文档,但每个索引生成和存储100万个文档需要3个小时。

你应该测试或计算你应该设置Mean time: 221.330 ms Mean docs: 197 Percentiles: 1st = 140.51ms 5th = 150.17ms 25th = 172.29ms 50th = 207.92ms 75th = 233.25ms 95th = 296.27ms 99th = 533.88ms 的低点,以便在错过的匹配和不正确的匹配之间获得所需的权衡,这取决于哈希的分布。

示例查询(显示1024个字段中的3个):

minimum_should_match

编辑:当我开始做进一步的基准测试时,我注意到我已经为不同的索引生成了太不相似的哈希值,因此从那些搜索中搜索导致零匹配。新生成的文档会产生大约150 - 250个匹配/索引/查询,并且应该更加真实。

之前的图表中显示了新的结果,我有ES的4 GB内存和OS的剩余20 GB。搜索1 - 3个索引具有良好的性能(中位时间0.1 - 0.2秒),但搜索超过这个导致大量的磁盘IO和查询开始需要9 - 11秒!这可以通过减少散列样本来规避,但随后召回并且精确率不会那么好,或者你可以拥有一台64 GB RAM的机器,看看你能得到多远。

Percentiles of query times (in ms) for varying number of indexes searched.

编辑2:我使用{ "bool": { "should": [ { "filtered": { "filter": { "term": { "0": -12094, "_cache": false } } } }, { "filtered": { "filter": { "term": { "_cache": false, "1": -20275 } } } }, { "filtered": { "filter": { "term": { "ff": 15724, "_cache": false } } } } ], "minimum_should_match": 150 } } 重新生成数据而不存储哈希样本(仅原始哈希),这将存储空间减少了60%,达到约6.7 GB /索引( 100万个文档)。这不会影响较小数据集的查询速度,但是当RAM不足并且必须使用磁盘时,查询速度提高了大约40%。

Percentiles of query times (in ms) for varying number of indexes searched.

编辑3:我在一组3000万个文档中测试了_source: false搜索,编辑距离为2,并将其与256个随机哈希样本进行比较,以获得近似结果。在这些条件下,方法的速度大致相同,但fuzzy给出了精确的结果,并且不需要额外的磁盘空间。我认为这种方法仅适用于“非常模糊”的查询,例如汉明距离大于3。

答案 1 :(得分:8)

即使在笔记本电脑的GeForce 650M显卡上,我也实现了CUDA方法并取得了一些不错的效果。使用Thrust库可以轻松实现。我希望代码没有错误(我没有彻底测试它),但它不应该影响基准测试结果。至少我在停止high-precision timer之前致电thrust::system::cuda::detail::synchronize()

typedef unsigned __int32 uint32_t;
typedef unsigned __int64 uint64_t;

// Maybe there is a simple 64-bit solution out there?
__host__ __device__ inline int hammingWeight(uint32_t v)
{
    v = v - ((v>>1) & 0x55555555);
    v = (v & 0x33333333) + ((v>>2) & 0x33333333);

    return ((v + (v>>4) & 0xF0F0F0F) * 0x1010101) >> 24;
}

__host__ __device__ inline int hammingDistance(const uint64_t a, const uint64_t b)
{
    const uint64_t delta = a ^ b;
    return hammingWeight(delta & 0xffffffffULL) + hammingWeight(delta >> 32);
}

struct HammingDistanceFilter
{
    const uint64_t _target, _maxDistance;

    HammingDistanceFilter(const uint64_t target, const uint64_t maxDistance) :
            _target(target), _maxDistance(maxDistance) {
    }

    __host__ __device__ bool operator()(const uint64_t hash) {
        return hammingDistance(_target, hash) <= _maxDistance;
    }
};

线性搜索就像

一样简单
thrust::copy_if(
    hashesGpu.cbegin(), hashesGpu.cend(), matchesGpu.begin(),
    HammingDistanceFilter(target_hash, maxDistance)
)

搜索是100%准确且比我的ElasticSearch答案更快,在50毫秒内CUDA可以流过3500万个哈希值!我确定较新的桌面卡比这更快。当我们浏览越来越多的数据时,我们也会获得非常低的方差和一致的搜索时间线性增长。由于采样数据膨胀,ElasticSearch在较大的查询中遇到了错误的内存问题。

所以我在这里报告&#34;从这些N个哈希的结果,找到距离单个哈希H&#34;在8汉明距离内的那些哈希值。我跑了500次并报告了百分位数。

Search performance

有一些内核启动开销,但在搜索空间超过500万次哈希后,搜索速度相当稳定,达到7亿哈希/秒。当然,要搜索的散列数的上限由GPU的RAM设置。

Search performance

更新:我在GTX 1060上重新运行测试,每秒扫描大约3800万次哈希:)

答案 2 :(得分:4)

我自己开始解决这个问题了。到目前为止,我只对大约380万份文档的数据集进行了测试,我打算将其推高到数百万以上。

到目前为止我的解决方案是:

编写本机评分函数并将其注册为插件。然后在查询时调用它来调整文档的_score值。

作为一个时髦的脚本,运行自定义评分函数所花费的时间非常不起眼,但将其作为本机评分函数编写(如这篇有点陈旧的博客帖子所示:http://www.spacevatican.org/2012/5/12/elasticsearch-native-scripts-for-dummies/)的速度要快几个数量级

我的HammingDistanceScript看起来像这样:

public class HammingDistanceScript extends AbstractFloatSearchScript {

    private String field;
    private String hash;
    private int length;

    public HammingDistanceScript(Map<String, Object> params) {
        super();
        field = (String) params.get("param_field");
        hash = (String) params.get("param_hash");
        if(hash != null){
            length = hash.length() * 8;
        }
    }

    private int hammingDistance(CharSequence lhs, CharSequence rhs){          
        return length - new BigInteger(lhs, 16).xor(new BigInteger(rhs, 16)).bitCount();
    }

    @Override
    public float runAsFloat() {
        String fieldValue = ((ScriptDocValues.Strings) doc().get(field)).getValue();
        //Serious arse covering:
        if(hash == null || fieldValue == null || fieldValue.length() != hash.length()){
            return 0.0f;
        }

        return hammingDistance(fieldValue, hash);
    }
}

此时值得一提的是,我的哈希值是十六进制编码的二进制字符串。所以,和你的一样,但是十六进制编码以减少存储空间。

另外,我期待一个param_field参数,它可以识别我想要对汉明距离做哪个字段值。您不需要这样做,但我在多个字段中使用相同的脚本,所以我这样做:)

我在这样的查询中使用它:

curl -XPOST 'http://localhost:9200/scf/_search?pretty' -d '{
  "query": {
    "function_score": {     
      "min_score": MY IDEAL MIN SCORE HERE,
      "query":{
       "match_all":{}
      },
      "functions": [
        {
          "script_score": {
            "script": "hamming_distance",
            "lang" : "native",
            "params": {
              "param_hash": "HASH TO COMPARE WITH",
              "param_field":"phash"
            }
          }
        }
      ]
    }
  }
}'

我希望这在某种程度上有所帮助!

如果您走这条路线可能对您有用的其他信息:

<强> 1。请记住es-plugin.properties文件
这必须编译到你的jar文件的根目录中(如果你把它放在/ src / main / resources中然后构建你的jar它会去正确的地方)。

我看起来像这样:

plugin=com.example.elasticsearch.plugins.HammingDistancePlugin
name=hamming_distance
version=0.1.0
jvm=true
classname=com.example.elasticsearch.plugins.HammingDistancePlugin
java.version=1.7
elasticsearch.version=1.7.3

<强> 2。在elasticsearch.yml中引用自定义NativeScriptFactory impl
就像在老年博客上一样。

我看起来像这样:

script.native:
    hamming_distance.type: com.example.elasticsearch.plugins.HammingDistanceScriptFactory

如果你不这样做,它仍会出现在插件列表中(见下文),但是当你尝试使用弹性搜索无法找到它时,你会收到错误。

第3。不要使用elasticsearch插件脚本来安装它
它只是一个痛苦的屁股,它似乎只是解开你的东西 - 有点无意义。相反,只需将其粘贴在%ELASTICSEARCH_HOME%/plugins/hamming_distance中 并重启elasticsearch。

如果一切顺利,你会在elasticsearch startup上看到它被加载:

[2016-02-09 12:02:43,765][INFO ][plugins                  ] [Junta] loaded [mapper-attachments, marvel, knapsack-1.7.2.0-954d066, hamming_distance, euclidean_distance, cloud-aws], sites [marvel, bigdesk]

当你拨打插件列表时,它会在那里:

curl http://localhost:9200/_cat/plugins?v

产生类似的东西:

name        component                version type url
Junta       hamming_distance         0.1.0   j

我希望能够在接下来的一周内测试数以万计的文件。如果有帮助的话,我会尝试并记住弹出并用结果更新它。

答案 3 :(得分:1)

这是一个不优雅但非常精确的(暴力)解决方案,需要将您的功能哈希解构为单独的布尔字段,以便您可以运行如下查询:

"query": {
    "bool": {
      "minimum_should_match": -8,
      "should": [
          { "term": { "phash.0": true } },
          { "term": { "phash.1": false } },
          ...
          { "term": { "phash.63": true } }
        ]
    }
}

我不确定这会如何与fuzzy_like_this相比,但 FLT 实现被弃用的原因是它必须访问索引中的每个术语来计算编辑距离

(在此处/上方,您正在利用Lucene的底层反向索引数据结构和优化的集合操作,应该对您有利,因为您可能已经相当稀疏功能)

答案 4 :(得分:1)

我使用@ndtreviv's答案作为起点。以下是我对ElasticSearch 2.3.3的说明:

  1. es-plugin.properties文件现在称为plugin-descriptor.properties

  2. 您没有在NativeScriptFactory中引用elasticsearch.yml,而是在HammingDistanceScript旁边创建了一个额外的课程。

  3. import org.elasticsearch.common.Nullable;
    import org.elasticsearch.plugins.Plugin;
    import org.elasticsearch.script.ExecutableScript;
    import org.elasticsearch.script.NativeScriptFactory;
    import org.elasticsearch.script.ScriptModule;
    
    import java.util.Map;
    
    public class StringMetricsPlugin extends Plugin {
        @Override
        public String name() {
            return "string-metrics";
        }
    
        @Override
        public  String description() {
            return "";
        }
    
        public void onModule(ScriptModule module) {
            module.registerScript("hamming-distance", HammingDistanceScriptFactory.class);
        }
    
        public static class HammingDistanceScriptFactory implements NativeScriptFactory {
            @Override
            public ExecutableScript newScript(@Nullable Map<String, Object> params) {
                return new HammingDistanceScript(params);
            }
            @Override
            public boolean needsScores() {
                return false;
            }
        }
    }
    
    1. 然后在plugin-descriptor.properties文件中引用此课程:
    2. plugin=com.example.elasticsearch.plugins. StringMetricsPlugin
      name=string-metrics
      version=0.1.0
      jvm=true
      classname=com.example.elasticsearch.plugins.StringMetricsPlugin
      java.version=1.8
      elasticsearch.version=2.3.3
      
      1. 您可以通过提供此行中使用的名称进行查询:module.registerScript("hamming-distance", HammingDistanceScriptFactory.class); in 2。
      2. 希望这有助于下一个必须处理糟糕的ES文档的可怜的灵魂。

答案 5 :(得分:1)

以下是@NikoNyrh's答案的64位解决方案。汉明距离可以通过使用具有内置__popcll CUDA函数的XOR运算符来计算。

struct HammingDistanceFilter
{
    const uint64_t _target, _maxDistance;

    HammingDistanceFilter(const uint64_t target, const uint64_t maxDistance) :
            _target(target), _maxDistance(maxDistance) {
    }

    __device__ bool operator()(const uint64_t hash) {
        return __popcll(_target ^ hash) <= _maxDistance;
    }
};

答案 6 :(得分:1)

最近从[1]中提出的FENSHSES方法似乎是在Elasticsearch的汉明空间中进行r邻域搜索的最新方法。

[1] Mu,C,Zhao,J.,Yang,G.,Yang,B. and Yan,Z.,2019年10月。在全文搜索引擎上的汉明空间中进行快速,精确的最近邻搜索。在关于相似性搜索和应用的国际会议上(第49-56页)。湛史普林格。