web-dev-qa-db-ja.com

ElasticsearchでのpHash距離による類似画像検索

類似画像検索の問題

  • 何百万もの画像 pHash 'edおよびElasticsearchに保存されます。
  • 形式は「11001101 ... 11」(長さ64)ですが、変更できます(変更はできません)。

対象画像のハッシュ "100111..10"が与えられた場合、Elasticsearchインデックスで類似するすべての画像ハッシュを検索します8のハミング距離内

もちろん、クエリは8を超える距離の画像を返すことができ、Elasticsearchまたは外部のスクリプトは結果セットをフィルタリングできます。ただし、合計検索時間は1秒以内でなければなりません。

現在のマッピング

各ドキュメントには、画像ハッシュを含むネストされたimagesフィールドがあります。

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

私たちの貧弱な解決策

Fact: Elasticsearchファジークエリは、最大2のレーベンシュタイン距離のみをサポートします。

カスタムトークナイザーを使用して、64ビット文字列を16ビットの4つのグループに分割し、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で返されますが、minimum_should_match == 2と同様に100%(または99.9%)が必要です。

N-gramを使用して同様のアプローチを試みましたが、結果が多すぎるという同様の方法ではまだあまり成功していません。

他のデータ構造とクエリのソリューションはありますか?

Edit

評価プロセスにバグがあり、minimum_should_match == 2が結果の100%を返すことに気付きました。ただし、その後の処理時間は平均で5秒かかります。スクリプトを最適化する価値があるかどうかを確認します。

36

すべての高価な「ファジー」クエリを回避する可能なソリューションをシミュレートして実装しました。代わりに、インデックス時に、これらの64ビットからNビットのMランダムサンプルを取得します。これは Locality-sensitive hashing の例だと思います。そのため、ドキュメントごと(およびクエリ時)に、サンプル全体で一貫したハッシュを得るために、サンプルのnumber xは常に同じビット位置から取得されます。

クエリは、bool queryterm句で、比較的低いminimum_should_matchしきい値でshouldフィルターを使用します。低いしきい値は、高い「あいまいさ」に対応します。残念ながら、このアプローチをテストするには、すべての画像のインデックスを再作成する必要があります。

{ "term": { "phash.0": true } }クエリは、各フィルターが平均してドキュメントの50%に一致するため、うまく機能しなかったと思います。 16ビット/サンプルでは、​​各サンプルは2^-16 = 0.0015%ドキュメントに一致します。

次の設定でテストを実行します。

  • 1024サンプル/ハッシュ(ドキュメントフィールド"0"-"ff"に保存)
  • 16ビット/サンプル(shortタイプ、doc_values = trueに保存)
  • 4個の断片と100万個のハッシュ/インデックス、約17.6 GBのストレージ(元のバイナリハッシュのみを_sourceとサンプルを保存しないことで最小化できます)
  • minimum_should_match = 150(1024のうち)
  • 400万のドキュメント(4つのインデックス)でベンチマーク

少ないサンプルで高速化とディスク使用量の削減を実現できますが、ハミング距離が8〜9のドキュメントはそれほど分離されていません(私のシミュレーションによると)。 1024がshould句の最大数のようです。

テストは、シングルCore i5 3570K、24 GB RAM、ES用8 GB、バージョン1.7.1で実行されました。 500件のクエリの結果(以下の注を参照、結果は楽観的すぎます)

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

これが1500万文書にどのようにスケーリングするかをテストしますが、100万文書を生成して各インデックスに保存するには3時間かかります。

minimum_should_matchをどの程度低く設定する必要があるかをテストまたは計算して、一致の不一致と不一致の望ましいトレードオフを取得する必要があります。これはハッシュの分布に依存します。

クエリの例(1024フィールドのうち3フィールドが表示されています):

{
  "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
  }
}

編集:さらにベンチマークを開始すると、異なるインデックスに対してあまりにも異なるハッシュを生成していることに気づいたため、それらから検索すると一致がゼロになりました。新しく生成されたドキュメントは、約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:_source: falseでデータを再生成し、ハッシュサンプル(生のハッシュのみ)を保存せず、これによりストレージスペースが60削減されました%〜約6.7 GB /インデックス(100万ドキュメントの)。これは、小さなデータセットでのクエリ速度には影響しませんでしたが、RAMでは十分ではなく、ディスクを使用する必要があったクエリは約40%高速でした。

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

編集3:3,000万のドキュメントのセットでfuzzy検索を編集距離2でテストし、それを256個のランダムサンプルと比較しましたおおよその結果を得るためのハッシュの。これらの条件下では、メソッドはほぼ同じ速度ですが、fuzzyは正確な結果を提供し、追加のディスク容量は必要ありません。このアプローチは、ハミング距離が3を超えるような「非常にあいまいな」クエリにのみ役立つと思います。

16
NikoNyrh

また、ラップトップのGeForce 650Mグラフィックスカードでも、CUDAアプローチを実装していくつかの良い結果を得ました。 Thrust ライブラリで実装は簡単でした。コードにバグがないことを願っています(徹底的にテストしていません)が、ベンチマークの結果には影響しないはずです。少なくとも、 高精度タイマー を停止する前に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)
)

検索はElasticSearchの回答よりも100%正確で、はるかに高速でした。50ミリ秒でCUDAは3,500万ハッシュをストリーミングできました。新しいデスクトップカードはこれよりもはるかに高速であると確信しています。また、より多くのデータを処理するにつれて、非常に低い分散と一貫した検索時間の線形増加が得られます。 ElasticSearchは、サンプリングデータが増大するため、大規模なクエリでメモリの問題が発生します。

そこで、ここでは「これらのN個のハッシュから、単一のハッシュHから8ハミング距離以内にあるものを見つけてください」という結果を報告しています。これらを500回実行し、パーセンタイルを報告しました。

Search performance

カーネル起動のオーバーヘッドがいくらかありますが、検索スペースが500万ハッシュを超えた後、検索速度は7億ハッシュ/秒とかなり一貫しています。当然、検索されるハッシュの数の上限はGPUのRAMによって設定されます。

Search performance

更新:GTX 1060でテストを再実行すると、1秒間に約38億のハッシュがスキャンされます:)

10
NikoNyrh

私はこれに対する解決策を自分で始めました。私はこれまでに約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);
    }
}

この時点で、私のハッシュは16進エンコードされたバイナリ文字列であることに言及する価値があります。だから、あなたのものと同じですが、ストレージサイズを減らすために16進エンコードされています。

また、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

これを行わない場合、プラグインリストには引き続き表示されます(後述)が、それを使用しようとすると、elasticsearchが見つからないというエラーが表示されます。

3。 elasticsearchプラグインスクリプトを使用してインストールしないでください
それはお尻が痛いだけで、荷物を開梱するだけのようです-少し無意味です。代わりに、%ELASTICSEARCH_HOME%/plugins/hamming_distanceに貼り付けて、elasticsearchを再起動します。

すべてがうまくいった場合は、elasticsearchの起動時にロードされていることがわかります。

[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

私は来週かそこらで数千万件以上の文書に対してテストできると期待しています。役立つ場合は、結果を表示してこれを更新して更新することを忘れないでください。

4
ndtreviv

@ ndtreviv's answerを出発点として使用しました。 ElasticSearch 2.3.3に関する注意事項は次のとおりです。

  1. _es-plugin.properties_ファイルは_plugin-descriptor.properties_という名前になりました

  2. _elasticsearch.yml_でNativeScriptFactoryを参照せず、代わりにHammingDistanceScriptの隣に追加のクラスを作成します。


_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_ファイルでこのクラスを参照します。

_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。

これが、くだらないESドキュメントを処理しなければならない次の貧しい人々の助けになることを願っています。

2
mirosval

エレガントではありませんが、正確な(ブルートフォース)ソリューションです。フィーチャハッシュを個々のブールフィールドに分解して、次のようなクエリを実行できます。

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

これがどのように実行されるかfuzzy_like_thisがどうなるかはわかりませんが、[〜#〜] flt [〜#〜]実装が廃止されている理由編集距離を計算するためにインデックス内のすべての用語にアクセスする必要があるということです。

(一方、ここ/上記では、Luceneの基礎となる逆索引データ構造と、shouldが有利に機能する最適化された集合演算を活用しています。おそらくかなりまばらな機能を持っていると仮定して)

2

@ NikoNyrh's answerの64ビットソリューションです。ハミング距離は、XOR演算子を組み込み __ popcll CUDAの関数と共に使用するだけで計算できます。

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;
    }
};
1
Bahadir