Skip to content

Hybrid search - fill and max normalise (Option 1b)#4763

Draft
ellenmuller wants to merge 8 commits into
mainfrom
em-hybrid-search-fill-max-normalise
Draft

Hybrid search - fill and max normalise (Option 1b)#4763
ellenmuller wants to merge 8 commits into
mainfrom
em-hybrid-search-fill-max-normalise

Conversation

@ellenmuller

Copy link
Copy Markdown
Contributor

What does this change?

How should a reviewer test this change?

How can success be measured?

Who should look at this?

Tested? Documented?

  • locally by committer
  • locally by Guardian reviewer
  • on the Guardian's TEST environment
  • relevant documentation added or amended (if needed)

@ellenmuller ellenmuller added the feature Departmental tracking: work on a new feature label Jun 15, 2026
@github-actions

github-actions Bot commented Jun 15, 2026

Copy link
Copy Markdown

@ellenmuller ellenmuller changed the title Em hybrid search fill max normalise Hybrid search - fill and max normalise Jun 16, 2026
Comment on lines +308 to +315
// Raw cosine similarity, then mapped onto ES's Cosine `_score` scale of (1 + cos) / 2
// so a locally-computed score is directly comparable to the knn scores ES returns.
def cosineSim(a: List[Double], b: List[Double]): Double = {
val dot = (a zip b).map { case (x, y) => x * y }.sum
val magA = math.sqrt(a.map(x => x * x).sum)
val magB = math.sqrt(b.map(y => y * y).sum)
if (magA == 0.0 || magB == 0.0) 0.0 else dot / (magA * magB)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the vectors in ES are unit-normalised, the dot product is equivalent to cos here and you don't need the magnitude & division part - worth checking if they are, to avoid extra steps

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that query vectors will be, because we actually request them at 256 dims, but I think image vectors will not, because we've stored the truncated vector that we requested at 1536

val rawBm25Score = bm25ScoreById.getOrElse(id, followUpBm25ScoreById.getOrElse(id, 0.0))

// HNSW is approximate, so a locally-computed knn score can nudge above maxScore - clamp to 1.
val normKnn = if (knnMaxScore > 0.0) math.min(rawKnnScore / knnMaxScore, 1.0) else 0.0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we genuinely get a KNN score higher than the previous one, it doesn't really matter, so we don't need to do this capping here. If it's higher, it should get placed higher in the ranking

.knn(knn)
.size(k)

val multiMatchQuery = createMultiMatchQuery(query, boost = Some(1.0))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think you need this boost?

Suggested change
val multiMatchQuery = createMultiMatchQuery(query, boost = Some(1.0))
val multiMatchQuery = createMultiMatchQuery(query)

val normBm25 = if (bm25MaxScore > 0.0) rawBm25Score / bm25MaxScore else 0.0

val combinedScore = vecWeight * normKnn + (1.0 - vecWeight) * normBm25
(id, source, combinedScore)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could be a bit more readable to create a little case class for this instead of a tuple

sourceById.get(id).map { source =>
// knn score: ES knn score if present, else local cosine mapped to ES Cosine scale.
val rawKnnScore = knnScoreById.getOrElse(id,
bm25EmbeddingById.get(id).map(emb => (1.0 + cosineSim(queryEmbedding, emb)) / 2.0).getOrElse(0.0)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Everything else is beautifully readable but this line is a little dense. Could do with breaking out with variables/functions to make a bit clearer

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also you could use orElse to avoid the nested getOrElse e.g.

val rawKnnScore = knnScoreById.get(id)
  .orElse(bm25EmbeddingById.get(id).map(localKnnScore))
  .getOrElse(0.0)

@joelochlann joelochlann changed the title Hybrid search - fill and max normalise Hybrid search - fill and max normalise (Option 1b) Jun 17, 2026
bm25EmbeddingById.get(id).map(emb => (1.0 + cosineSim(queryEmbedding, emb)) / 2.0).getOrElse(0.0)
)
// bm25 score: bm25 top-k score if present, else exact follow-up score, else 0.
val rawBm25Score = bm25ScoreById.getOrElse(id, followUpBm25ScoreById.getOrElse(id, 0.0))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

again, orElse could help you un-nest the getOrElse

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature Departmental tracking: work on a new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants