packages feed

langchain-hs-0.0.5.0: src/Langchain/TextSplitter/Markdown.hs

{-# LANGUAGE OverloadedStrings #-}

{- |
Module      : Langchain.TextSplitter.Markdown
Description : Markdown document header-aware text splitter
Copyright   : (c) 2025-2026 Tushar Adhatrao
License     : MIT
Maintainer  : Tushar Adhatrao <tusharadhatrao@gmail.com>
Stability   : experimental

Splits markdown text based on structural headers (# Header 1, ## Header 2, etc.)
and generates chunks with inherited header context.
-}
module Langchain.TextSplitter.Markdown
  ( MarkdownSplitterOps (..)
  , MarkdownChunk (..)
  , defaultMarkdownSplitterOps
  , splitMarkdown
  , splitMarkdownToChunks
  ) where

import Data.Int (Int64)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Text.Lazy (Text)
import qualified Data.Text.Lazy as T

import Langchain.TextSplitter.RecursiveCharacter
  ( RecursiveCharacterSplitterOps (..)
  , defaultRecursiveCharacterSplitterOps
  , splitTextRecursive
  )

-- | Represents a chunk of markdown text with its associated header hierarchy
data MarkdownChunk = MarkdownChunk
  { chunkContent :: Text
  , chunkHeaders :: Map Text Text
  }
  deriving (Show, Eq)

-- | Configuration options for markdown text splitting
data MarkdownSplitterOps = MarkdownSplitterOps
  { mdChunkSize :: Int64
  , mdChunkOverlap :: Int64
  , headersToSplitOn :: [(Text, Text)] -- e.g. [("#", "Header 1"), ("##", "Header 2"), ("###", "Header 3")]
  }
  deriving (Show, Eq)

-- | Default markdown splitter options
defaultMarkdownSplitterOps :: MarkdownSplitterOps
defaultMarkdownSplitterOps =
  MarkdownSplitterOps
    { mdChunkSize = 1000
    , mdChunkOverlap = 100
    , headersToSplitOn =
        [ ("#", "Header 1")
        , ("##", "Header 2")
        , ("###", "Header 3")
        , ("####", "Header 4")
        ]
    }

-- | Split markdown document into MarkdownChunks with header metadata
splitMarkdownToChunks :: MarkdownSplitterOps -> Text -> [MarkdownChunk]
splitMarkdownToChunks _ "" = []
splitMarkdownToChunks ops text =
  let rawLines = T.lines text
      sections = groupLinesByHeaders (headersToSplitOn ops) Map.empty rawLines
   in concatMap (subSplitSection ops) sections

-- | Split markdown text into plain Text chunks
splitMarkdown :: MarkdownSplitterOps -> Text -> [Text]
splitMarkdown ops text = map chunkContent (splitMarkdownToChunks ops text)

-- Group lines into header-annotated sections
groupLinesByHeaders :: [(Text, Text)] -> Map Text Text -> [Text] -> [MarkdownChunk]
groupLinesByHeaders _ _ [] = []
groupLinesByHeaders headerRules currentHeaders ls = go currentHeaders [] ls
  where
    go :: Map Text Text -> [Text] -> [Text] -> [MarkdownChunk]
    go hdrs acc [] = [MarkdownChunk (T.unlines (reverse acc)) hdrs | not (null acc)]
    go hdrs acc (l : rest) =
      case matchHeader headerRules l of
        Just (hPrefix, hName, hTitle) ->
          let currentChunk = [MarkdownChunk (T.unlines (reverse acc)) hdrs | not (null acc)]
              -- update headers: clear deeper headers when higher header occurs
              newHdrs = updateHeaderMap headerRules hPrefix hName hTitle hdrs
           in currentChunk ++ go newHdrs [l] rest
        Nothing ->
          go hdrs (l : acc) rest

matchHeader :: [(Text, Text)] -> Text -> Maybe (Text, Text, Text)
matchHeader rules line =
  let stripped = T.stripStart line
   in findRule rules stripped
  where
    findRule [] _ = Nothing
    findRule ((prefix, name) : rs) s =
      let prefixWithSpace = prefix <> " "
       in if prefixWithSpace `T.isPrefixOf` s
            then Just (prefix, name, T.strip (T.drop (T.length prefixWithSpace) s))
            else findRule rs s

updateHeaderMap :: [(Text, Text)] -> Text -> Text -> Text -> Map Text Text -> Map Text Text
updateHeaderMap rules prefix name title curMap =
  let prefixDepth = T.length prefix
      -- keep only headers with depth < prefixDepth
      filtered =
        Map.filterWithKey
          ( \k _ -> case lookupPrefixKey rules k of
              Just p -> T.length p < prefixDepth
              Nothing -> True
          )
          curMap
   in Map.insert name title filtered

lookupPrefixKey :: [(Text, Text)] -> Text -> Maybe Text
lookupPrefixKey [] _ = Nothing
lookupPrefixKey ((p, n) : rest) name
  | n == name = Just p
  | otherwise = lookupPrefixKey rest name

subSplitSection :: MarkdownSplitterOps -> MarkdownChunk -> [MarkdownChunk]
subSplitSection ops (MarkdownChunk content hdrs) =
  let cSize = mdChunkSize ops
      cOverlap = mdChunkOverlap ops
   in if T.length content <= cSize
        then [MarkdownChunk content hdrs]
        else
          let subOps =
                defaultRecursiveCharacterSplitterOps
                  { chunkSize = cSize
                  , chunkOverlap = cOverlap
                  , separators = ["\n\n", "\n", " ", ""]
                  }
              subPieces = splitTextRecursive subOps content
           in [MarkdownChunk piece hdrs | piece <- subPieces]