Other database backends
Database backend client libraries are optional extras – install only the one you need:
$ pip install "image-match[elasticsearch]" # Elasticsearch driver
$ pip install "image-match[elasticsearch-async]" # async Elasticsearch driver
$ pip install "image-match[opensearch]" # OpenSearch driver (+ k-NN)
$ pip install "image-match[opensearch-async]" # async OpenSearch driver (+ k-NN)
$ pip install "image-match[mongo]" # MongoDB driver
OpenSearch
SignatureOpenSearch shares the record format and query DSL with the
Elasticsearch driver; only the client library differs (OpenSearch is
API-compatible with Elasticsearch 7.x for the operations used here). Both
OpenSearch 2.x and 3.x servers are supported — the opensearch extra pulls
opensearch-py>=2.4, whose 3.x client line covers server versions 1.x–3.x:
from image_match.opensearch_driver import SignatureOpenSearch
from opensearchpy import OpenSearch
ses = SignatureOpenSearch(OpenSearch("http://localhost:9200"))
Async drivers
AsyncSignatureES and AsyncSignatureOpenSearch mirror the synchronous
drivers, but every database-touching method is a coroutine. Image decoding and
signature generation run in a worker thread via asyncio.to_thread, so the
event loop is never blocked. Use these when embedding image-match in async
services such as FastAPI:
from image_match.elasticsearch_async_driver import AsyncSignatureES
from elasticsearch import AsyncElasticsearch
ses = AsyncSignatureES(AsyncElasticsearch("http://localhost:9200"))
# in an async endpoint / handler:
await ses.add_image("cat.jpg", refresh_after=True)
matches = await ses.search_image("cat.jpg")
# don't forget to close the client on shutdown:
await ses.es.close()
The async drivers expose the same API — add_image, search_image,
search_single_record, insert_single_record and delete_duplicates —
plus the same constructor options (index, timeout, size,
delete_duplicates_limit). For OpenSearch, swap in
AsyncSignatureOpenSearch with an AsyncOpenSearch client. MongoDB has no
async driver; wrap the sync driver in asyncio.to_thread instead if needed.
OpenSearch k-NN
SignatureOpenSearchKNN (and AsyncSignatureOpenSearchKNN) use
OpenSearch’s native approximate nearest-neighbour search on the signature
vector directly, instead of the word-overlap disjunction. The record format is
identical — records already store signature, so no data transformation is
needed, only a different index mapping:
from opensearchpy import OpenSearch
from image_match.opensearch_knn_driver import SignatureOpenSearchKNN, knn_index_body
client = OpenSearch("http://localhost:9201")
client.indices.create(index="images_knn", body=knn_index_body(648))
ses = SignatureOpenSearchKNN(client, index="images_knn")
ses.add_image("cat.jpg", refresh_after=True)
matches = ses.search_image("cat.jpg")
The index must be created with index.knn: true and the signature field
mapped as knn_vector — knn_index_body() produces exactly that (648 is
the signature length for the default n_grid=9). All other fields stay
dynamically mapped, so simple_word_* fields still index normally and the
classic word drivers keep working on the same index — migrations are
reversible (see Migrating to the OpenSearch k-NN index).
The knn candidates are rescored client-side with the usual normalized
distance, so dist/distance_cutoff semantics are unchanged. The
difference is recall: HNSW is approximate — size controls the candidate
count k (and Lucene derives ef_search from it); raise it for better
recall on large indexes.
Warning
HNSW needs the vector graphs in page cache to be fast and accurate.
Storage footprint is roughly docs × dimension × 4 bytes (float) plus
graph overhead — a ~56M-document, 648-dimension index grew to ~400 GB
(~2.6× the equivalent word index). On HDD-backed nodes without enough RAM
to hold that working set we observed:
near-zero recall at the default
ef_search— even exact self-matches (dist 0.0) were absent from the top hits;recall recovering only at
ef_search ≈ 512–1000, where random graph reads pushed single queries past 120 s.
For large collections prefer data_type="byte" (4× smaller vectors,
lossless for int8 signatures), SSD storage, and RAM sized to the vector
working set. Without those, the word drivers are both faster and more
accurate — k-NN is not a free upgrade on memory-constrained hardware.
Engine and data-type choices via knn_index_body(..., engine=..., space_type=..., data_type=...):
engine:lucene(default, no native dependency) orfaiss.nmslibis deprecated and rejected for new indexes on OpenSearch 3 — only use it for OpenSearch 2 targets.data_type:float(default) orbyte. Signatures are int8, sobyteis lossless and ~4x smaller in memory (requires OpenSearch ≥ 2.9).
Both OpenSearch 2.x and 3.x accept the mapping knn_index_body() produces
— method parameters live in the field mapping, the style OpenSearch 3
requires (index-level knn.algo_param.* settings were removed in 3.0).
MongoDB
For demonstration purposes we include also a MongoDB driver:
from image_match.mongodb_driver import SignatureMongo
from pymongo import MongoClient
client = MongoClient(connect=False)
c = client.images.images
ses = SignatureMongo(c)
now you can use the same functionality as above like ses.add_image(...).
We tried to separate signature logic from the database insertion/search as much
as possible. To write your own database backend, you can inherit from the
SignatureDatabaseBase class and override the appropriate methods:
from image_match.signature_database_base import SignatureDatabaseBase
# other relevant imports
class MySignatureBackend(SignatureDatabaseBase):
# if you need to do some setup, override __init__
def __init__(self, myarg1, myarg2, *args, **kwargs):
# do some initializing stuff here if necessary
# ...
super().__init__(*args, **kwargs)
# you MUST implement these two functions
def search_single_record(self, rec):
# should query your database given a record generated from
# signature_database_base.make_record
# ...
# should return a list of dicts like
# [{'id': 'some_unique_id_from_db',
# 'dist': 0.109234,
# 'path': 'url/or/filepath'},
# {...}, ...]
# you can have other keys, but you need at least id and dist
return formatted_results
def insert_single_record(self, rec):
# if your database driver or instance can accept a dict as input,
# this should be very simple
# ...
Unfortunately, implementing a good search_single_record function does
require some knowledge of the search algorithm. You can also look at the two
included database drivers for guidelines.