-
Notifications
You must be signed in to change notification settings - Fork 120
Revamp hybrid search algorithm #4764
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
joelochlann
merged 64 commits into
main
from
js-fill-missing-scores-with-two-rescore-queries
Jun 25, 2026
Merged
Changes from all commits
Commits
Show all changes
64 commits
Select commit
Hold shift + click to select a range
98d5669
Add fill scores tickbox to frontend
9be7d05
WIP implementation of missing scores with two rescore queries
8eb244d
Fill missing scores with two rescore queries
6e96872
Small cleanup
fa7ccd0
Add VectorUtils
ab90510
Calculate cosine similarity clientside
c9672b7
Remove TODOs
22c6c4d
Correct comment
516b04b
Add logging
d316266
Minor cleanups
fa18841
Add comment about ES-norming of cosine similarity
a8f0eb5
Update media-api/app/lib/elasticsearch/ElasticSearch.scala
joelochlann e8d92da
Clarify naming
c5dc67a
Merge branch 'js-fill-missing-scores-with-two-rescore-queries' of git…
119c600
More naming clarifications
d9bdc82
Revert "More naming clarifications"
ce45879
Naming tweaks
c4097e4
Silly formatting bits
a7115e2
De-duplicate before doing any extra work
ac5bb48
Fix cosine similarity to handle division by zero
7dabe4a
Use proper cosine similarity
3612a0b
Log timing after future completes
26c08d0
Add filtering
220d620
Set size on KNN query
7683c46
Make fill scores the default approach
d55ae39
Break out HybridResult and add tests
88fedc3
Add comment
eab0c66
Break out functions, more tests, more comments
6f23087
Add integration test
9b15ce5
Extra logging, maybe not needed
2110499
Short-circuit hybrid search if vecWeight is 0 or 1
da7bada
Add tests that check score filling behaviour for those outside top k …
6debe3c
Rename combine to fuse
f7b6fba
Put theoretical minima into constants
8d1a978
Sync es CI version with the local and PROD version
0e3d5af
Remove heap size and limit and refine comments on es CI settings
665ea22
Clean up test
a8eda78
Merge branch 'js-float-to-double-for-query-embedding' into js-fill-mi…
8706093
Add more vector utils to help with testing
e24efb9
Rewrite tests myself
20ddaf3
Refine tests and logging
3a8bb29
Clean up test
6003b2a
Handle empty counts
6cdf13a
Merge branch 'main' into js-fill-missing-scores-with-two-rescore-queries
joelochlann b7670cb
Comment cleanup
36389e5
Merge branch 'js-fill-missing-scores-with-two-rescore-queries' of git…
1b25455
Comment tweak
298e752
Better grouping in test
15aff72
More silly cleanup
45accba
Consolidate two tests
6946880
Return None when cosine similarity is undefined
4d994f3
Typo
joelochlann 2a96962
Factor out maybeWithQuery
461fe45
Merge branch 'js-fill-missing-scores-with-two-rescore-queries' of git…
0ac0fa4
Improve logging
8ed482c
Cover cosine similarity edge cases and log warning when we cannot cal…
9d2b1fd
Cover off vectorWithCosineSimilarity edge case
20f8f07
Add tests for new vector edge cases
90536d0
Add implicit to test
e05e37a
Log timing in andThen instead of foreach
joelochlann 0b0fcf6
Use tolerance variable
f60e38c
Add comments about vectorWithCosineSimilarity
f4a1ace
Merge branch 'js-fill-missing-scores-with-two-rescore-queries' of git…
6f8e32d
Add logging of scores for all hybrid results
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
80 changes: 80 additions & 0 deletions
80
common-lib/src/main/scala/com/gu/mediaservice/lib/VectorUtils.scala
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| package com.gu.mediaservice.lib | ||
|
|
||
| object VectorUtils { | ||
| def dotProduct(vectorOne: List[Double], vectorTwo: List[Double]): Double = { | ||
| require(vectorOne.length == vectorTwo.length, "Vectors must be the same dimensionality") | ||
| vectorOne.zip(vectorTwo).map(component => component._1 * component._2).sum | ||
| } | ||
|
|
||
| def magnitude(vector: List[Double]): Double = { | ||
| math.sqrt(vector.map(math.pow(_, 2)).sum) | ||
| } | ||
|
|
||
| def cosineSimilarity(vectorOne: List[Double], vectorTwo: List[Double]): Option[Double] = { | ||
| // Cosine similarity is undefined when the vectors differ in dimensionality | ||
| // (the dot product is meaningless) or when either has zero magnitude (the | ||
| // angle to the origin is meaningless), so we return None in those cases and | ||
| // let the caller choose a domain-appropriate fallback. | ||
| if (vectorOne.length != vectorTwo.length) None | ||
| else { | ||
| val magnitudeProduct = magnitude(vectorOne) * magnitude(vectorTwo) | ||
| if (magnitudeProduct == 0.0) None | ||
| else Some(dotProduct(vectorOne, vectorTwo) / magnitudeProduct) | ||
| } | ||
| } | ||
|
|
||
| // =============================================================================== | ||
| // The below functions are primarily for constructing artificial vectors for tests | ||
| // =============================================================================== | ||
| private def oneHotVector(dims: Int, hotIndex: Int): List[Double] = | ||
| List.tabulate(dims)(i => if (i == hotIndex) 1.0 else 0.0) | ||
|
|
||
| // The first standard basis vector e0: (1, 0, 0, ...). | ||
| def firstBasisVector(dims: Int): List[Double] = oneHotVector(dims, 0) | ||
|
|
||
| private def vectorWithComponents(dims: Int, components: (Int, Double)*): List[Double] = { | ||
| val arr = Array.fill(dims)(0.0) | ||
| components.foreach { case (i, v) => arr(i) = v } | ||
| arr.toList | ||
| } | ||
|
|
||
| // Builds a vector whose cosine similarity with the first basis vector equals | ||
| // the requested `similarity` (in [-1, 1], where 1 is identical and 0 is orthogonal). | ||
| // This is specifically for use in tests where we need to create test fixtures with | ||
| // specific semantic scores. | ||
| def vectorWithCosineSimilarity(dims: Int, similarity: Double): List[Double] = { | ||
| require(dims > 1, "Dimensions must be at least 2") | ||
| require(similarity >= -1.0 && similarity <= 1.0, "Cosine similarity must be between -1 and 1") | ||
| if (similarity == 0.0) { | ||
| // A cosine similarity of 0 means orthogonal to the first basis vector. | ||
| // That vector is one-hot at index 0, so any vector with a zero | ||
| // first component is orthogonal to it. (0, 1, 0, 0, ...) is the simplest choice. | ||
| oneHotVector(dims, 1) | ||
| } else { | ||
| val absVec2D = { | ||
| // Let's pretend we're in just 2 dimensions and think about triangles. | ||
| // | ||
| // /| | ||
| // / | | ||
| // n / | opposite = sqrt(n^2 - 1) | ||
| // / | | ||
| // /θ___| | ||
| // 1 | ||
| // (adjacent) | ||
| // | ||
| // Our queryEmbedding lies flat on the x axis. | ||
| // cos(θ) = adjacent / hypotenuse = 1 / n | ||
| // If we want cos θ to be 1/n, e.g. 1/2, | ||
| // then the adjacent side must be 1, and the hypotenuse n. | ||
| // We want to find the opposite side | ||
| // n^2 = 1^2 + adj^2 | ||
| // opposite = sqrt(n^2 - 1) | ||
| // The two components of our vector are therefore (1, sqrt(n^2 - 1)) | ||
| val n = 1 / Math.abs(similarity) | ||
| (1, Math.sqrt(Math.pow(n, 2) - 1)) | ||
| } | ||
| val vec2D = if (similarity < 0.0) (-absVec2D._1, -absVec2D._2) else absVec2D | ||
| vectorWithComponents(dims, 0 -> vec2D._1, 1 -> vec2D._2) | ||
| } | ||
| } | ||
| } |
143 changes: 143 additions & 0 deletions
143
common-lib/src/test/scala/com/gu/mediaservice/lib/VectorUtilsTest.scala
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,143 @@ | ||
| package com.gu.mediaservice.lib | ||
|
|
||
| import org.scalatest.Inspectors | ||
| import org.scalatest.OptionValues | ||
| import org.scalatest.funspec.AnyFunSpec | ||
| import org.scalatest.matchers.should.Matchers | ||
| import org.scalacheck.Gen | ||
| import VectorUtils.{dotProduct, magnitude, cosineSimilarity, firstBasisVector, vectorWithCosineSimilarity} | ||
|
|
||
|
|
||
| class VectorUtilsTest extends AnyFunSpec with Matchers with Inspectors with OptionValues { | ||
| val tolerance = 1e-9 | ||
|
|
||
| describe("dotProduct") { | ||
| it ("should compute the dot product of two vectors") { | ||
| dotProduct(List(1.0, 2.0, 3.0), List(4.0, 5.0, 6.0)) shouldBe 32.0 | ||
| } | ||
|
|
||
| it ("should compute a dot product of zero for orthogonal vectors") { | ||
| dotProduct(List(1.0, 0.0), List(0.0, 1.0)) shouldBe 0.0 | ||
| } | ||
|
|
||
| it ("should return zero for the dot product of empty vectors") { | ||
| dotProduct(List.empty, List.empty) shouldBe 0.0 | ||
| } | ||
|
|
||
| it ("should reject vectors of differing dimensionality") { | ||
| an [IllegalArgumentException] should be thrownBy dotProduct(List(1.0, 2.0), List(1.0, 2.0, 3.0)) | ||
| } | ||
| } | ||
|
|
||
| describe("magnitude") { | ||
| it ("should compute the magnitude of a vector") { | ||
| magnitude(List(3.0, 4.0)) shouldBe 5.0 | ||
| } | ||
|
|
||
| it ("should return a magnitude of zero for a zero vector") { | ||
| magnitude(List(0.0, 0.0, 0.0)) shouldBe 0.0 | ||
| } | ||
| } | ||
|
|
||
| describe("cosineSimilarity") { | ||
| it ("should return a cosine similarity of 1 for identical vectors") { | ||
| cosineSimilarity(List(1.0, 2.0, 3.0), List(1.0, 2.0, 3.0)).value shouldBe 1.0 +- tolerance | ||
| } | ||
|
|
||
| it ("should return a cosine similarity of 1 for parallel vectors") { | ||
| cosineSimilarity(List(1.0, 2.0, 3.0), List(2.0, 4.0, 6.0)).value shouldBe 1.0 +- tolerance | ||
| } | ||
|
|
||
| it ("should return a cosine similarity of 0 for orthogonal vectors") { | ||
| cosineSimilarity(List(1.0, 0.0), List(0.0, 1.0)).value shouldBe 0.0 +- tolerance | ||
| } | ||
|
|
||
| it ("should return a cosine similarity of -1 for opposite vectors") { | ||
| cosineSimilarity(List(1.0, 2.0, 3.0), List(-1.0, -2.0, -3.0)).value shouldBe -1.0 +- tolerance | ||
| } | ||
|
|
||
| it ("should be undefined (None) when the first vector has zero magnitude") { | ||
| cosineSimilarity(List(0.0, 0.0, 0.0), List(1.0, 2.0, 3.0)) shouldBe None | ||
| } | ||
|
|
||
| it ("should be undefined (None) when the second vector has zero magnitude") { | ||
| cosineSimilarity(List(1.0, 2.0, 3.0), List(0.0, 0.0, 0.0)) shouldBe None | ||
| } | ||
|
|
||
| it ("should be undefined (None) when both vectors have zero magnitude") { | ||
| cosineSimilarity(List(0.0, 0.0, 0.0), List(0.0, 0.0, 0.0)) shouldBe None | ||
| } | ||
|
|
||
| it ("should be undefined (None) when the vectors differ in dimensionality") { | ||
| cosineSimilarity(List(1.0, 2.0), List(1.0, 2.0, 3.0)) shouldBe None | ||
| } | ||
| } | ||
|
|
||
| // This function is specifically for use in tests, | ||
| // where we need to create test fixtures with specific semantic scores. | ||
| describe("vectorWithCosineSimilarity") { | ||
| val dims = 256 | ||
|
|
||
| it ("should create a vector at the requested cosine similarity across a fine sweep of the valid range") { | ||
| val similarities = (-1000 to 1000).map(_ / 1000.0) | ||
| forAll(similarities) { s => | ||
| cosineSimilarity(firstBasisVector(dims), vectorWithCosineSimilarity(dims, s)).value shouldBe s +- tolerance | ||
| } | ||
| } | ||
|
|
||
| it ("should create a vector at the requested cosine similarity for randomly generated similarities") { | ||
| val similarityGen = Gen.chooseNum(-1.0, 1.0) | ||
| val samples = Gen.listOfN(500, similarityGen).sample.getOrElse(Nil) | ||
| samples should not be empty | ||
| forAll(samples) { s => | ||
| cosineSimilarity(firstBasisVector(dims), vectorWithCosineSimilarity(dims, s)).value shouldBe s +- tolerance | ||
| } | ||
| } | ||
|
|
||
| it ("should work across a range of dimensions") { | ||
| forAll(List(2, 3, 8, 16, 128, 256, 1024)) { n => | ||
| cosineSimilarity(firstBasisVector(n), vectorWithCosineSimilarity(n, 0.42)).value shouldBe 0.42 +- tolerance | ||
| } | ||
| } | ||
|
|
||
| it ("should return a vector identical to the basis vector for a similarity of 1") { | ||
| vectorWithCosineSimilarity(dims, 1.0) shouldBe firstBasisVector(dims) | ||
| cosineSimilarity(firstBasisVector(dims), vectorWithCosineSimilarity(dims, 1.0)).value shouldBe 1.0 +- tolerance | ||
| } | ||
|
|
||
| it ("should return a vector opposite to the basis vector for a similarity of -1") { | ||
| cosineSimilarity(firstBasisVector(dims), vectorWithCosineSimilarity(dims, -1.0)).value shouldBe -1.0 +- tolerance | ||
| } | ||
|
|
||
| it ("should return a vector orthogonal to the basis vector for a similarity of 0") { | ||
| cosineSimilarity(firstBasisVector(dims), vectorWithCosineSimilarity(dims, 0.0)).value shouldBe 0.0 +- tolerance | ||
| } | ||
|
|
||
| it ("should negate the vector when the similarity is negated") { | ||
| val positive = vectorWithCosineSimilarity(dims, 0.25) | ||
| val negative = vectorWithCosineSimilarity(dims, -0.25) | ||
| forAll(positive.zip(negative)) { case (p, n) => n shouldBe (-p) +- tolerance } | ||
| } | ||
|
|
||
| it ("should produce only finite components for valid similarities") { | ||
| forAll(List(-1.0, -0.5, -1e-3, 0.0, 1e-3, 0.5, 1.0)) { s => | ||
| forAll(vectorWithCosineSimilarity(dims, s)) { component => | ||
| component.isNaN shouldBe false | ||
| component.isInfinite shouldBe false | ||
| } | ||
| } | ||
| } | ||
|
|
||
| it ("should reject a similarity greater than 1") { | ||
| an [IllegalArgumentException] should be thrownBy vectorWithCosineSimilarity(dims, 1.0001) | ||
| } | ||
|
|
||
| it ("should reject a similarity less than -1") { | ||
| an [IllegalArgumentException] should be thrownBy vectorWithCosineSimilarity(dims, -1.0001) | ||
| } | ||
|
|
||
| it ("should reject fewer than 2 dimensions") { | ||
| an [IllegalArgumentException] should be thrownBy vectorWithCosineSimilarity(1, 0.5) | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.