This issue has been created
 
 
LLM AI Integration / cid:jira-generated-image-avatar-5582fa1d-65aa-4885-abf7-e40bd5a3f23e LLMAI-171 Open

Hybrid search sorts BM25 and cosine scores in one comparator, so keyword hits always outrank semantic hits

 
View issue   ยท   Add comment
 

Issue created

 
cid:jira-generated-image-avatar-07c4ead2-09e9-4173-abf5-2f04bb1780ce Sebastian Elsner created this issue on 17/Aug/26 16:01
 
Summary: Hybrid search sorts BM25 and cosine scores in one comparator, so keyword hits always outrank semantic hits
Issue Type: cid:jira-generated-image-avatar-5582fa1d-65aa-4885-abf7-e40bd5a3f23e Bug
Affects Versions: 0.9
Assignee: Unassigned
Created: 17/Aug/26 16:01
Environment: XWiki 18.6.0, ai-llm 0.9, bundled Solr 9.4.1, DJL 0.36.0, internal inference server with sentence-transformers/all-MiniLM-L6-v2.
Priority: cid:jira-generated-image-static-major-a03a8224-654c-4f75-a204-e742a50e1327 Major
Reporter: Sebastian Elsner
Description:

What happens

DefaultCollectionManager.hybridSearch concatenates the semantic and the keyword result lists, de-duplicates on identical chunk content, and hands the merged list to filterSearchResults, which re-sorts it:

// Sort the results by similarity score in descending order again as the sorting was lost during grouping.
.sorted(Comparator.comparingDouble(Context::similarityScore).reversed())

The two halves populate Context.similarityScore from different, unrelated scales:

  • the semantic half is a Solr kNN query on a DenseVectorField with similarityFunction=cosine. Lucene's VectorSimilarityFunction.COSINE returns (1 + cos) / 2, so the score is bounded to 0..1.
  • the keyword half is an ordinary Solr query scored by BM25, which is unbounded and in practice lands between about 1 and 30.

Since the semantic score can never exceed 1.0, every keyword hit that scores above 1.0 โ€“ i.e. essentially every real keyword match โ€“ is sorted above every semantic hit, no matter how good the vector match was or how weak the keyword match was.

Why it matters

The merged list is both the LLM's context, in order, via RAGChatRequestFilter.buildContext, and the user-visible Sources list. So the strongest semantic hit is pushed below the weakest keyword hit in the prompt and in the citations. Enabling keyword search therefore actively degrades the ordering that vector search got right.

Observed

Query: "How do I install zsh and oh my zsh at rise?", one collection, maxSemanticResults=5, maxKeywordResults=5. The only relevant page in the wiki is Development/zsh, and it is the top hit in both halves independently:

  • keyword-only: zsh 21.63, then four chunks that do not contain the string "zsh" at all (5.70 / 5.39 / 5.36 / 5.33)
  • semantic-only: zsh 0.818, then unrelated chunks (0.691 / 0.686 / 0.686 / 0.684)
  • hybrid: the four irrelevant keyword chunks come first (5.70 ... 5.33) and Development/zsh lands at position 5 of 9.

Suggested fix

Do not compare the two scores directly. Options, roughly in order of robustness:

  1. Reciprocal Rank Fusion over the two ranked lists โ€“ the standard hybrid-search answer, needs no score comparability at all.
  2. Min-max normalise each list to 0..1 before merging.
  3. Interleave the two lists round-robin by rank.

Whichever is chosen, Context.similarityScore would benefit from documenting which scale it carries, since REST and MCP consumers see it too.