packages feed

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

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

{- |
Module      : Langchain.Retriever.BM25
Description : Okapi BM25 Sparse Inverted Index Retriever
Copyright   : (c) 2025-2026 Tushar Adhatrao
License     : MIT
Maintainer  : Tushar Adhatrao <tusharadhatrao@gmail.com>
Stability   : experimental

Pure Haskell implementation of the Okapi BM25 ranking algorithm for sparse keyword retrieval.
Supports document addition, customized k1 and b parameters, and fast inverted index scoring.
-}
module Langchain.Retriever.BM25
  ( BM25Index (..)
  , newBM25Index
  , newBM25IndexWithParams
  , addDocumentsBM25
  , bm25Search
  , bm25SearchWithScores
  , tokenize
  ) where

import Langchain.Core.Runnable (Runnable (..))

import Data.Char (isAlphaNum)
#if MIN_VERSION_base(4,20,0)
import Data.List (sortBy)
#else
import Data.List (foldl', sortBy)
#endif
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Ord (Down (..), comparing)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Lazy as TL
import GHC.Generics (Generic)

import Langchain.DocumentLoader.Core (Document (..))
import Langchain.Retriever.Core (Retriever (..))

-- | BM25 Index containing documents, lengths, and inverted index
data BM25Index = BM25Index
  { bm25Docs :: ![Document]
  , bm25DocLens :: !(Map Int Int)
  , bm25AvgDocLen :: !Double
  , bm25InvertedIndex :: !(Map Text (Map Int Int))
  , bm25K1 :: !Double
  , bm25B :: !Double
  }
  deriving (Show, Eq, Generic)

instance Retriever BM25Index where
  getRelevantDocuments index query = pure $ bm25Search index query 5

-- | Tokenize text into lowercased alphanumeric terms
tokenize :: Text -> [Text]
tokenize = filter (not . T.null) . map (T.filter isAlphaNum . T.toLower) . T.words

-- | Construct a BM25 index with default parameters (k1 = 1.5, b = 0.75)
newBM25Index :: [Document] -> BM25Index
newBM25Index = newBM25IndexWithParams 1.5 0.75

-- | Construct a BM25 index with customized k1 and b parameters
newBM25IndexWithParams :: Double -> Double -> [Document] -> BM25Index
newBM25IndexWithParams k1 b docs =
  let indexedDocs = zip [0 ..] docs
      docLensList = [(i, length (tokenize (TL.toStrict (pageContent d)))) | (i, d) <- indexedDocs]
      docLens = Map.fromList docLensList
      totalTokens = sum (map snd docLensList)
      nDocs = length docs
      avgLen = if nDocs > 0 then fromIntegral totalTokens / fromIntegral nDocs else 0.0

      -- Build inverted index: term -> docIndex -> termFrequency
      invIndex = foldl' addDocToInvertedIndex Map.empty indexedDocs
   in BM25Index
        { bm25Docs = docs
        , bm25DocLens = docLens
        , bm25AvgDocLen = avgLen
        , bm25InvertedIndex = invIndex
        , bm25K1 = k1
        , bm25B = b
        }
  where
    addDocToInvertedIndex acc (docIdx, doc) =
      let tokens = tokenize (TL.toStrict (pageContent doc))
          tfs = foldl' (\m t -> Map.insertWith (+) t 1 m) Map.empty tokens
       in Map.foldlWithKey'
            (\accM t count -> Map.insertWith Map.union t (Map.singleton docIdx count) accM)
            acc
            tfs

-- | Add new documents to an existing BM25 index
addDocumentsBM25 :: [Document] -> BM25Index -> BM25Index
addDocumentsBM25 newDocs BM25Index {..} =
  newBM25IndexWithParams bm25K1 bm25B (bm25Docs ++ newDocs)

-- | Perform BM25 search returning top-k documents sorted by score
bm25Search :: BM25Index -> Text -> Int -> [Document]
bm25Search index query k = map fst (bm25SearchWithScores index query k)

-- | Perform BM25 search returning top-k documents with their relevance scores
bm25SearchWithScores :: BM25Index -> Text -> Int -> [(Document, Double)]
bm25SearchWithScores BM25Index {..} query k
  | null bm25Docs || null queryTokens = []
  | otherwise =
      let nTotalDocs = fromIntegral (length bm25Docs)
          -- Accumulate BM25 score per document
          scores = foldl' (scoreTerm nTotalDocs) (Map.empty :: Map Int Double) queryTokens
          indexedDocs = zip [0 ..] bm25Docs
          scoredList =
            [ (doc, score)
            | (idx, doc) <- indexedDocs
            , let score = Map.findWithDefault 0.0 idx scores
            , score > 0.0
            ]
          sorted = sortBy (comparing (Down . snd)) scoredList
       in take k sorted
  where
    queryTokens = tokenize query

    scoreTerm nTotalDocs accScores term =
      case Map.lookup term bm25InvertedIndex of
        Nothing -> accScores
        Just postingMap ->
          let nDocWithTerm = fromIntegral (Map.size postingMap)
              -- Okapi BM25 IDF: ln(1 + (N - n + 0.5) / (n + 0.5))
              idf = log (1.0 + (nTotalDocs - nDocWithTerm + 0.5) / (nDocWithTerm + 0.5))
           in Map.foldlWithKey' (updateDocScore idf) accScores postingMap

    updateDocScore idf acc docIdx tf =
      let docLen = fromIntegral (Map.findWithDefault 1 docIdx bm25DocLens)
          normLen = if bm25AvgDocLen > 0 then docLen / bm25AvgDocLen else 1.0
          tfD = fromIntegral tf
          -- Okapi BM25 TF component
          tfWeight = (tfD * (bm25K1 + 1.0)) / (tfD + bm25K1 * (1.0 - bm25B + bm25B * normLen))
          scoreDelta = idf * tfWeight
       in Map.insertWith (+) docIdx scoreDelta acc

-- | 'BM25Index' implements 'Runnable' mapping a search query 'Text' to '[Document]' results.
instance Monad m => Runnable BM25Index m where
  type RunnableInput BM25Index = Text
  type RunnableOutput BM25Index = [Document]
  invoke idx query = pure $ Right (bm25Search idx query 5)