@@ -6,7 +6,7 @@ import com.gu.mediaservice.lib.argo.model.{ExtraCount, ExtraCountConfig, ExtraCo
66import com .gu .mediaservice .lib .elasticsearch .filters
77import com .gu .mediaservice .lib .auth .Authentication .Principal
88import com .gu .mediaservice .lib .elasticsearch .{CompletionPreview , ElasticSearchClient , ElasticSearchConfig , MigrationStatusProvider , Running }
9- import com .gu .mediaservice .lib .logging .{GridLogging , LogMarker , MarkerMap }
9+ import com .gu .mediaservice .lib .logging .{GridLogging , LogMarker , MarkerMap , Stopwatch }
1010import com .gu .mediaservice .lib .metrics .FutureSyntax
1111import com .gu .mediaservice .model .{Agencies , Agency , AwaitingReviewForSyndication , Image }
1212import com .sksamuel .elastic4s .ElasticDsl
@@ -275,6 +275,9 @@ class ElasticSearch(
275275 vecWeight : Double
276276 )(implicit ex : ExecutionContext , logMarker : LogMarker ): Future [SearchResults ] = {
277277
278+ // Times the whole pipeline: parallel queries + conditional follow-up + local merge.
279+ val pipelineStopwatch = Stopwatch .start
280+
278281 val knn = Knn (" embedding.cohereEmbedV4.image" , filter = filterOpt)
279282 .queryVector(queryEmbedding)
280283 .k(k)
@@ -293,8 +296,14 @@ class ElasticSearch(
293296 .size(k)
294297
295298 // Kick both queries off before awaiting either, so they execute in parallel.
299+ // Each is timed independently so we can see the individual request latencies.
300+ val knnStopwatch = Stopwatch .start
296301 val knnResponseF = executeAndLog(withSearchQueryTimeout(knnRequest), " hybrid search: knn component" )
302+ knnResponseF.onComplete(_ => logger.info(logMarker, s " hybrid search timing: knn component took ${knnStopwatch.elapsed.toMillis} ms " ))
303+
304+ val multiMatchStopwatch = Stopwatch .start
297305 val multiMatchResponseF = executeAndLog(withSearchQueryTimeout(multiMatchRequest), " hybrid search: bm25 component" )
306+ multiMatchResponseF.onComplete(_ => logger.info(logMarker, s " hybrid search timing: bm25 component took ${multiMatchStopwatch.elapsed.toMillis} ms " ))
298307
299308 // Raw cosine similarity, then mapped onto ES's Cosine `_score` scale of (1 + cos) / 2
300309 // so a locally-computed score is directly comparable to the knn scores ES returns.
@@ -334,6 +343,7 @@ class ElasticSearch(
334343 // BM25 score with a second, ids-filtered query (scores only, no source).
335344 missingBm25Ids = knnScoreById.keySet.diff(bm25ScoreById.keySet).toList
336345
346+ followUpStopwatch = Stopwatch .start
337347 followUpBm25ScoreById <- if (missingBm25Ids.isEmpty) Future .successful(Map .empty[String , Double ]) else {
338348 val followUpRequest = ElasticDsl .search(imagesCurrentAlias)
339349 .query(boolQuery().must(multiMatchQuery).filter(filters.ids(missingBm25Ids)))
@@ -342,7 +352,11 @@ class ElasticSearch(
342352 executeAndLog(withSearchQueryTimeout(followUpRequest), " hybrid search: bm25 fill for knn ids" )
343353 .map(_.result.hits.hits.map(h => h.id -> h.score.toDouble).toMap)
344354 }
355+ _ = logger.info(logMarker, s " hybrid search timing: bm25 fill follow-up took ${followUpStopwatch.elapsed.toMillis} ms ( ${missingBm25Ids.length} ids) " )
345356 } yield {
357+ // Time the local (in-memory) merge + scoring separately from the ES round trips.
358+ val mergeStopwatch = Stopwatch .start
359+
346360 // Every candidate id: union of the knn results and the bm25 top-k.
347361 val candidateIds = knnScoreById.keySet ++ bm25ScoreById.keySet
348362
@@ -366,6 +380,9 @@ class ElasticSearch(
366380
367381 val ranked = scored.sortBy { case (_, _, score) => - score }.take(k)
368382
383+ logger.info(logMarker, s " hybrid search timing: local merge took ${mergeStopwatch.elapsed.toMillis} ms " )
384+ logger.info(logMarker, s " hybrid search timing: full pipeline took ${pipelineStopwatch.elapsed.toMillis} ms " )
385+
369386 logger.info(logMarker, s " hybrid search merged ${ranked.length} results " +
370387 s " (knn= ${knnHits.length}, bm25= ${multiMatchHits.length}, bm25-fill= ${missingBm25Ids.length}) " )
371388
0 commit comments