Skip to content

Commit 1a1968c

Browse files
committed
spaghetti string, copilot powered, ranking logic
1 parent 4b52e0a commit 1a1968c

1 file changed

Lines changed: 77 additions & 29 deletions

File tree

media-api/app/lib/elasticsearch/ElasticSearch.scala

Lines changed: 77 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)