web-dev-qa-db-fra.com

Recherche d'images similaires par distance pHash dans Elasticsearch

Problème de recherche d'images similaire

  • Des millions d'images pHash 'ed et stockées dans Elasticsearch.
  • Le format est "11001101 ... 11" (longueur 64), mais peut être modifié (mieux ne pas).

Le hachage "100111..10" de l'image de sujet étant donné, nous souhaitons trouver tous les hachages d'images similaires dans l'index Elasticsearch dans un rayon de 8.

Bien entendu, la requête peut renvoyer des images avec une distance supérieure à 8 et le script dans Elasticsearch ou à l'extérieur peut filtrer le jeu de résultats. Mais le temps total de recherche doit être d’environ 1 seconde.

Notre cartographie actuelle

Chaque document possède un champ images imbriqué contenant des hachages d'image:

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

Notre mauvaise solution

Fait: La requête floue Elasticsearch prend en charge la distance de Levenshtein de 2 au maximum. 

Nous avons utilisé un tokenizer personnalisé pour scinder une chaîne de 64 bits en 4 groupes de 16 bits et effectuer une recherche de 4 groupes avec quatre requêtes floues.

Analyseur:

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

Puis nouvelle cartographie de terrain:

"index_analyzer": "split4_fingerprint_analyzer",

Puis interroger:

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

Notez que nous renvoyons les documents dont les images correspondent, pas les images elles-mêmes, mais cela ne devrait pas trop changer les choses.

Le problème est que cette requête renvoie des centaines de milliers de résultats même après avoir ajouté d'autres filtres spécifiques au domaine afin de réduire l'ensemble initial. Le script a trop de travail pour calculer à nouveau la distance, alors la requête peut prendre quelques minutes.

Comme prévu, si minimum_should_match est augmenté à 3 et 4, seul un sous-ensemble d'images à rechercher est renvoyé, mais l'ensemble résultant est petit et rapide. Au-dessous de 95% des images nécessaires sont retournées avec minimum_should_match == 3 mais nous avons besoin de 100% (ou 99,9%) comme avec minimum_should_match == 2.

Nous avons essayé des approches similaires avec n-grammes, mais nous n’avons toujours pas beaucoup de succès de la même manière, avec trop de résultats.

Des solutions d'autres structures de données et des requêtes?

Modifier:

Nous avons remarqué qu'il y avait un bogue dans notre processus d'évaluation et minimum_should_match == 2 renvoie 100% des résultats. Cependant, le temps de traitement suivant prend en moyenne 5 secondes. Nous verrons si le script mérite d'être optimisé.

31
TautrimasPajarskas

J'ai simulé et implémenté une solution possible, qui évite toutes les requêtes coûteuses "floues". A la place de l'index, vous prenez N échantillons aléatoires de M bits sur ces 64 bits. Je suppose que ceci est un exemple de hachage sensible à la localité . Ainsi, pour chaque document (et lors de l'interrogation), le nombre d'échantillons x est toujours pris à des positions de bits identiques pour assurer un hachage cohérent dans tous les documents.

Les requêtes utilisent des filtres term dans la clause should de bool query avec un seuil minimum_should_match relativement bas. Le seuil inférieur correspond à un "flou" plus élevé. Malheureusement, vous devez réindexer toutes vos images pour tester cette approche.

Je pense que les requêtes { "term": { "phash.0": true } } n'ont pas donné de bons résultats, car chaque filtre correspond en moyenne à 50% des documents. Avec 16 bits/échantillon, chaque échantillon correspond à 2^-16 = 0.0015% de documents.

Je lance mes tests avec les paramètres suivants:

  • 1024 échantillons/hash (stockés dans les champs doc "0" - "ff")
  • 16 bits/échantillon (stocké dans le type short, doc_values = true)
  • 4 fragments et 1 million de hachages/index, environ 17,6 Go de stockage (peut être minimisé en ne stockant pas _source et les échantillons, uniquement le hachage binaire d'origine)
  • minimum_should_match = 150 (sur 1024)
  • Benchmarked avec 4 millions de documents (4 index)

Vous obtenez une vitesse plus rapide et une utilisation plus réduite du disque avec moins d'échantillons, mais les documents entre les distances 8 et 9 ne sont pas aussi bien séparés (selon mes simulations). 1024 semble être le nombre maximal de clauses should.

Les tests ont été exécutés sur un seul Core i5 3570K, 24 Go de RAM, 8 Go pour ES, version 1.7.1. Résultats de 500 requêtes (voir notes ci-dessous, les résultats sont trop optimistes):

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

Je vais tester comment cela passe à 15 millions de documents, mais il faut 3 heures pour générer et stocker 1 million de documents dans chaque index.

Vous devez tester ou calculer le niveau minimum que vous devez définir minimum_should_match pour obtenir le compromis souhaité entre les correspondances manquées et les correspondances incorrectes, cela dépend de la distribution de vos hachages.

Exemple de requête (3 champs sur 1024 affichés):

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

Edit: Lorsque j'ai commencé à faire d'autres tests, j'ai remarqué que j'avais généré des hachages trop dissemblables dans différents index, ce qui a conduit à une recherche nulle. Les documents nouvellement générés génèrent environ 150 à 250 correspondances/index/requête et devraient être plus réalistes.

Le graphique ci-dessus indique les nouveaux résultats. J'avais 4 Go de mémoire pour ES et 20 Go restants pour OS. La recherche dans les index 1 - 3 affichait de bonnes performances (temps moyen médian: 0,1 à 0,2 seconde), mais une recherche plus poussée aboutissait à beaucoup de disques IO et les requêtes commençaient à prendre de 9 à 11 secondes! Cela pourrait être contourné en prenant moins d'échantillons de hachage, mais les taux de précision et de précision ne seraient pas aussi bons. Sinon, vous pourriez avoir une machine de 64 Go de RAM et voir jusqu'où vous irez.

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

Edit 2: J'ai régénéré les données avec _source: false et ne stockant pas les échantillons de hachage (uniquement le hachage brut), ce qui a réduit l'espace de stockage de 60% à environ 6,7 Go/index (sur 1 million de documents). Cela n'affectait pas la vitesse de requête sur des ensembles de données plus petits, mais lorsque RAM ne suffisait pas et que le disque devait être utilisé, les requêtes étaient environ 40% plus rapides.

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

Edit 3: J'ai testé la recherche fuzzy avec une distance de montage de 2 sur un ensemble de 30 millions de documents et comparé cette opération à 256 échantillons aléatoires du hachage pour obtenir des résultats approximatifs. Dans ces conditions, les méthodes ont à peu près la même vitesse, mais fuzzy donne des résultats exacts et n’a pas besoin de cet espace disque supplémentaire. Je pense que cette approche n’est utile que pour des requêtes "très floues", comme une distance de Hamming supérieure à 3.

14
NikoNyrh

J'ai également mis en œuvre l'approche CUDA avec de bons résultats, même avec une carte graphique GeForce 650M pour ordinateur portable. L'implémentation était facile avec la bibliothèque Thrust . J'espère que le code ne contient pas de bugs (je ne l'ai pas testé à fond), mais cela ne devrait pas affecter les résultats des tests. Au moins, j’ai appelé thrust::system::cuda::detail::synchronize() avant d’arrêter la minuterie high-precision .

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

La recherche linéaire était aussi facile que

thrust::copy_if(
    hashesGpu.cbegin(), hashesGpu.cend(), matchesGpu.begin(),
    HammingDistanceFilter(target_hash, maxDistance)
)

La recherche était précise à 100% et bien plus rapide que ma réponse ElasticSearch: en 50 millisecondes, CUDA pouvait parcourir 35 millions de hachages! Je suis sûr que les cartes de bureau les plus récentes sont bien plus rapides que cela. De plus, nous obtenons une très faible variance et une croissance linéaire constante du temps de recherche alors que nous parcourons de plus en plus de données. ElasticSearch a rencontré de graves problèmes de mémoire lors de requêtes plus volumineuses en raison du gonflement des données d'échantillonnage.

Donc, ici, je rapporte les résultats de "Parmi ces N hachages, trouvez ceux qui se trouvent à moins de 8 km de Hamming d'un hachage simple H". J'ai couru ces 500 fois et signalé des centiles.

Search performance

Il y a un peu de lenteur au lancement du noyau, mais une fois que l’espace de recherche est supérieur à 5 millions de hachages, la vitesse de recherche est relativement constante à 700 millions de hachages/seconde. Naturellement, la limite supérieure du nombre de hachages à rechercher est définie par la mémoire vive du processeur graphique.

Search performance

7
NikoNyrh

J'ai moi-même commencé à trouver une solution. Jusqu'à présent, je n'ai testé que des données d'environ 3,8 millions de documents, et j'ai bien l'intention de pousser ces données à la hausse jusqu'à des dizaines de millions maintenant.

Ma solution à ce jour est la suivante:

Écrivez une fonction d'évaluation native et enregistrez-la en tant que plug-in. Appelez ensuite cette option lors de la requête pour ajuster la valeur _score des documents à leur retour.

En tant que script groovy, le temps nécessaire pour exécuter la fonction de scoring personnalisée était extrêmement peu impressionnant, mais l'écrire en tant que fonction de scoring native (comme le montre ce billet de blog un peu vieux: http://www.spacevatican.org/2012/5/12/elasticsearch-native-scripts-for-dummies / ) était beaucoup plus rapide.

Mon HammingDistanceScript ressemblait à ceci:

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);
    }
}

Il est à noter à ce stade que mes hachages sont des chaînes binaires codées en hexadécimal. Donc, le même que le vôtre, mais codé en hexadécimal pour réduire la taille de stockage.

De plus, je m'attends à un paramètre param_field, qui identifie la valeur de champ pour laquelle je veux faire la distance de Hamming. Vous n'avez pas besoin de faire cela, mais j'utilise le même script pour plusieurs champs, alors je le fais :)

Je l'utilise dans des requêtes comme celle-ci:

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"
            }
          }
        }
      ]
    }
  }
}'

J'espère que cela aide d'une certaine manière!

Autres informations qui pourraient vous être utiles si vous choisissez cette voie:

1. N'oubliez pas le fichier es-plugin.properties
Cela doit être compilé à la racine de votre fichier jar (si vous le collez dans/src/main/resources puis construisez votre jar, il ira au bon endroit).

Le mien ressemblait à ceci:

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. Référencez votre implémentation NativeScriptFactory personnalisée dans elasticsearch.yml
Comme sur un vieux blog.

Le mien ressemblait à ceci:

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

Si vous ne le faites pas, il apparaîtra toujours dans la liste des plugins (voir plus loin), mais vous obtiendrez des erreurs en essayant de l'utiliser, indiquant que elasticsearch ne peut pas le trouver.

3. Ne vous embêtez pas avec le script du plugin elasticsearch pour l'installer
C'est juste une douleur au cul et tout ce qu'il semble faire est de déballer vos affaires - un peu inutile. Au lieu de cela, collez-le simplement dans %ELASTICSEARCH_HOME%/plugins/hamming_distance.__ et relancez elasticsearch.

Si tout se passe bien, vous verrez qu'il est chargé au démarrage d'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]

ET quand vous appelez la liste des plugins, ce sera là:

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

produit quelque chose comme:

name        component                version type url
Junta       hamming_distance         0.1.0   j

J'espère pouvoir tester des dizaines de millions de documents dans la semaine à venir. Je vais essayer de me rappeler de revenir en arrière et de mettre à jour ceci avec les résultats, si cela peut aider.

3
ndtreviv

J'ai utilisé @ ndtreviv answer comme point de départ. Voici mes notes pour ElasticSearch 2.3.3:

  1. Le fichier es-plugin.properties s'appelle maintenant plugin-descriptor.properties

  2. Vous ne faites pas référence à NativeScriptFactory dans elasticsearch.yml, mais vous créez une classe supplémentaire à côté de votre 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. Ensuite, référencez cette classe dans votre fichier 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. Vous interrogez en fournissant le nom que vous avez utilisé dans cette ligne: module.registerScript("hamming-distance", HammingDistanceScriptFactory.class); in 2.

J'espère que cela aidera la prochaine âme pauvre qui doit faire face aux draps ES de merde.

1
mirosval

Voici la solution 64 bits de @ NikoNyrh answer. La distance de Hamming peut être calculée en utilisant simplement l'opérateur XOR avec la fonction __popcll intégrée de 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

Voici une solution inélégante mais exacte (force brute) qui nécessite de déconstruire votre hachage de fonctionnalité dans des champs booléens individuels afin que vous puissiez exécuter une requête comme celle-ci:

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

Je ne sais pas comment cela fonctionnera par rapport à fuzzy_like_this, mais l'implémentation de FLT est obsolète parce qu'elle doit visiter chaque terme de l'index pour calculer la distance d'édition.

(alors qu'ici/ci-dessus, vous exploitez la structure de données sous-jacente à index inversé sous-jacente de Lucene et les opérations sur les ensembles optimisés qui devrait fonctionnent à votre avantage compte tenu du fait que vous avez probablement des fonctionnalités relativement clairsemées)

1
Peter Dixon-Moses