9bb4a12562
- Modified the SQL clause in the KeywordsRetrieve function to utilize ParadeDB's ||| operator for improved token matching. - Enhanced the handling of retrieval results in the fuseOrDeduplicate function to maintain original scores for keyword-only results, ensuring better performance in FAQ scenarios. This update optimizes the keyword retrieval process and improves the accuracy of search results.
142 lines
4.8 KiB
Go
142 lines
4.8 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
|
|
"slices"
|
|
|
|
"github.com/Tencent/WeKnora/internal/logger"
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|
)
|
|
|
|
// classifyRetrievalResults separates retrieval results by retriever type (vector vs keyword).
|
|
func classifyRetrievalResults(ctx context.Context, retrieveResults []*types.RetrieveResult) (
|
|
vectorResults, keywordResults []*types.IndexWithScore,
|
|
) {
|
|
for _, retrieveResult := range retrieveResults {
|
|
logger.Infof(ctx, "Retrieval results, engine: %v, retriever: %v, count: %v",
|
|
retrieveResult.RetrieverEngineType,
|
|
retrieveResult.RetrieverType,
|
|
len(retrieveResult.Results),
|
|
)
|
|
if retrieveResult.RetrieverType == types.VectorRetrieverType {
|
|
vectorResults = append(vectorResults, retrieveResult.Results...)
|
|
} else {
|
|
keywordResults = append(keywordResults, retrieveResult.Results...)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// fuseOrDeduplicate either fuses vector+keyword results via RRF or deduplicates vector-only results.
|
|
func fuseOrDeduplicate(ctx context.Context, vectorResults, keywordResults []*types.IndexWithScore) []*types.IndexWithScore {
|
|
if len(keywordResults) == 0 {
|
|
// Vector-only: keep original embedding scores (important for FAQ)
|
|
result := deduplicateByScore(vectorResults)
|
|
logger.Infof(ctx, "Result count after deduplication: %d", len(result))
|
|
return result
|
|
}
|
|
if len(vectorResults) == 0 {
|
|
// Keyword-only: keep original scores (important for FAQ)
|
|
result := deduplicateByScore(keywordResults)
|
|
logger.Infof(ctx, "Result count after deduplication: %d", len(result))
|
|
return result
|
|
}
|
|
// Hybrid: use RRF fusion to merge vector + keyword results
|
|
result := fuseWithRRF(ctx, vectorResults, keywordResults)
|
|
logger.Infof(ctx, "Result count after RRF fusion: %d", len(result))
|
|
return result
|
|
}
|
|
|
|
// sortByScoreDesc is a reusable sort comparator for IndexWithScore slices (descending by Score).
|
|
func sortByScoreDesc(a, b *types.IndexWithScore) int {
|
|
if a.Score > b.Score {
|
|
return -1
|
|
} else if a.Score < b.Score {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// deduplicateByScore deduplicates retrieval results by chunk ID, keeping the highest score
|
|
// for each chunk. Returns the results sorted by score descending.
|
|
// Used when only a single retriever (e.g. vector-only for FAQ) is active.
|
|
func deduplicateByScore(results []*types.IndexWithScore) []*types.IndexWithScore {
|
|
chunkInfoMap := make(map[string]*types.IndexWithScore, len(results))
|
|
for _, r := range results {
|
|
if existing, exists := chunkInfoMap[r.ChunkID]; !exists || r.Score > existing.Score {
|
|
chunkInfoMap[r.ChunkID] = r
|
|
}
|
|
}
|
|
deduped := make([]*types.IndexWithScore, 0, len(chunkInfoMap))
|
|
for _, info := range chunkInfoMap {
|
|
deduped = append(deduped, info)
|
|
}
|
|
slices.SortFunc(deduped, sortByScoreDesc)
|
|
return deduped
|
|
}
|
|
|
|
// fuseWithRRF merges vector and keyword retrieval results using Reciprocal Rank Fusion.
|
|
// RRF score = vectorWeight/(k+vectorRank) + keywordWeight/(k+keywordRank), with k=60.
|
|
// The merged results are sorted by RRF score descending.
|
|
func fuseWithRRF(ctx context.Context, vectorResults, keywordResults []*types.IndexWithScore) []*types.IndexWithScore {
|
|
const rrfK = 60
|
|
const vectorWeight = 0.7
|
|
const keywordWeight = 0.3
|
|
|
|
// Build rank maps for each retriever (already sorted by score from retriever)
|
|
vectorRanks := make(map[string]int, len(vectorResults))
|
|
for i, r := range vectorResults {
|
|
if _, exists := vectorRanks[r.ChunkID]; !exists {
|
|
vectorRanks[r.ChunkID] = i + 1 // 1-indexed rank
|
|
}
|
|
}
|
|
keywordRanks := make(map[string]int, len(keywordResults))
|
|
for i, r := range keywordResults {
|
|
if _, exists := keywordRanks[r.ChunkID]; !exists {
|
|
keywordRanks[r.ChunkID] = i + 1
|
|
}
|
|
}
|
|
|
|
// Collect all unique chunks — prefer vector result's metadata for each chunk
|
|
chunkInfoMap := make(map[string]*types.IndexWithScore)
|
|
for _, r := range vectorResults {
|
|
if existing, exists := chunkInfoMap[r.ChunkID]; !exists || r.Score > existing.Score {
|
|
chunkInfoMap[r.ChunkID] = r
|
|
}
|
|
}
|
|
for _, r := range keywordResults {
|
|
if _, exists := chunkInfoMap[r.ChunkID]; !exists {
|
|
chunkInfoMap[r.ChunkID] = r
|
|
}
|
|
}
|
|
|
|
// Compute weighted RRF scores and assign to each chunk
|
|
result := make([]*types.IndexWithScore, 0, len(chunkInfoMap))
|
|
for chunkID, info := range chunkInfoMap {
|
|
rrfScore := 0.0
|
|
if rank, ok := vectorRanks[chunkID]; ok {
|
|
rrfScore += vectorWeight / float64(rrfK+rank)
|
|
}
|
|
if rank, ok := keywordRanks[chunkID]; ok {
|
|
rrfScore += keywordWeight / float64(rrfK+rank)
|
|
}
|
|
info.Score = rrfScore
|
|
result = append(result, info)
|
|
}
|
|
slices.SortFunc(result, sortByScoreDesc)
|
|
|
|
// Log top results for debugging
|
|
for i, chunk := range result {
|
|
if i >= 15 {
|
|
break
|
|
}
|
|
vRank, vOk := vectorRanks[chunk.ChunkID]
|
|
kRank, kOk := keywordRanks[chunk.ChunkID]
|
|
logger.Debugf(ctx, "RRF rank %d: chunk_id=%s, rrf_score=%.6f, vector_rank=%v(%v), keyword_rank=%v(%v)",
|
|
i, chunk.ChunkID, chunk.Score, vRank, vOk, kRank, kOk)
|
|
}
|
|
|
|
return result
|
|
}
|