packages feed

langchain-hs-0.0.5.0: src/Langchain/Retriever/Hybrid.hs

{-# LANGUAGE CPP #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE TypeFamilies #-}

{- |
Module      : Langchain.Retriever.Hybrid
Description : Hybrid Dense + Sparse Retriever with Reciprocal Rank Fusion (RRF)
Copyright   : (c) 2025-2026 Tushar Adhatrao
License     : MIT
Maintainer  : Tushar Adhatrao <tusharadhatrao@gmail.com>
Stability   : experimental

Combines sparse keyword search (BM25) and dense semantic vector search using
Reciprocal Rank Fusion (RRF) scoring: RRF(d) = sum_i ( weight_i / (k + rank_i(d)) ).
-}
module Langchain.Retriever.Hybrid
  ( HybridRetriever (..)
  , newHybridRetriever
  , newHybridRetrieverWithWeights
  , searchHybrid
  , searchHybridWithScores
  , reciprocalRankFusion
  ) where

import Control.Monad.Except (runExceptT)
import Control.Monad.IO.Class (MonadIO, liftIO)
import Langchain.Core.Runnable (Runnable (..))
#if MIN_VERSION_base(4,20,0)
import Data.List (sortBy)
#else
import Data.List (foldl', sortBy)
#endif
import qualified Data.Map.Strict as Map
import Data.Ord (Down (..), comparing)
import Data.Text (Text)

import Langchain.DocumentLoader.Core (Document (..))
import Langchain.Retriever.BM25 (BM25Index, bm25Search)
import Langchain.Retriever.Core (Retriever (..))

-- | Configuration and handles for Hybrid Retrieval
data HybridRetriever = HybridRetriever
  { hybridBM25 :: !BM25Index
  , hybridVectorSearch :: !(Text -> Int -> IO [Document])
  , hybridRrfK :: !Double
  , hybridDenseWeight :: !Double
  , hybridSparseWeight :: !Double
  }

instance Show HybridRetriever where
  show HybridRetriever {..} =
    "HybridRetriever { hybridRrfK = "
      ++ show hybridRrfK
      ++ ", hybridDenseWeight = "
      ++ show hybridDenseWeight
      ++ ", hybridSparseWeight = "
      ++ show hybridSparseWeight
      ++ " }"

instance Retriever HybridRetriever where
  getRelevantDocuments hr query = searchHybrid hr query 5

-- | Construct a default Hybrid Retriever (rrfK = 60.0, equal weights = 1.0)
newHybridRetriever ::
  BM25Index ->
  (Text -> Int -> IO [Document]) ->
  HybridRetriever
newHybridRetriever bm25 vecSearch =
  newHybridRetrieverWithWeights bm25 vecSearch 60.0 1.0 1.0

-- | Construct a Hybrid Retriever with custom RRF smoothing and weights
newHybridRetrieverWithWeights ::
  BM25Index ->
  (Text -> Int -> IO [Document]) ->
  Double ->
  Double ->
  Double ->
  HybridRetriever
newHybridRetrieverWithWeights bm25 vecSearch rrfK denseW sparseW =
  HybridRetriever
    { hybridBM25 = bm25
    , hybridVectorSearch = vecSearch
    , hybridRrfK = rrfK
    , hybridDenseWeight = denseW
    , hybridSparseWeight = sparseW
    }

-- | Compute Reciprocal Rank Fusion score for documents across ranked lists
reciprocalRankFusion ::
  Double ->
  [([Document], Double)] -> -- List of (ranked documents, weight)
  [(Document, Double)]
reciprocalRankFusion rrfK rankedLists =
  let scoreMap = foldl' processList Map.empty rankedLists
      docLookup = foldl' buildLookup Map.empty [d | (docs, _) <- rankedLists, d <- docs]
      scoredDocs =
        [ (doc, score)
        | (contentKey, score) <- Map.toList scoreMap
        , Just doc <- [Map.lookup contentKey docLookup]
        ]
   in sortBy (comparing (Down . snd)) scoredDocs
  where
    processList accMap (docs, weight) =
      let indexed = zip [1 ..] docs
       in foldl' (updateScore weight) accMap indexed

    updateScore weight acc (rank, doc) =
      let key = pageContent doc
          delta = weight / (rrfK + rank)
       in Map.insertWith (+) key delta acc

    buildLookup acc doc = Map.insert (pageContent doc) doc acc

-- | Execute hybrid search returning top-k documents
searchHybrid ::
  (MonadIO m) =>
  HybridRetriever ->
  Text ->
  Int ->
  m [Document]
searchHybrid hr query k = map fst <$> searchHybridWithScores hr query k

-- | Execute hybrid search returning top-k documents with RRF scores
searchHybridWithScores ::
  (MonadIO m) =>
  HybridRetriever ->
  Text ->
  Int ->
  m [(Document, Double)]
searchHybridWithScores HybridRetriever {..} query k = do
  -- 1. Run sparse BM25 search (fetch 2 * k candidates)
  let sparseDocs = bm25Search hybridBM25 query (k * 2)

  -- 2. Run dense vector search (fetch 2 * k candidates)
  denseDocs <- liftIO $ hybridVectorSearch query (k * 2)

  -- 3. Fuse rankings via RRF
  let fused =
        reciprocalRankFusion
          hybridRrfK
          [ (denseDocs, hybridDenseWeight)
          , (sparseDocs, hybridSparseWeight)
          ]

  pure $ take k fused

-- | 'HybridRetriever' implements 'Runnable' mapping search query 'Text' to fused '[Document]' results.
instance MonadIO m => Runnable HybridRetriever m where
  type RunnableInput HybridRetriever = Text
  type RunnableOutput HybridRetriever = [Document]
  invoke hr query = runExceptT (searchHybrid hr query 5)