@@ -273,7 +273,7 @@ class ElasticSearch(
273273 k : Int ,
274274 numCandidates : Int ,
275275 vecWeight : Double
276- )(implicit ex : ExecutionContext , logMarker : LogMarker ): Future [Unit ] = {
276+ )(implicit ex : ExecutionContext , logMarker : LogMarker ): Future [SearchResults ] = {
277277
278278 val knn = Knn (" embedding.cohereEmbedV4.image" , filter = filterOpt)
279279 .queryVector(queryEmbedding)
@@ -286,40 +286,94 @@ class ElasticSearch(
286286
287287 val multiMatchQuery = createMultiMatchQuery(query, boost = Some (1.0 ))
288288
289+ // Top-k BM25 results are our lexical contenders. We keep their source so we can read
290+ // each image's embedding and compute a local cosine for any contender absent from knn.
289291 val multiMatchRequest = ElasticDsl .search(imagesCurrentAlias)
290- .query(multiMatchQuery)
291- // We don't restrict the number of results for our multiMatch so that we can assume if not there it's 0
292- // .size(k)
292+ .query(filterOpt.map(f => boolQuery().must(multiMatchQuery).filter(f)).getOrElse(multiMatchQuery))
293+ .size(k)
293294
294295 // Kick both queries off before awaiting either, so they execute in parallel.
295296 val knnResponseF = executeAndLog(withSearchQueryTimeout(knnRequest), " hybrid search: knn component" )
296297 val multiMatchResponseF = executeAndLog(withSearchQueryTimeout(multiMatchRequest), " hybrid search: bm25 component" )
297298
299+ // Raw cosine similarity, then mapped onto ES's Cosine `_score` scale of (1 + cos) / 2
300+ // so a locally-computed score is directly comparable to the knn scores ES returns.
301+ def cosineSim (a : List [Double ], b : List [Double ]): Double = {
302+ val dot = (a zip b).map { case (x, y) => x * y }.sum
303+ val magA = math.sqrt(a.map(x => x * x).sum)
304+ val magB = math.sqrt(b.map(y => y * y).sum)
305+ if (magA == 0.0 || magB == 0.0 ) 0.0 else dot / (magA * magB)
306+ }
307+
298308 for {
299309 knnResponse <- knnResponseF
300310 multiMatchResponse <- multiMatchResponseF
301- } yield {
302- // Get the max score
303- val multiMatchMaxScore = multiMatchResponse.result.hits.maxScore
304- val knnMaxScore = knnResponse.result.hits.maxScore
305-
306- // Create hash maps of the results for easy lookup and merging
307- val multiMatchMap : Map [String , (List [Double ], Float )] = multiMatchResponse.result.hits.hits.flatMap { hit =>
308- resolveHit(hit)
309- .flatMap(_.instance.embedding)
310- .flatMap(_.cohereEmbedV4)
311- .map(embedding => hit.id -> (embedding.image, hit.score))
311+
312+ knnHits = knnResponse.result.hits.hits
313+ multiMatchHits = multiMatchResponse.result.hits.hits
314+
315+ // Max scores for max-normalisation. Guard against empty result sets.
316+ knnMaxScore = if (knnHits.isEmpty) 0.0 else knnResponse.result.hits.maxScore
317+ bm25MaxScore = if (multiMatchHits.isEmpty) 0.0 else multiMatchResponse.result.hits.maxScore
318+
319+ // knn id -> raw knn score (ES Cosine _score)
320+ knnScoreById = knnHits.map(h => h.id -> h.score.toDouble).toMap
321+ // bm25 top-k id -> raw bm25 score
322+ bm25ScoreById = multiMatchHits.map(h => h.id -> h.score.toDouble).toMap
323+
324+ // id -> SourceWrapper[Image] for every candidate we've seen (union of both result sets)
325+ sourceById = (knnHits.toSeq ++ multiMatchHits.toSeq).flatMap(h => resolveHit(h).map(sw => h.id -> sw)).toMap
326+
327+ // bm25 contender id -> image embedding, used to compute a local cosine when the
328+ // contender is absent from the knn result set.
329+ bm25EmbeddingById = multiMatchHits.flatMap { h =>
330+ resolveHit(h).flatMap(_.instance.embedding).flatMap(_.cohereEmbedV4).map(e => h.id -> e.image)
312331 }.toMap
313- val knnMap : Map [String , Float ] = knnResponse.result.hits.hits.map(hit => hit.id -> hit.score).toMap
314332
333+ // knn ids missing an exact BM25 score (not in the bm25 top-k). We fetch their exact
334+ // BM25 score with a second, ids-filtered query (scores only, no source).
335+ missingBm25Ids = knnScoreById.keySet.diff(bm25ScoreById.keySet).toList
336+
337+ followUpBm25ScoreById <- if (missingBm25Ids.isEmpty) Future .successful(Map .empty[String , Double ]) else {
338+ val followUpRequest = ElasticDsl .search(imagesCurrentAlias)
339+ .query(boolQuery().must(multiMatchQuery).filter(filters.ids(missingBm25Ids)))
340+ .fetchSource(false )
341+ .size(missingBm25Ids.size)
342+ executeAndLog(withSearchQueryTimeout(followUpRequest), " hybrid search: bm25 fill for knn ids" )
343+ .map(_.result.hits.hits.map(h => h.id -> h.score.toDouble).toMap)
344+ }
345+ } yield {
346+ // Every candidate id: union of the knn results and the bm25 top-k.
347+ val candidateIds = knnScoreById.keySet ++ bm25ScoreById.keySet
348+
349+ val scored = candidateIds.toSeq.flatMap { id =>
350+ sourceById.get(id).map { source =>
351+ // knn score: ES knn score if present, else local cosine mapped to ES Cosine scale.
352+ val rawKnnScore = knnScoreById.getOrElse(id,
353+ bm25EmbeddingById.get(id).map(emb => (1.0 + cosineSim(queryEmbedding, emb)) / 2.0 ).getOrElse(0.0 )
354+ )
355+ // bm25 score: bm25 top-k score if present, else exact follow-up score, else 0.
356+ val rawBm25Score = bm25ScoreById.getOrElse(id, followUpBm25ScoreById.getOrElse(id, 0.0 ))
357+
358+ // HNSW is approximate, so a locally-computed knn score can nudge above maxScore - clamp to 1.
359+ val normKnn = if (knnMaxScore > 0.0 ) math.min(rawKnnScore / knnMaxScore, 1.0 ) else 0.0
360+ val normBm25 = if (bm25MaxScore > 0.0 ) rawBm25Score / bm25MaxScore else 0.0
361+
362+ val combinedScore = vecWeight * normKnn + (1.0 - vecWeight) * normBm25
363+ (id, source, combinedScore)
364+ }
365+ }
315366
316- val knnHits = knnResponse.result.hits.hits
317- val multiMatchHits = multiMatchResponse.result.hits.hits
367+ val ranked = scored.sortBy { case (_, _, score) => - score }.take(k)
318368
319- logger.info(logMarker, s " knn returned ${knnHits.length} hits: " +
320- knnHits.map(h => s " ${h.id}= ${h.score}" ).mkString(" , " ))
321- logger.info(logMarker, s " bm25 returned ${multiMatchHits.length} hits: " +
322- multiMatchHits.map(h => s " ${h.id}= ${h.score}" ).mkString(" , " ))
369+ logger.info(logMarker, s " hybrid search merged ${ranked.length} results " +
370+ s " (knn= ${knnHits.length}, bm25= ${multiMatchHits.length}, bm25-fill= ${missingBm25Ids.length}) " )
371+
372+ SearchResults (
373+ hits = ranked.map { case (id, source, _) => (id, source) },
374+ total = ranked.length,
375+ extraCounts = None
376+ )
323377 }
324378 }
325379
@@ -340,13 +394,7 @@ class ElasticSearch(
340394 } else {
341395 val queryEmbeddingDouble : List [Double ] = queryEmbedding.map(_.toDouble)
342396
343- for {
344- searchRequest <- fillAndMaxNormalise(query, filterOpt, queryEmbeddingDouble, k, numCandidates, vecWeight)
345- result <- executeAndLog(withSearchQueryTimeout(searchRequest), " hybrid search" )
346- } yield {
347- val imageHits = result.result.hits.hits.map(resolveHit).toSeq.flatten.map(i => (i.instance.id, i))
348- SearchResults (hits = imageHits, total = imageHits.length, extraCounts = None )
349- }
397+ fillAndMaxNormalise(query, filterOpt, queryEmbeddingDouble, k, numCandidates, vecWeight)
350398 }
351399 }
352400
0 commit comments