Type "find an ai tool for web design" into an AI-tools directory and get Typli.AI—an AI writing tool—on page one. Plus the counter always says "100+ Results found," no matter what you type. That was the search experience at myaihub.ai, an AI product startup running a directory of 10,000+ AI tools on Elasticsearch.
The catalog was fine. The query wasn't. Every document in an AI-tools index contains the words "AI" and "tool," so a boosted, fuzzy multi_match rewards exactly the wrong thing. Here is how we rebuilt it as hybrid search—semantic kNN plus lexical filtering—and what it took on the infrastructure side to get there.
Quick answer: myaihub.ai moved from a keyword-only multi_match query to hybrid search on Elasticsearch: a .multilingual-e5-small embedding model deployed to a dedicated 4GB ML node via eland_import_hub_model, dense vectors built from the description field, and a bool query that combines a boosted match_phrase with knn (query_vector_builder), filtered by an English-analyzer multi_match over name, slug, tags, category, and tagline. Semantic recall finds the meaning; lexical filters keep it honest.
Challenge
myaihub.ai runs its catalog in a tools index—1 primary shard, 1 replica, text fields with .keyword multifields for name, description, slug, tagline, tags, and category. The application-side query was a classic first-generation setup:
{
"bool": {
"must": [{
"multi_match": {
"query": "find an ai tool for web design",
"fields": ["name^10", "description^2", "slug^8", "category^4", "tags^3"],
"type": "best_fields",
"fuzziness": "AUTO"
}
}],
"should": [
{ "match_phrase": { "name": { "query": "...", "boost": 3 } } },
{ "match_phrase": { "description": { "query": "...", "boost": 2 } } }
]
}
}
Three problems came straight out of the meeting notes and the screenshot:
- Unrelated results. "find an ai tool for web design" surfaced Typli.AI, an AI writing tool—because "ai" and "tool" match everything, and
fuzziness: AUTOwidens the net further. - "There are always 100 results." The result count carried no signal; the UI permanently showed "100+ Results found."
- Generic tokens dominated scoring. The notes were explicit: remove
ai,an,find,toolbefore the query hits the ML model.
A smaller detail from the controller code: "most popular" and "most recent" sorting ran in JavaScript after the search, on the fetched page only—another sign relevance was being patched downstream instead of fixed in the query.
Diagnosis
Lexical scoring wasn't broken; it was doing exactly what it was told. In a corpus where every document is an AI tool, tf-idf-style ranking over "ai tool" phrases is noise. What the user means by "web design" lives in the description semantics, not in fuzzy token overlap.
The team also made one sharp product call early: don't solve this by shrinking num_candidates. A broad query like "design" legitimately matches 100+ tools, and capping candidates would hide real results. So the fix had to add understanding, not subtract recall.
That pointed to hybrid search: dense-vector semantic retrieval for meaning, lexical matching as a precision filter. Elastic's own writing on improving search relevance frames the same trade-off.
Solution
Prepare the cluster for ML
Two prerequisites landed before any query work: a dedicated machine learning node with 4GB RAM, and an Elasticsearch upgrade target of at least 8.13 (the index had been created on 8.5.x). No ML node, no model deployment—this is the step teams most often skip.
Upload the model with eland
Model import used eland_import_hub_model from the official Docker image. The first working run deployed a Hugging Face text-embedding model:
docker run -it --rm elastic/eland \
eland_import_hub_model \
--url https://<your-elasticsearch-endpoint>:443 \
-u <user> -p <password> \
--hub-model-id sentence-transformers/msmarco-MiniLM-L-12-v3 \
--task-type text_embedding \
--start
For multilingual and image-similarity plans, we built a custom image (python:3.11, eland[pytorch], transformers, sentence-transformers, with PYTORCH_ENABLE_MPS_FALLBACK=1) and sanity-tested clip-ViT-B-32-multilingual-v1 locally on Apple silicon before pushing anything to the cluster. Production text search settled on Elastic's built-in multilingual-e5-small (.multilingual-e5-small_linux-x86_64), with dense vectors generated from the description field at both index and search time.
The hybrid query
{
"query": {
"bool": {
"should": [
{ "match_phrase": {
"name": { "query": "find an ai tool for web design",
"boost": 10, "_name": "exact_match" } } },
{ "knn": {
"field": "ai.predicted_value",
"num_candidates": 100,
"query_vector_builder": {
"text_embedding": {
"model_id": ".multilingual-e5-small_linux-x86_64",
"model_text": "find an ai tool for web design" } },
"_name": "semantic_search" } }
],
"minimum_should_match": 1,
"filter": [
{ "multi_match": {
"query": "web design",
"fields": ["name", "slug", "tags", "category", "tagline"] } },
{ "knn": {
"field": "ai.predicted_value",
"num_candidates": 100,
"query_vector_builder": {
"text_embedding": {
"model_id": ".multilingual-e5-small_linux-x86_64",
"model_text": "web design" } } } }
]
}
}
}
The division of labor, as documented in the engagement notes:
- Semantic side: kNN over
ai.predicted_valuefinds the most related products (~20 in practice) by embedding similarity—_scoreis a probability-like signal, not a yes/no. - Lexical side: the English analyzer (stemmer, stop words, lowercase) runs at both index and search time, and at least 50% of the query words must match across
name,slug,tags,category,tagline,description. - Filter, don't cap: the lexical
multi_matchsits infilter, trimming the semantic candidate set by keywords—so broad queries keep their 100+ legitimate hits while "typli for web design" accidents disappear. - Query hygiene: generic tokens (
ai,an,find,tool) are stripped before the text reaches the embedding model.
An exact match_phrase on name with boost: 10 keeps navigational queries ("Foundr AI") pinned to the top.
Results
Only what the evidence pack supports:
| Result | Evidence |
|---|---|
| Hybrid query (kNN + lexical filter) in production shape | Saved query in engagement notes |
Embedding model deployed via eland, --task-type text_embedding, started |
eland run annotated "looks working fine" |
| Dedicated ML node (4GB) and ≥8.13 upgrade defined as prerequisites | Action items in notes |
Catalog scale: 10,000+ docs in tools (1 shard / 1 replica) |
Search response header, index settings |
| Known bad case identified and targeted (Typli.AI for "web design") | Meeting notes + UI screenshot |
Recall preserved: no num_candidates cap as a relevance band-aid |
Explicit decision in notes |
What we do not claim: relevance uplift percentages, CTR changes, or latency numbers. They were not in the source pack, so they are not in this article.
Key Takeaways
- In a single-domain corpus, generic tokens are poison. When every document says "AI tool," strip those words before scoring or embedding.
- Hybrid beats either alone. kNN finds meaning; a lexical
multi_matchinfilterkeeps precision without capping recall. - Don't tune
num_candidatesto hide bad relevance. Broad queries deserve broad results; fix ranking, not the count. - eland + Docker is the shortest path to model deployment. One
eland_import_hub_modelcommand uploads and starts a Hugging Face model. - Budget the ML node first. A 4GB ML node and a modern 8.x version are prerequisites, not afterthoughts.
- Keep an exact-match escape hatch. A boosted
match_phraseonnameprotects navigational queries from semantic drift.
If your product search still returns "100+ results" of loosely related noise, this is the pattern I implement: embeddings via eland, hybrid bool queries, lexical discipline. And once the model is live, watch your ML node like any other production workload—see searchali.com/en/monitoring.
Want search that understands your catalog? → searchali.com
