packages feed

full-text-search 0.2.2.2 → 0.2.2.3

raw patch · 26 files changed

+2471/−2445 lines, 26 filesdep ~Cabaldep ~arraydep ~basenew-uploaderPVP ok

version bump matches the API change (PVP)

Dependency ranges changed: Cabal, array, base, bytestring, containers, directory, filepath, mtl, split, tar, text, time, tokenize, transformers, vector

API changes (from Hackage documentation)

Files

− Data/SearchEngine.hs
@@ -1,44 +0,0 @@-module Data.SearchEngine (--    -- * Basic interface--    -- ** Querying-    Term,-    query,--    -- *** Query auto-completion \/ auto-suggestion-    queryAutosuggest,-    ResultsFilter(..),-    queryAutosuggestPredicate,-    queryAutosuggestMatchingDocuments,--    -- ** Making a search engine instance-    initSearchEngine,-    SearchEngine,-    SearchConfig(..),-    SearchRankParameters(..),-    FeatureFunction(..),--    -- ** Helper type for non-term features-    NoFeatures,-    noFeatures,--    -- ** Managing documents to be searched-    insertDoc,-    insertDocs,-    deleteDoc,--    -- * Explain mode for query result rankings-    queryExplain,-    Explanation(..),-    setRankParams,--    -- * Internal sanity check-    invariant,-  ) where--import Data.SearchEngine.Types-import Data.SearchEngine.Update-import Data.SearchEngine.Query-import Data.SearchEngine.Autosuggest-
− Data/SearchEngine/Autosuggest.hs
@@ -1,573 +0,0 @@-{-# LANGUAGE BangPatterns, NamedFieldPuns, RecordWildCards,-             ScopedTypeVariables #-}--module Data.SearchEngine.Autosuggest (--    -- * Query auto-completion \/ auto-suggestion-    queryAutosuggest,-    ResultsFilter(..),--    queryAutosuggestPredicate,-    queryAutosuggestMatchingDocuments--  ) where--import Data.SearchEngine.Types-import Data.SearchEngine.Query (ResultsFilter(..))-import qualified Data.SearchEngine.Query       as Query-import qualified Data.SearchEngine.SearchIndex as SI-import qualified Data.SearchEngine.DocIdSet    as DocIdSet-import qualified Data.SearchEngine.DocTermIds  as DocTermIds-import qualified Data.SearchEngine.BM25F       as BM25F--import Data.Ix-import Data.Ord-import Data.List-import Data.Maybe-import qualified Data.Map as Map-import qualified Data.IntSet as IntSet-import qualified Data.Vector.Unboxed as Vec----- | Execute an \"auto-suggest\" query. This is where one of the search terms--- is an incomplete prefix and we are looking for possible completions of that--- search term, and result documents to go with the possible completions.------ An auto-suggest query only gives useful results when the 'SearchEngine' is--- configured to use a non-term feature score. That is, when we can give--- documents an importance score independent of what terms we are looking for.--- This is because an auto-suggest query is backwards from a normal query: we--- are asking for relevant terms occurring in important or popular documents--- so we need some notion of important or popular. Without this we would just--- be ranking based on term frequency which while it makes sense for normal--- \"forward\" queries is pretty meaningless for auto-suggest \"reverse\"--- queries. Indeed for single-term auto-suggest queries the ranking function--- we use will assign 0 for all documents and completions if there is no --- non-term feature scores.----queryAutosuggest :: (Ix field, Bounded field, Ix feature, Bounded feature) =>-                    SearchEngine doc key field feature ->-                    ResultsFilter key ->-                    [Term] -> Term -> ([(Term, Float)], [(key, Float)])-queryAutosuggest se resultsFilter precedingTerms partialTerm =--     step_external-   . step_rank-   . step_scoreDs-   . step_scoreTs-   . step_cache-   . step_postfilterlimit-   . step_filter-   . step_prefilterlimit-   . step_process-   $ step_prep-       precedingTerms partialTerm--  where-    -- Construct the auto-suggest query from the query terms-    step_prep pre_ts t = mkAutosuggestQuery se pre_ts t--    -- Find the appropriate subset of ts and ds-    -- and an intermediate result that will be useful later:-    -- { (t, ds ∩ ds_t) | t ∈ ts, ds ∩ ds_t ≠ ∅ }-    step_process (ts, ds, pre_ts) = (ts', ds', tdss', pre_ts)-      where-        (tdss', ts', ds') = processAutosuggestQuery se (ts, ds, pre_ts)--    -- If the number of docs results is huge then we may not want to bother-    -- and just return no results. Even the filtering of a huge number of-    -- docs can be expensive.-    step_prefilterlimit args@(_, ds, _, _)-      | withinPrefilterLimit se ds = args-      | otherwise                  = ([], DocIdSet.empty, [], [])--    -- Filter ds to those that are visible for this query-    -- and at the same time, do the docid -> docinfo lookup-    -- (needed at this step anyway to do the filter)-    step_filter (ts, ds, tdss, pre_ts) = (ts, ds_info, tdss, pre_ts)-      where-        ds_info = filterAutosuggestQuery se resultsFilter ds--    -- If the number of docs results is huge then we may not want to bother-    -- and just return no results. Scoring a large number of docs is expensive.-    step_postfilterlimit args@(_, ds_info, _, _)-      | withinPostfilterLimit se ds_info = args-      | otherwise                        = ([], [], [], [])--    -- For all ds, calculate and cache a couple bits of info needed-    -- later for scoring completion terms and doc results-    step_cache (ts, ds_info, tdss, pre_ts) = (ds_info', tdss)-      where-        ds_info' = cacheDocScoringInfo se ts ds_info pre_ts--    -- Score the completion terms-    step_scoreTs (ds_info, tdss) = (ds_info, tdss, ts_scored)-      where-        ts_scored = scoreAutosuggestQueryCompletions tdss ds_info--    -- Score the doc results (making use of the completion scores)-    step_scoreDs (ds_info, tdss, ts_scored) = (ts_scored, ds_scored)-      where-        ds_scored = scoreAutosuggestQueryResults tdss ds_info ts_scored--    -- Rank the completions and results based on their scores-    step_rank = sortResults--    -- Convert from internal Ids into external forms: Term and doc key-    step_external = convertIdsToExternal se----- | Given an incomplete prefix query, find the set of documents that match--- possible completions of that query.  This should be less computationally--- expensive than 'queryAutosuggest' as it does not do any ranking of documents.--- However, it does not apply the pre-filter or post-filter limits, and the list--- may be large when the query terms occur in many documents.  The order of--- returned keys is unspecified.-queryAutosuggestMatchingDocuments :: (Ix field, Bounded field, Ord key) =>-                                     SearchEngine doc key field feature ->-                                     [Term] -> Term -> [key]-queryAutosuggestMatchingDocuments se@SearchEngine{searchIndex} precedingTerms partialTerm =-    let (_, _, ds) = processAutosuggestQuery se (mkAutosuggestQuery se precedingTerms partialTerm)-    in map (SI.getDocKey searchIndex) (DocIdSet.toList ds)---- | Given an incomplete prefix query, return a predicate that indicates whether--- a key is in the set of documents that match possible completions of that--- query.  This is equivalent to calling 'queryAutosuggestMatchingDocuments' and--- testing whether the key is in the list, but should be more efficient.------ This does not apply the pre-filter or post-filter limits.-queryAutosuggestPredicate :: (Ix field, Bounded field, Ord key) =>-                             SearchEngine doc key field feature ->-                             [Term] -> Term -> (key -> Bool)-queryAutosuggestPredicate se@SearchEngine{searchIndex} precedingTerms partialTerm =-    let (_, _, ds) = processAutosuggestQuery se (mkAutosuggestQuery se precedingTerms partialTerm)-    in (\ key -> maybe False (flip DocIdSet.member ds) (SI.lookupDocKeyDocId searchIndex key))----- We apply hard limits both before and after filtering.--- The post-filter limit is to avoid scoring 1000s of documents.--- The pre-filter limit is to avoid filtering 1000s of docs (which in some--- apps may be expensive itself)--withinPrefilterLimit :: SearchEngine doc key field feature ->-                        DocIdSet -> Bool-withinPrefilterLimit SearchEngine{searchRankParams} ds =-    DocIdSet.size ds <= paramAutosuggestPrefilterLimit searchRankParams--withinPostfilterLimit :: SearchEngine doc key field feature ->-                         [a] -> Bool-withinPostfilterLimit SearchEngine{searchRankParams} ds_info =-    length ds_info <= paramAutosuggestPostfilterLimit searchRankParams---sortResults :: (Ord av, Ord bv) => ([(a,av)], [(b,bv)]) -> ([(a,av)], [(b,bv)])-sortResults (xs, ys) =-    ( sortBySndDescending xs-    , sortBySndDescending ys )-  where-    sortBySndDescending :: Ord v => [(x,v)] -> [(x,v)]-    sortBySndDescending = sortBy (flip (comparing snd))--convertIdsToExternal :: SearchEngine doc key field feature ->-                        ([(TermId, v)], [(DocId, v)]) -> ([(Term, v)], [(key, v)])-convertIdsToExternal SearchEngine{searchIndex} (termids, docids) =-    ( [ (SI.getTerm   searchIndex termid, s) | (termid, s) <- termids ]-    , [ (SI.getDocKey searchIndex docid,  s) | (docid,  s) <- docids  ]-    )----- From Bast and Weber:------   An autocompletion query is a pair (T, D), where T is a range of terms---   (all possible completions of the last term which the user has started---   typing) and D is a set of documents (the hits for the preceding part of---   the query).------ We augment this with the preceding terms because we will need these to--- score the set of documents D.------ Note that the set D will be the entire collection in the case that the--- preceding part of the query is empty. For efficiency we represent that--- case specially with Maybe.--type AutosuggestQuery = (Map.Map TermId DocIdSet, Maybe DocIdSet, [TermId])--mkAutosuggestQuery :: (Ix field, Bounded field) =>-                      SearchEngine doc key field feature ->-                      [Term] -> Term -> AutosuggestQuery-mkAutosuggestQuery se@SearchEngine{ searchIndex }-                   precedingTerms partialTerm =-    (completionTerms, precedingDocHits, precedingTerms')-  where-    completionTerms =-      Map.unions-        [ Map.fromList (SI.lookupTermsByPrefix searchIndex partialTerm')-        | partialTerm' <- Query.expandTransformedQueryTerm se partialTerm-        ]--    (precedingTerms', precedingDocHits)-      | null precedingTerms = ([], Nothing)-      | otherwise           = fmap carefulUnions-                                   (lookupRawResults precedingTerms)--    -- For the preceding terms, we compute the union of the sets of documents in-    -- which they appear.  This means that a query like "Apple Blackberry C"-    -- will look for documents containing "Apple" or "Blackberry", then later-    -- intersect that set with documents containing completions of "C".-    ---    -- In general we want to use union rather than intersection here, because-    -- the preceding terms might contain some useful and some missing terms, and-    -- if we took the intersection we would end up with no results; thus we rely-    -- on scoring to rank the best matches highest.-    ---    -- However, this leads to an issue: if some of the terms are extremely-    -- common, we might end up taking unions of very large document sets, which-    -- is a performance disaster.  We address this by unioning only sets smaller-    -- than the pre-filter limit (but falling back on the whole collection if-    -- all sets are too large).  This means that:-    ---    --  * A query containing a mixture of common and uncommon preceding terms-    --    will be completed/ranked solely based on the uncommon terms.  For-    --    example, "Apple Blackberry C" will be equivalent to "Blackberry C" if-    --    there are many apples.-    ---    --  * A query containing only common preceding terms will be-    --    completed/ranked as if only the final term was present.  For example,-    --    "Apple Blackberry C" will be equivalent to "C" if there are many-    --    apples and blackberries.-    ---    carefulUnions :: [DocIdSet] -> Maybe DocIdSet-    carefulUnions dss-      | null dss  = Just DocIdSet.empty-      | null dss' = Nothing-      | otherwise = Just (DocIdSet.unions dss')-      where-        dss' = filter (withinPrefilterLimit se) dss--    lookupRawResults :: [Term] -> ([TermId], [DocIdSet])-    lookupRawResults ts =-      unzip $ catMaybes-        [ SI.lookupTerm searchIndex t'-        | t  <- ts-        , t' <- Query.expandTransformedQueryTerm se t-        ]------ From Bast and Weber:------   To process the query means to compute the subset T' ⊆ T of terms that---   occur in at least one document from D, as well as the subset D' ⊆ D of---   documents that contain at least one of these words.------   The obvious way to use an inverted index to process an autocompletion---   query (T, D) is to compute, for each t ∈ T, the intersections D ∩ Dt.---   Then, T' is simply the set of all t for which the intersection was---   non-empty, and D' is the union of all (non-empty) intersections.------ We will do this but additionally we will return all the non-empty--- intersections because they will be useful when scoring.--processAutosuggestQuery :: SearchEngine doc key field feature ->-                           AutosuggestQuery ->-                           ([(TermId, DocIdSet)], [TermId], DocIdSet)-processAutosuggestQuery se (completionTerms, precedingDocHits, _)-  -- Check all the individual document sets are smaller than the pre-filter-  -- limit.  If any are larger, their union must also be too large, so we return-  -- no results now rather than having to compute the union (which may be-  -- expensive) only for it to inevitably hit the limit.-  | all (withinPrefilterLimit se) docSets =-    ( completionTermAndDocSets-    , completionTerms'-    , allTermDocSet-    )-  | otherwise = ([], [], DocIdSet.empty)-  where-    -- We look up each candidate completion to find the set of documents-    -- it appears in, and filtering (intersecting) down to just those-    -- appearing in the existing partial query results (if any).-    -- Candidate completions not appearing at all within the existing-    -- partial query results are excluded at this stage.-    ---    -- We have to keep these doc sets for the whole process, so we keep-    -- them as the compact DocIdSet type.-    ---    completionTermAndDocSets :: [(TermId, DocIdSet)]-    completionTermAndDocSets =-      [ (t, ds_t')-      | (t, ds_t) <- Map.toList completionTerms-      , let ds_t' = case precedingDocHits of-                      Just ds -> ds `DocIdSet.intersection` ds_t-                      Nothing -> ds_t-      , not (DocIdSet.null ds_t')-      ]--    -- The remaining candidate completions-    completionTerms' :: [TermId]-    docSets :: [DocIdSet]-    (completionTerms', docSets) = unzip completionTermAndDocSets--    -- The union of all these is this set of documents that form the results.-    allTermDocSet :: DocIdSet-    allTermDocSet = DocIdSet.unions docSets---filterAutosuggestQuery :: SearchEngine doc key field feature ->-                          ResultsFilter key ->-                          DocIdSet ->-                          [(DocId, (key, DocTermIds field, DocFeatVals feature))]-filterAutosuggestQuery SearchEngine{ searchIndex } resultsFilter ds =-    case resultsFilter of-      NoFilter ->-        [ (docid, doc)-        | docid <- DocIdSet.toList ds-        , let doc = SI.lookupDocId searchIndex docid ]--      FilterPredicate predicate ->-        [ (docid, doc)-        | docid <- DocIdSet.toList ds-        , let doc@(k,_,_) = SI.lookupDocId searchIndex docid-        , predicate k ]--      FilterBulkPredicate bulkPredicate ->-        [ (docid, doc)-        | let docids = DocIdSet.toList ds-              docinf = map (SI.lookupDocId searchIndex) docids-              keep   = bulkPredicate [ k | (k,_,_) <- docinf ]-        , (docid, doc, True) <- zip3 docids docinf keep ]----- Scoring-------------------- From Bast and Weber:---   In practice, only a selection of items from these lists can and will be---   presented to the user, and it is of course crucial that the most relevant---   completions and hits are selected.------   A standard approach for this task in ad-hoc retrieval is to have a---   precomputed score for each term-in-document pair, and when a query is---   being processed, to aggregate these scores for each candidate document,---   and return documents with the highest such aggregated scores.------   Both INV and HYB can be easily adapted to implement any such scoring and---   aggregation scheme: store by each term-in-document pair its precomputed---   score, and when intersecting, aggregate the scores. A decision has to be---   made on how to reconcile scores from different completions within the---   same document. We suggest the following: when merging the intersections---   (which gives the set D' according to Definition 1), compute for each---   document in D' the maximal score achieved for some completion in T'---   contained in that document, and compute for each completion in T' the---   maximal score achieved for a hit from D' achieved for this completion.------ So firstly let us explore what this means and then discuss why it does not--- work for BM25.------ The "precomputed score for each term-in-document pair" refers to the bm25--- score for this term in this document (and obviously doesn't have to be--- precomputed, though that'd be faster).------ So the score for a document d ∈ D' is:---   maximum of score for d ∈ D ∩ Dt, for any t ∈ T'------ While the score for a completion t ∈ T' is:---   maximum of score for d ∈ D ∩ Dt------ So for documents we say their score is their best score for any of the--- completion terms they contain. While for completions we say their score--- is their best score for any of the documents they appear in.------ For a scoring function like BM25 this appears to be not a good method, both--- in principle and in practice. Consider what terms get high BM25 scores:--- very rare ones. So this means we're going to score highly documents that--- contain the least frequent terms, and completions that are themselves very--- rare. This is silly.------ Another important thing to note is that if we use this scoring method then--- we are using the BM25 score in a way that makes no sense. The BM25 score--- for different documents for the /same/ set of terms are comparable. The--- score for the same for different document with different terms are simply--- not comparable.------ This also makes sense if you consider what question the BM25 score is--- answering: "what is the likelihood that this document is relevant given that--- I judge these terms to be relevant". However an auto-suggest query is--- different: "what is the likelihood that this term is relevant given the--- importance/popularity of the documents (and any preceding terms I've judged--- to be relevant)". They are both conditional likelihood questions but with--- different premises.------ More generally, term frequency information simply isn't useful for--- auto-suggest queries. We don't want results that have the most obscure terms--- nor the most common terms, not even something in-between. Term frequency--- just doesn't tell us anything unless we've already judged terms to be--- relevant, and in an auto-suggest query we've not done that yet.------ What we really need is information on the importance/popularity of the--- documents. We can actually do something with that.------ So, instead we follow a different strategy. We require that we have--- importance/popularity info for the documents.------ A first approximation would be to rank result documents by their importance--- and completion terms by the sum of the importance of the documents each--- term appears in.------ Score for a document d ∈ D'---   importance score for d------ Score for a completion t ∈ T'---   sum of importance score for d ∈ D ∩ Dt------ The only problem with this is that just because a term appears in an--- important document, doesn't mean that term is /about/ that document, or to--- put it another way, that term may not be relevant for that document. For--- example common words like "the" likely appear in all important documents--- but this doesn't really tell us anything because "the" isn't an important--- keyword.------ So what we want to do is to weight the document importance by the relevance--- of the keyword to the document. So now if we have an important document and--- a relevant keyword for that document then we get a high score, but an--- irrelevant term like "the" would get a very low weighting and so would not--- contribute much to the score, even for very important documents.------ The intuition is that we will score term completions by taking the--- document importance weighted by the relevance of that term to that document--- and summing over all the documents where the term occurs.------ We define document importance (for the set D') to be the BM25F score for--- the documents with any preceding terms. So this includes the non-term--- feature score for the importance/popularity, and also takes account of--- preceding terms if there were any.------ We define term relevance (for terms in documents) to be the BM25F score for--- that term in that document as a fraction of the total BM25F score for all--- terms in the document. Thus the relevance of all terms in a document sums--- to 1.------ Now we can re-weight the document importance by the term relevance:------ Score for a completion t ∈ T'---   sum (for d ∈ D ∩ Dt) of ( importance for d * relevance for t in d )------ And now for document result scores. We don't want to just stick with the--- innate document importance. We want to re-weight by the completion term--- scores:------ Score for a document d ∈ D'---   sum (for t ∈ T' ∩ d) (importance score for d * score for completion t)------ Clear as mud?--type DocImportance = Float-type TermRelevanceBreakdown = Map.Map TermId Float---- | Precompute the document importance and the term relevance breakdown for--- all the documents. This will be used in scoring the term completions--- and the result documents. They will all be used and some used many--- times so it's better to compute up-front and share.------ This is actually the expensive bit (which is why we've filtered already).----cacheDocScoringInfo :: (Ix field, Bounded field, Ix feature, Bounded feature) =>-                       SearchEngine doc key field feature ->-                       [TermId] ->-                       [(DocId, (key, DocTermIds field, DocFeatVals feature))] ->-                       [TermId] ->-                       Map.Map DocId (DocImportance, TermRelevanceBreakdown)-cacheDocScoringInfo se completionTerms allTermDocInfo precedingTerms =-    Map.fromList-      [ (docid, (docImportance, termRelevances))-      | (docid, (_dockey, doctermids, docfeatvals)) <- allTermDocInfo-      , let docImportance  = Query.relevanceScore se precedingTerms-                                                  doctermids docfeatvals-            termRelevances = relevanceBreakdown se doctermids docfeatvals-                                                completionTerms-      ]---- | Calculate the relevance of each of a given set of terms to the given--- document.------ We define the \"relevance\" of each term in a document to be its--- term-in-document score as a fraction of the total of the scores for all--- terms in the document. Thus the sum of all the relevance values in the--- document is 1.------ Note: we have to calculate the relevance for all terms in the document--- but we only keep the relevance value for the terms of interest.----relevanceBreakdown :: forall doc key field feature.-                      (Ix field, Bounded field, Ix feature, Bounded feature) =>-                      SearchEngine doc key field feature ->-                      DocTermIds field -> DocFeatVals feature ->-                      [TermId] -> TermRelevanceBreakdown-relevanceBreakdown SearchEngine{ bm25Context } doctermids docfeatvals ts =-    let -- We'll calculate the bm25 score for each term in this document-        bm25Doc     = Query.indexDocToBM25Doc doctermids docfeatvals--        -- Cache the info that depends only on this doc, not the terms-        termScore   :: (TermId -> (field -> Int) -> Float)-        termScore   = BM25F.scoreTermsBulk bm25Context bm25Doc--        -- The DocTermIds has the info we need to do bulk scoring, but it's-        -- a sparse representation, so we first convert it to a dense table-        term        :: Int -> TermId-        count       :: Int -> field -> Int-        (!numTerms, term, count) = DocTermIds.denseTable doctermids--        -- We generate the vector of scores for all terms, based on looking up-        -- the termid and the per-field counts in the dense table-        termScores  :: Vec.Vector Float-        !termScores = Vec.generate numTerms $ \i ->-                       termScore (term i) (\f -> count i f)--        -- We keep only the values for the terms we're interested in-        -- and normalise so we get the relevence fraction-        !scoreSum   = Vec.sum termScores-        !tset       = IntSet.fromList (map fromEnum ts)-     in Map.fromList-      . Vec.toList-      . Vec.map    (\(t,s) -> (t, s/scoreSum))-      . Vec.filter (\(t,_) -> fromEnum t `IntSet.member` tset)-      . Vec.imap   (\i s   -> (term i, s))-      $ termScores---scoreAutosuggestQueryCompletions :: [(TermId, DocIdSet)]-                                 -> Map.Map DocId (Float, Map.Map TermId Float)-                                 -> [(TermId, Float)]-scoreAutosuggestQueryCompletions completionTermAndDocSets allTermDocInfo =-    [ (t, candidateScore t ds_t)-    | (t, ds_t) <- completionTermAndDocSets ]-  where-    -- The score for a completion is the sum of the importance of the-    -- documents in which that completion occurs, weighted by the relevance-    -- of the term to each document. For example we can have a very-    -- important document and our completion term is highly relevant to it-    -- or we could have a large number of moderately important documents-    -- that our term is quite relevant to. In either example the completion-    -- term would score highly.-    candidateScore :: TermId -> DocIdSet -> Float-    candidateScore t ds_t =-      sum [ docImportance * termRelevance-          | Just (docImportance, termRelevances) <--               map (`Map.lookup` allTermDocInfo) (DocIdSet.toList ds_t)-          , let termRelevance = termRelevances Map.! t-          ]---scoreAutosuggestQueryResults :: [(TermId, DocIdSet)] ->-                                Map.Map DocId (Float, Map.Map TermId Float) ->-                                [(TermId, Float)] ->-                                [(DocId, Float)]-scoreAutosuggestQueryResults completionTermAndDocSets allTermDocInfo-                             scoredCandidates =-  Map.toList $ Map.fromListWith (+)-    [ (docid, docImportance * score_t)-    | ((_, ds_t), (_, score_t)) <- zip completionTermAndDocSets scoredCandidates-    , let docids  = DocIdSet.toList ds_t-          docinfo = map (`Map.lookup` allTermDocInfo) docids-    , (docid, Just (docImportance, _)) <- zip docids docinfo-    ]-
− Data/SearchEngine/BM25F.hs
@@ -1,253 +0,0 @@-{-# LANGUAGE RecordWildCards, BangPatterns, ScopedTypeVariables #-}---- | An implementation of BM25F ranking. See:------ * A quick overview: <http://en.wikipedia.org/wiki/Okapi_BM25>------ * /The Probabilistic Relevance Framework: BM25 and Beyond/---   <http://www.staff.city.ac.uk/~sbrp622/papers/foundations_bm25_review.pdf>------ * /An Introduction to Information Retrieval/---   <http://nlp.stanford.edu/IR-book/pdf/irbookonlinereading.pdf>----module Data.SearchEngine.BM25F (-    -- * The ranking function-    score,-    Context(..),-    FeatureFunction(..),-    Doc(..),-    -- ** Specialised variants-    scoreTermsBulk,--    -- * Explaining the score-    Explanation(..),-    explain,-  ) where--import Data.Ix-import Data.Array.Unboxed--data Context term field feature = Context {-         numDocsTotal     :: !Int,-         avgFieldLength   :: field -> Float,-         numDocsWithTerm  :: term -> Int,-         paramK1          :: !Float,-         paramB           :: field -> Float,-         -- consider minimum length to prevent massive B bonus?-         fieldWeight      :: field -> Float,-         featureWeight    :: feature -> Float,-         featureFunction  :: feature -> FeatureFunction-       }--data Doc term field feature = Doc {-         docFieldLength        :: field -> Int,-         docFieldTermFrequency :: field -> term -> Int,-         docFeatureValue       :: feature -> Float-       }----- | The BM25F score for a document for a given set of terms.----score :: (Ix field, Bounded field, Ix feature, Bounded feature) =>-         Context term field feature ->-         Doc term field feature -> [term] -> Float-score ctx doc terms =-    sum (map (weightedTermScore    ctx doc) terms)-  + sum (map (weightedNonTermScore ctx doc) features)--  where-    features = range (minBound, maxBound)---weightedTermScore :: (Ix field, Bounded field) =>-                     Context term field feature ->-                     Doc term field feature -> term -> Float-weightedTermScore ctx doc t =-    weightIDF ctx t *     tf'-                     / (k1 + tf')-  where-    tf' = weightedDocTermFrequency ctx doc t-    k1  = paramK1 ctx---weightIDF :: Context term field feature -> term -> Float-weightIDF ctx t =-    log ((n - n_t + 0.5) / (n_t + 0.5))-  where-    n   = fromIntegral (numDocsTotal ctx)-    n_t = fromIntegral (numDocsWithTerm ctx t)---weightedDocTermFrequency :: (Ix field, Bounded field) =>-                            Context term field feature ->-                            Doc term field feature -> term -> Float-weightedDocTermFrequency ctx doc t =-    sum [ w_f * tf_f / _B_f-        | field <- range (minBound, maxBound)-        , let w_f  = fieldWeight ctx field-              tf_f = fromIntegral (docFieldTermFrequency doc field t)-              _B_f = lengthNorm ctx doc field-        , not (isNaN _B_f)-        ]-    -- When the avgFieldLength is 0 we have a field which is empty for all-    -- documents. Unfortunately it leads to a NaN because the-    -- docFieldTermFrequency will also be 0 so we get 0/0. What we want to-    -- do in this situation is have that field contribute nothing to the-    -- score. The simplest way to achieve that is to skip if _B_f is NaN.-    -- So I think this is fine and not an ugly hack.--lengthNorm :: Context term field feature ->-              Doc term field feature -> field -> Float-lengthNorm ctx doc field =-    (1-b_f) + b_f * sl_f / avgsl_f-  where-    b_f     = paramB ctx field-    sl_f    = fromIntegral (docFieldLength doc field)-    avgsl_f = avgFieldLength ctx field---weightedNonTermScore :: (Ix feature, Bounded feature) =>-                        Context term field feature ->-                        Doc term field feature -> feature -> Float-weightedNonTermScore ctx doc feature =-    w_f * _V_f f_f-  where-    w_f  = featureWeight ctx feature-    _V_f = applyFeatureFunction (featureFunction ctx feature)-    f_f  = docFeatureValue doc feature---data FeatureFunction-   = LogarithmicFunction   Float -- ^ @log (\lambda_i + f_i)@-   | RationalFunction      Float -- ^ @f_i / (\lambda_i + f_i)@-   | SigmoidFunction Float Float -- ^ @1 / (\lambda + exp(-(\lambda' * f_i))@--applyFeatureFunction :: FeatureFunction -> (Float -> Float)-applyFeatureFunction (LogarithmicFunction p1) = \fi -> log (p1 + fi)-applyFeatureFunction (RationalFunction    p1) = \fi -> fi / (p1 + fi)-applyFeatureFunction (SigmoidFunction  p1 p2) = \fi -> 1 / (p1 + exp (-fi * p2))----------------------------------- Bulk scoring of many terms------- | Most of the time we want to score several different documents for the same--- set of terms, but sometimes we want to score one document for many terms--- and in that case we can save a bit of work by doing it in bulk. It lets us--- calculate once and share things that depend only on the document, and not--- the term.------ To take advantage of the sharing you must partially apply and name the--- per-doc score functon, e.g.------ > let score :: term -> (field -> Int) -> Float--- >     score = BM25.bulkScorer ctx doc--- >  in sum [ score t (\f -> counts ! (t, f)) | t <- ts ]----scoreTermsBulk :: forall field term feature. (Ix field, Bounded field) =>-                  Context term field feature ->-                  Doc term field feature ->-                  (term -> (field -> Int) -> Float)-scoreTermsBulk ctx doc = -    -- This is just a rearrangement of weightedTermScore and-    -- weightedDocTermFrequency above, with the doc-constant bits hoisted out.--    \t tFreq ->-    let !tf' = sum [ w!f * tf_f / _B!f-                   | f <- range (minBound, maxBound)-                   , let tf_f = fromIntegral (tFreq f)-                         _B_f = _B!f-                   , not (isNaN _B_f)-                   ]--     in weightIDF ctx t *     tf'-                         / (k1 + tf')-  where-    -- So long as the caller does the partial application thing then these-    -- values can all be shared between many calls with different terms.--    !k1 = paramK1 ctx-    w, _B :: UArray field Float-    !w  = array (minBound, maxBound)-                [ (field, fieldWeight ctx field)-                | field <- range (minBound, maxBound) ]-    !_B = array (minBound, maxBound)-                [ (field, lengthNorm ctx doc field)-                | field <- range (minBound, maxBound) ]------------------------ Explanation------- | A breakdown of the BM25F score, to explain somewhat how it relates to--- the inputs, and so you can compare the scores of different documents.----data Explanation field feature term = Explanation {-       -- | The overall score is the sum of the 'termScores', 'positionScore'-       -- and 'nonTermScore'-       overallScore  :: Float,--       -- | There is a score contribution from each query term. This is the-       -- score for the term across all fields in the document (but see-       -- 'termFieldScores').-       termScores    :: [(term, Float)],-{--       -- | There is a score contribution for positional information. Terms-       -- appearing in the document close together give a bonus.-       positionScore :: [(field, Float)],--}-       -- | The document can have an inate bonus score independent of the terms-       -- in the query. For example this might be a popularity score.-       nonTermScores :: [(feature, Float)],--       -- | This does /not/ contribute to the 'overallScore'. It is an-       -- indication of how the 'termScores' relates to per-field scores.-       -- Note however that the term score for all fields is /not/ simply-       -- sum of the per-field scores. The point of the BM25F scoring function-       -- is that a linear combination of per-field scores is wrong, and BM25F-       -- does a more cunning non-linear combination.-       ---       -- However, it is still useful as an indication to see scores for each-       -- field for a term, to see how the compare.-       ---       termFieldScores :: [(term, [(field, Float)])]-     }-  deriving Show--instance Functor (Explanation field feature) where-  fmap f e@Explanation{..} =-    e {-      termScores      = [ (f t, s)  | (t, s)  <- termScores ],-      termFieldScores = [ (f t, fs) | (t, fs) <- termFieldScores ]-    }--explain :: (Ix field, Bounded field, Ix feature, Bounded feature) =>-           Context term field feature ->-           Doc term field feature -> [term] -> Explanation field feature term-explain ctx doc ts =-    Explanation {..}-  where-    overallScore  = sum (map snd termScores)---                  + sum (map snd positionScore)-                  + sum (map snd nonTermScores)-    termScores    = [ (t, weightedTermScore ctx doc t) | t <- ts ]---    positionScore = [ (f, 0) | f <- range (minBound, maxBound) ]-    nonTermScores = [ (feature, weightedNonTermScore ctx doc feature)-                    | feature <- range (minBound, maxBound) ]--    termFieldScores =-      [ (t, fieldScores)-      | t <- ts-      , let fieldScores =-              [ (f, weightedTermScore ctx' doc t)-              | f <- range (minBound, maxBound)-              , let ctx' = ctx { fieldWeight = fieldWeightOnly f }-              ]-      ]-    fieldWeightOnly f f' | sameField f f' = fieldWeight ctx f'-                         | otherwise      = 0--    sameField f f' = index (minBound, maxBound) f-                  == index (minBound, maxBound) f'
− Data/SearchEngine/DocFeatVals.hs
@@ -1,25 +0,0 @@-{-# LANGUAGE BangPatterns, GeneralizedNewtypeDeriving #-}-module Data.SearchEngine.DocFeatVals (-    DocFeatVals,-    featureValue,-    create,-  ) where--import Data.SearchEngine.DocTermIds (vecIndexIx, vecCreateIx)-import Data.Vector (Vector)-import Data.Ix (Ix)----- | Storage for the non-term feature values i a document.----newtype DocFeatVals feature = DocFeatVals (Vector Float)-  deriving (Show)--featureValue :: (Ix feature, Bounded feature) => DocFeatVals feature -> feature -> Float-featureValue (DocFeatVals featVec) = vecIndexIx featVec--create :: (Ix feature, Bounded feature) =>-          (feature -> Float) -> DocFeatVals feature-create docFeatVals =-    DocFeatVals (vecCreateIx docFeatVals)-
− Data/SearchEngine/DocIdSet.hs
@@ -1,207 +0,0 @@-{-# LANGUAGE BangPatterns, GeneralizedNewtypeDeriving, MultiParamTypeClasses,-             TypeFamilies #-}-module Data.SearchEngine.DocIdSet (-    DocId(DocId),-    DocIdSet(..),-    null,-    size,-    empty,-    singleton,-    fromList,-    toList,-    insert,-    delete,-    member,-    union,-    unions,-    intersection,-    invariant,-  ) where--import Data.Word-import qualified Data.Vector.Unboxed         as Vec-import qualified Data.Vector.Unboxed.Mutable as MVec-import qualified Data.Vector.Generic         as GVec-import qualified Data.Vector.Generic.Mutable as GMVec-import Control.Monad.ST-import Control.Monad (liftM)-import qualified Data.Set as Set-import Data.List (foldl', sortBy)-import Data.Function (on)--import Prelude hiding (null)---newtype DocId = DocId { unDocId :: Word32 }-  deriving (Eq, Ord, Show, Enum, Bounded)--newtype DocIdSet = DocIdSet (Vec.Vector DocId)-  deriving (Eq, Show)---- represented as a sorted sequence of ids-invariant :: DocIdSet -> Bool-invariant (DocIdSet vec) =-    strictlyAscending (Vec.toList vec)-  where-    strictlyAscending (a:xs@(b:_)) = a < b && strictlyAscending xs-    strictlyAscending _  = True---size :: DocIdSet -> Int-size (DocIdSet vec) = Vec.length vec--null :: DocIdSet -> Bool-null (DocIdSet vec) = Vec.null vec--empty :: DocIdSet-empty = DocIdSet Vec.empty--singleton :: DocId -> DocIdSet-singleton = DocIdSet . Vec.singleton--fromList :: [DocId] -> DocIdSet-fromList = DocIdSet . Vec.fromList . Set.toAscList . Set.fromList--toList ::  DocIdSet -> [DocId]-toList (DocIdSet vec) = Vec.toList vec--insert :: DocId -> DocIdSet -> DocIdSet-insert x (DocIdSet vec) =-    case binarySearch vec 0 (Vec.length vec - 1) x of-      (_, True)  -> DocIdSet vec-      (i, False) -> case Vec.splitAt i vec of-                      (before, after) ->-                        DocIdSet (Vec.concat [before, Vec.singleton x, after])--delete :: DocId -> DocIdSet -> DocIdSet-delete x (DocIdSet vec) =-    case binarySearch vec 0 (Vec.length vec - 1) x of-      (_, False) -> DocIdSet vec-      (i, True)  -> case Vec.splitAt i vec of-                      (before, after) ->-                        DocIdSet (before Vec.++ Vec.tail after)--member :: DocId -> DocIdSet -> Bool-member x (DocIdSet vec) = snd (binarySearch vec 0 (Vec.length vec - 1) x)--binarySearch :: Vec.Vector DocId -> Int -> Int -> DocId -> (Int, Bool)-binarySearch vec !a !b !key-  | a > b     = (a, False)-  | otherwise =-    let mid = (a + b) `div` 2-     in case compare key (vec Vec.! mid) of-          LT -> binarySearch vec a (mid-1) key-          EQ -> (mid, True)-          GT -> binarySearch vec (mid+1) b key--unions :: [DocIdSet] -> DocIdSet-unions = foldl' union empty-         -- a bit more effecient if we merge small ones first-       . sortBy (compare `on` size)--union :: DocIdSet -> DocIdSet -> DocIdSet-union x y | null x = y-          | null y = x-union (DocIdSet xs) (DocIdSet ys) =-    DocIdSet (Vec.create (MVec.new sizeBound >>= writeMergedUnion xs ys))-  where-    sizeBound = Vec.length xs + Vec.length ys--writeMergedUnion :: Vec.Vector DocId -> Vec.Vector DocId ->-                    MVec.MVector s DocId -> ST s (MVec.MVector s DocId)-writeMergedUnion xs0 ys0 !out = do-    i <- go xs0 ys0 0-    return $! MVec.take i out-  where-    go !xs !ys !i-      | Vec.null xs = do Vec.copy (MVec.slice i (Vec.length ys) out) ys-                         return (i + Vec.length ys)-      | Vec.null ys = do Vec.copy (MVec.slice i (Vec.length xs) out) xs-                         return (i + Vec.length xs)-      | otherwise   = let x = Vec.head xs; y = Vec.head ys-                      in case compare x y of-                          GT -> do MVec.write out i y-                                   go           xs  (Vec.tail ys) (i+1)-                          EQ -> do MVec.write out i x-                                   go (Vec.tail xs) (Vec.tail ys) (i+1)-                          LT -> do MVec.write out i x-                                   go (Vec.tail xs)           ys  (i+1)--intersection :: DocIdSet -> DocIdSet -> DocIdSet-intersection x y | null x = empty-                 | null y = empty-intersection (DocIdSet xs) (DocIdSet ys) =-    DocIdSet (Vec.create (MVec.new sizeBound >>= writeMergedIntersection xs ys))-  where-    sizeBound = max (Vec.length xs) (Vec.length ys)--writeMergedIntersection :: Vec.Vector DocId -> Vec.Vector DocId ->-                           MVec.MVector s DocId -> ST s (MVec.MVector s DocId)-writeMergedIntersection xs0 ys0 !out = do-    i <- go xs0 ys0 0-    return $! MVec.take i out-  where-    go !xs !ys !i-      | Vec.null xs = return i-      | Vec.null ys = return i-      | otherwise   = let x = Vec.head xs; y = Vec.head ys-                      in case compare x y of-                          GT ->    go           xs  (Vec.tail ys)  i-                          EQ -> do MVec.write out i x-                                   go (Vec.tail xs) (Vec.tail ys) (i+1)-                          LT ->    go (Vec.tail xs)           ys   i----------------------------------------------------------------------------------- verbose Unbox instances-----instance MVec.Unbox DocId--newtype instance MVec.MVector s DocId = MV_DocId (MVec.MVector s Word32)--instance GMVec.MVector MVec.MVector DocId where-    basicLength          (MV_DocId v) = GMVec.basicLength v-    basicUnsafeSlice i l (MV_DocId v) = MV_DocId (GMVec.basicUnsafeSlice i l v)-    basicUnsafeNew     l              = MV_DocId `liftM` GMVec.basicUnsafeNew l-    basicInitialize      (MV_DocId v) = GMVec.basicInitialize v-    basicUnsafeReplicate l x          = MV_DocId `liftM` GMVec.basicUnsafeReplicate l (unDocId x)-    basicUnsafeRead  (MV_DocId v) i   = DocId `liftM`    GMVec.basicUnsafeRead v i-    basicUnsafeWrite (MV_DocId v) i x = GMVec.basicUnsafeWrite v i (unDocId x)-    basicClear       (MV_DocId v)     = GMVec.basicClear v-    basicSet         (MV_DocId v) x   = GMVec.basicSet v (unDocId x)-    basicUnsafeGrow  (MV_DocId v) l   = MV_DocId `liftM` GMVec.basicUnsafeGrow v l-    basicUnsafeCopy  (MV_DocId v) (MV_DocId v') = GMVec.basicUnsafeCopy v v'-    basicUnsafeMove  (MV_DocId v) (MV_DocId v') = GMVec.basicUnsafeMove v v'-    basicOverlaps    (MV_DocId v) (MV_DocId v') = GMVec.basicOverlaps   v v'-    {-# INLINE basicLength #-}-    {-# INLINE basicUnsafeSlice #-}-    {-# INLINE basicOverlaps #-}-    {-# INLINE basicUnsafeNew #-}-    {-# INLINE basicInitialize #-}-    {-# INLINE basicUnsafeReplicate #-}-    {-# INLINE basicUnsafeRead #-}-    {-# INLINE basicUnsafeWrite #-}-    {-# INLINE basicClear #-}-    {-# INLINE basicSet #-}-    {-# INLINE basicUnsafeCopy #-}-    {-# INLINE basicUnsafeMove #-}-    {-# INLINE basicUnsafeGrow #-}--newtype instance Vec.Vector DocId = V_DocId (Vec.Vector Word32)--instance GVec.Vector Vec.Vector DocId where-    basicUnsafeFreeze (MV_DocId mv)  = V_DocId  `liftM` GVec.basicUnsafeFreeze mv-    basicUnsafeThaw   (V_DocId  v)   = MV_DocId `liftM` GVec.basicUnsafeThaw v-    basicLength       (V_DocId  v)   = GVec.basicLength v-    basicUnsafeSlice i l (V_DocId v) = V_DocId (GVec.basicUnsafeSlice i l v)-    basicUnsafeIndexM (V_DocId  v) i = DocId `liftM` GVec.basicUnsafeIndexM v i-    basicUnsafeCopy   (MV_DocId mv)-                      (V_DocId  v)   = GVec.basicUnsafeCopy mv v-    elemseq           (V_DocId  v) x = GVec.elemseq v (unDocId x)-    {-# INLINE basicUnsafeFreeze #-}-    {-# INLINE basicUnsafeThaw #-}-    {-# INLINE basicLength #-}-    {-# INLINE basicUnsafeSlice #-}-    {-# INLINE basicUnsafeIndexM #-}-    {-# INLINE basicUnsafeCopy #-}-    {-# INLINE elemseq #-}
− Data/SearchEngine/DocTermIds.hs
@@ -1,81 +0,0 @@-{-# LANGUAGE BangPatterns, GeneralizedNewtypeDeriving #-}-module Data.SearchEngine.DocTermIds (-    DocTermIds,-    TermId,-    fieldLength,-    fieldTermCount,-    fieldElems,-    create,-    denseTable,-    vecIndexIx,-    vecCreateIx,-  ) where--import Data.SearchEngine.TermBag (TermBag, TermId)-import qualified Data.SearchEngine.TermBag as TermBag--import Data.Vector (Vector, (!))-import qualified Data.Vector as Vec-import qualified Data.Vector.Unboxed as UVec-import Data.Ix (Ix)-import qualified Data.Ix as Ix----- | The 'TermId's for the 'Term's that occur in a document. Documents may have--- multiple fields and the 'DocTerms' type holds them separately for each field.----newtype DocTermIds field = DocTermIds (Vector TermBag)-  deriving (Show)--getField :: (Ix field, Bounded field) => DocTermIds field -> field -> TermBag-getField (DocTermIds fieldVec) = vecIndexIx fieldVec--create :: (Ix field, Bounded field) =>-          (field -> [TermId]) -> DocTermIds field-create docTermIds =-    DocTermIds (vecCreateIx (TermBag.fromList . docTermIds))---- | The number of terms in a field within the document.-fieldLength :: (Ix field, Bounded field) => DocTermIds field -> field -> Int-fieldLength docterms field =-    TermBag.size (getField docterms field)---- | /O(log n)/ The frequency of a particular term in a field within the document.----fieldTermCount :: (Ix field, Bounded field) =>-                  DocTermIds field -> field -> TermId -> Int-fieldTermCount docterms field termid =-    fromIntegral (TermBag.termCount (getField docterms field) termid)--fieldElems :: (Ix field, Bounded field) => DocTermIds field -> field -> [TermId]-fieldElems docterms field =-    TermBag.elems (getField docterms field)---- | The 'DocTermIds' is really a sparse 2d array, and doing lookups with--- 'fieldTermCount' has a O(log n) cost. This function converts to a dense--- tabular representation which then enables linear scans.----denseTable :: (Ix field, Bounded field) => DocTermIds field ->-              (Int, Int -> TermId, Int -> field -> Int)-denseTable (DocTermIds fieldVec) =-    let (!termids, !termcounts) = TermBag.denseTable (Vec.toList fieldVec)-        !numTerms = UVec.length termids-     in ( numTerms-        , \i    -> termids UVec.! i-        , \i ix -> let j = Ix.index (minBound, maxBound) ix-                    in fromIntegral (termcounts UVec.! (j * numTerms + i))-        )-------------------------------------- Vector indexed by Ix Bounded-----vecIndexIx  :: (Ix ix, Bounded ix) => Vector a -> ix -> a-vecIndexIx vec ix = vec ! Ix.index (minBound, maxBound) ix--vecCreateIx :: (Ix ix, Bounded ix) => (ix -> a) -> Vector a-vecCreateIx f = Vec.fromListN (Ix.rangeSize bounds)-                  [ y | ix <- Ix.range bounds, let !y = f ix ]-  where-    bounds = (minBound, maxBound)-
− Data/SearchEngine/Query.hs
@@ -1,257 +0,0 @@-{-# LANGUAGE BangPatterns, NamedFieldPuns, RecordWildCards #-}--module Data.SearchEngine.Query (--    -- * Querying-    query,-    ResultsFilter(..),--    -- * Explain mode for query result rankings-    queryExplain,-    BM25F.Explanation(..),-    setRankParams,--    -- ** Utils used by autosuggest-    relevanceScore,-    indexDocToBM25Doc,-    expandTransformedQueryTerm,-  ) where--import Data.SearchEngine.Types-import qualified Data.SearchEngine.SearchIndex as SI-import qualified Data.SearchEngine.DocIdSet    as DocIdSet-import qualified Data.SearchEngine.DocTermIds  as DocTermIds-import qualified Data.SearchEngine.DocFeatVals as DocFeatVals-import qualified Data.SearchEngine.BM25F       as BM25F--import Data.Ix-import Data.List-import Data.Function-import Data.Maybe----- | Execute a normal query. Find the documents in which one or more of--- the search terms appear and return them in ranked order.------ The number of documents returned is limited by the 'paramResultsetSoftLimit'--- and 'paramResultsetHardLimit' paramaters. This also limits the cost of the--- query (which is primarily the cost of scoring each document).------ The given terms are all assumed to be complete (as opposed to prefixes--- like with 'queryAutosuggest').----query :: (Ix field, Bounded field, Ix feature, Bounded feature) =>-         SearchEngine doc key field feature ->-         [Term] -> [key]-query se@SearchEngine{ searchIndex,-                       searchRankParams = SearchRankParameters{..} }-      terms =--  let -- Start by transforming/normalising all the query terms.-      -- This can be done differently for each field we search by.-      lookupTerms :: [Term]-      lookupTerms = concatMap (expandTransformedQueryTerm se) terms--      -- Then we look up all the normalised terms in the index.-      rawresults :: [Maybe (TermId, DocIdSet)]-      rawresults = map (SI.lookupTerm searchIndex) lookupTerms--      -- For the terms that occur in the index, this gives us the term's id-      -- and the set of documents that the term occurs in.-      termids   :: [TermId]-      docidsets :: [DocIdSet]-      (termids, docidsets) = unzip (catMaybes rawresults)--      -- We looked up the documents that *any* of the term occur in (not all)-      -- so this could be rather a lot of docs if the user uses a few common-      -- terms. Scoring these result docs is a non-trivial cost so we want to-      -- limit the number that we have to score. The standard trick is to-      -- consider the doc sets in the order of size, smallest to biggest. Once-      -- we have gone over a certain threshold of docs then don't bother with-      -- the doc sets for the remaining terms. This tends to work because the-      -- scoring gives lower weight to terms that occur in many documents.-      unrankedResults :: DocIdSet-      unrankedResults = pruneRelevantResults-                          paramResultsetSoftLimit-                          paramResultsetHardLimit-                          docidsets--      --TODO: technically this isn't quite correct. Because each field can-      -- be normalised differently, we can end up with different termids for-      -- the same original search term, and then we score those as if they-      -- were different terms, which makes a difference when the term appears-      -- in multiple fields (exactly the case BM25F is supposed to deal with).-      -- What we ought to have instead is an Array (Int, field) TermId, and-      -- make the scoring use the appropriate termid for each field, but to-      -- consider them the "same" term.-   in rankResults se termids (DocIdSet.toList unrankedResults)---- | Before looking up a term in the main index we need to normalise it--- using the 'transformQueryTerm'. Of course the transform can be different--- for different fields, so we have to collect all the forms (eliminating--- duplicates).----expandTransformedQueryTerm :: (Ix field, Bounded field) =>-                              SearchEngine doc key field feature ->-                              Term -> [Term]-expandTransformedQueryTerm SearchEngine{searchConfig} term =-    nub [ transformForField field-        | let transformForField = transformQueryTerm searchConfig term-        , field <- range (minBound, maxBound) ]---rankResults :: (Ix field, Bounded field, Ix feature, Bounded feature) =>-               SearchEngine doc key field feature ->-               [TermId] -> [DocId] -> [key]-rankResults se@SearchEngine{searchIndex} queryTerms docids =-    map snd-  $ sortBy (flip compare `on` fst)-      [ (relevanceScore se queryTerms doctermids docfeatvals, dockey)-      | docid <- docids-      , let (dockey, doctermids, docfeatvals) = SI.lookupDocId searchIndex docid ]--relevanceScore :: (Ix field, Bounded field, Ix feature, Bounded feature) =>-                  SearchEngine doc key field feature ->-                  [TermId] -> DocTermIds field -> DocFeatVals feature -> Float-relevanceScore SearchEngine{bm25Context} queryTerms doctermids docfeatvals =-    BM25F.score bm25Context doc queryTerms-  where-    doc = indexDocToBM25Doc doctermids docfeatvals--indexDocToBM25Doc :: (Ix field, Bounded field, Ix feature, Bounded feature) =>-                     DocTermIds field ->-                     DocFeatVals feature ->-                     BM25F.Doc TermId field feature-indexDocToBM25Doc doctermids docfeatvals =-    BM25F.Doc {-      BM25F.docFieldLength        = DocTermIds.fieldLength    doctermids,-      BM25F.docFieldTermFrequency = DocTermIds.fieldTermCount doctermids,-      BM25F.docFeatureValue       = DocFeatVals.featureValue docfeatvals-    }--pruneRelevantResults :: Int -> Int -> [DocIdSet] -> DocIdSet-pruneRelevantResults softLimit hardLimit =-    -- Look at the docsets starting with the smallest ones. Smaller docsets-    -- correspond to the rarer terms, which are the ones that score most highly.-    go DocIdSet.empty . sortBy (compare `on` DocIdSet.size)-  where-    go !acc [] = acc-    go !acc (d:ds)-        -- If this is the first one, we add it anyway, otherwise we're in-        -- danger of returning no results at all.-      | DocIdSet.null acc = go d ds-        -- We consider the size our docset would be if we add this extra one...-        -- If it puts us over the hard limit then stop.-      | size > hardLimit  = acc-        -- If it puts us over soft limit then we add it and stop-      | size > softLimit  = DocIdSet.union acc d-        -- Otherwise we can add it and carry on to consider the remainder-      | otherwise         = go (DocIdSet.union acc d) ds-      where-        size = DocIdSet.size acc + DocIdSet.size d-------------------------------------- Normal query with explanation-----queryExplain :: (Ix field, Bounded field, Ix feature, Bounded feature) =>-                SearchEngine doc key field feature ->-                [Term] -> [(BM25F.Explanation field feature Term, key)]-queryExplain se@SearchEngine{ searchIndex,-                              searchConfig     = SearchConfig{transformQueryTerm},-                              searchRankParams = SearchRankParameters{..} }-      terms =--  -- See 'query' above for explanation. Really we ought to combine them.-  let lookupTerms :: [Term]-      lookupTerms = [ term'-                    | term  <- terms-                    , let transformForField = transformQueryTerm term-                    , term' <- nub [ transformForField field-                                   | field <- range (minBound, maxBound) ]-                    ]--      rawresults :: [Maybe (TermId, DocIdSet)]-      rawresults = map (SI.lookupTerm searchIndex) lookupTerms--      termids   :: [TermId]-      docidsets :: [DocIdSet]-      (termids, docidsets) = unzip (catMaybes rawresults)--      unrankedResults :: DocIdSet-      unrankedResults = pruneRelevantResults-                          paramResultsetSoftLimit-                          paramResultsetHardLimit-                          docidsets--   in rankExplainResults se termids (DocIdSet.toList unrankedResults)--rankExplainResults :: (Ix field, Bounded field, Ix feature, Bounded feature) =>-                      SearchEngine doc key field feature ->-                      [TermId] ->-                      [DocId] ->-                      [(BM25F.Explanation field feature Term, key)]-rankExplainResults se@SearchEngine{searchIndex} queryTerms docids =-    sortBy (flip compare `on` (BM25F.overallScore . fst))-      [ (explainRelevanceScore se queryTerms doctermids docfeatvals, dockey)-      | docid <- docids-      , let (dockey, doctermids, docfeatvals) = SI.lookupDocId searchIndex docid ]---explainRelevanceScore :: (Ix field, Bounded field, Ix feature, Bounded feature) =>-                         SearchEngine doc key field feature ->-                         [TermId] ->-                         DocTermIds field ->-                         DocFeatVals feature ->-                         BM25F.Explanation field feature Term-explainRelevanceScore SearchEngine{bm25Context, searchIndex}-                      queryTerms doctermids docfeatvals =-    fmap (SI.getTerm searchIndex) (BM25F.explain bm25Context doc queryTerms)-  where-    doc = indexDocToBM25Doc doctermids docfeatvals---setRankParams :: SearchRankParameters field feature ->-                 SearchEngine doc key field feature ->-                 SearchEngine doc key field feature-setRankParams params@SearchRankParameters{..} se =-    se {-      searchRankParams = params,-      bm25Context      = (bm25Context se) {-        BM25F.paramK1         = paramK1,-        BM25F.paramB          = paramB,-        BM25F.fieldWeight     = paramFieldWeights,-        BM25F.featureWeight   = paramFeatureWeights,-        BM25F.featureFunction = paramFeatureFunctions-      }-    }-------------------------------------- Results filter------- | In some applications it is necessary to enforce some security or--- visibility rule about the query results (e.g. in a typical DB-based--- application different users can see different data items). Typically--- it would be too expensive to build different search indexes for the--- different contexts and so the strategy is to use one index containing--- everything and filter for visibility in the results. This means the--- filter condition is different for different queries (e.g. performed--- on behalf of different users).------ Filtering the results after a query is possible but not the most efficient--- thing to do because we've had to score all the not-visible documents.--- The better thing to do is to filter as part of the query, this way we can--- filter before the expensive scoring.------ We provide one further optimisation: bulk predicates. In some applications--- it can be quicker to check the security\/visibility of a whole bunch of--- results all in one go.----data ResultsFilter key = NoFilter-                       | FilterPredicate     (key -> Bool)-                       | FilterBulkPredicate ([key] -> [Bool])---TODO: allow filtering & non-feature score lookup in one bulk op-
− Data/SearchEngine/SearchIndex.hs
@@ -1,456 +0,0 @@-{-# LANGUAGE BangPatterns, NamedFieldPuns #-}--module Data.SearchEngine.SearchIndex (-    SearchIndex,-    Term,-    TermId,-    DocId,--    emptySearchIndex,-    insertDoc,-    deleteDoc,--    docCount,-    lookupTerm,-    lookupTermsByPrefix,-    lookupTermId,-    lookupDocId,-    lookupDocKey,-    lookupDocKeyDocId,--    getTerm,-    getDocKey,--    invariant,-  ) where--import Data.SearchEngine.DocIdSet (DocIdSet, DocId)-import qualified Data.SearchEngine.DocIdSet as DocIdSet-import Data.SearchEngine.DocTermIds (DocTermIds, TermId, vecIndexIx, vecCreateIx)-import qualified Data.SearchEngine.DocTermIds as DocTermIds-import Data.SearchEngine.DocFeatVals (DocFeatVals)-import qualified Data.SearchEngine.DocFeatVals as DocFeatVals--import Data.Ix (Ix)-import qualified Data.Ix as Ix-import Data.Map (Map)-import qualified Data.Map as Map-import Data.IntMap (IntMap)-import qualified Data.IntMap as IntMap-import qualified Data.Set as Set-import Data.Text (Text)-import qualified Data.Text as T-import Data.List (foldl')--import Control.Exception (assert)---- | Terms are short strings, usually whole words.----type Term = Text---- | The search index is essentially a many-to-many mapping between documents--- and terms. Each document contains many terms and each term occurs in many--- documents. It is a bidirectional mapping as we need to support lookups in--- both directions.------ Documents are identified by a key (in Ord) while terms are text values.--- Inside the index however we assign compact numeric ids to both documents and--- terms. The advantage of this is a much more compact in-memory representation--- and the disadvantage is greater complexity. In particular it means we have--- to manage bidirectional mappings between document keys and ids, and between--- terms and term ids.------ So the mappings we maintain can be depicted as:------ >  Term   <-- 1:1 -->   TermId--- >          \              ^--- >           \             |--- >           1:many    many:many--- >                \        |--- >                 \->     v--- > DocKey  <-- 1:1 -->   DocId------ For efficiency, these details are exposed in the interface. In particular--- the mapping from TermId to many DocIds is exposed via a 'DocIdSet',--- and the mapping from DocIds to TermIds is exposed via 'DocTermIds'.------ The main reason we need to keep the DocId -> TermId is to allow for--- efficient incremental updates.----data SearchIndex key field feature = SearchIndex {-       -- the indexes-       termMap           :: !(Map Term TermInfo),-       termIdMap         :: !(IntMap TermIdInfo),-       docIdMap          :: !(IntMap (DocInfo key field feature)),-       docKeyMap         :: !(Map key DocId),--       -- auto-increment key counters-       nextTermId        :: TermId,-       nextDocId         :: DocId-     }-  deriving Show--data TermInfo = TermInfo !TermId !DocIdSet-  deriving Show--data TermIdInfo = TermIdInfo !Term !DocIdSet-  deriving (Show, Eq)--data DocInfo key field feature = DocInfo !key !(DocTermIds field)-                                              !(DocFeatVals feature)-  deriving Show----------------------------- SearchIndex basics-----emptySearchIndex :: SearchIndex key field feature-emptySearchIndex =-    SearchIndex-      Map.empty-      IntMap.empty-      IntMap.empty-      Map.empty-      minBound-      minBound--checkInvariant :: (Ord key, Ix field, Bounded field) =>-                  SearchIndex key field feature -> SearchIndex key field feature-checkInvariant si = assert (invariant si) si--invariant :: (Ord key, Ix field, Bounded field) =>-             SearchIndex key field feature -> Bool-invariant SearchIndex{termMap, termIdMap, docKeyMap, docIdMap} =-      and [ IntMap.lookup (fromEnum termId) termIdMap-            == Just (TermIdInfo term docidset)-          | (term, (TermInfo termId docidset)) <- Map.assocs termMap ]-  &&  and [ case Map.lookup term termMap of-              Just (TermInfo termId' docidset') -> toEnum termId == termId'-                                                   && docidset == docidset'-              Nothing                           -> False-          | (termId, (TermIdInfo term docidset)) <- IntMap.assocs termIdMap ]-  &&  and [ case IntMap.lookup (fromEnum docId) docIdMap of-              Just (DocInfo docKey' _ _) -> docKey == docKey'-              Nothing                  -> False-          | (docKey, docId) <- Map.assocs docKeyMap ]-  &&  and [ Map.lookup docKey docKeyMap == Just (toEnum docId)-          | (docId, DocInfo docKey _ _) <- IntMap.assocs docIdMap ]-  &&  and [ DocIdSet.invariant docIdSet-          | (_term, (TermInfo _ docIdSet)) <- Map.assocs termMap ]-  &&  and [ any (\field -> DocTermIds.fieldTermCount docterms field termId > 0) fields-          | (_term, (TermInfo termId docIdSet)) <- Map.assocs termMap-          , docId <- DocIdSet.toList docIdSet-          , let DocInfo _ docterms _ = docIdMap IntMap.! fromEnum docId ]-  &&  and [ IntMap.member (fromEnum termid) termIdMap-          | (_docId, DocInfo _ docTerms _) <- IntMap.assocs docIdMap-          , field <- fields-          , termid <- DocTermIds.fieldElems docTerms field ]-  where-    fields = Ix.range (minBound, maxBound)------------------------- Lookups-----docCount :: SearchIndex key field feature -> Int-docCount SearchIndex{docIdMap} = IntMap.size docIdMap--lookupTerm :: SearchIndex key field feature -> Term -> Maybe (TermId, DocIdSet)-lookupTerm SearchIndex{termMap} term =-    case Map.lookup term termMap of-      Nothing                         -> Nothing-      Just (TermInfo termid docidset) -> Just (termid, docidset)--lookupTermsByPrefix :: SearchIndex key field feature ->-                       Term -> [(TermId, DocIdSet)]-lookupTermsByPrefix SearchIndex{termMap} term =-    [ (termid, docidset)-    | (TermInfo termid docidset) <- lookupPrefix term termMap ]--lookupTermId :: SearchIndex key field feature -> TermId -> DocIdSet-lookupTermId SearchIndex{termIdMap} termid =-    case IntMap.lookup (fromEnum termid) termIdMap of-      Nothing -> error $ "lookupTermId: not found " ++ show termid-      Just (TermIdInfo _ docidset) -> docidset--lookupDocId :: SearchIndex key field feature ->-               DocId -> (key, DocTermIds field, DocFeatVals feature)-lookupDocId SearchIndex{docIdMap} docid =-    case IntMap.lookup (fromEnum docid) docIdMap of-      Nothing                                   -> errNotFound-      Just (DocInfo key doctermids docfeatvals) -> (key, doctermids, docfeatvals)-  where-    errNotFound = error $ "lookupDocId: not found " ++ show docid--lookupDocKey :: Ord key => SearchIndex key field feature ->-                key -> Maybe (DocTermIds field)-lookupDocKey SearchIndex{docKeyMap, docIdMap} key = do-    case Map.lookup key docKeyMap of-      Nothing    -> Nothing-      Just docid ->-        case IntMap.lookup (fromEnum docid) docIdMap of-          Nothing                          -> error "lookupDocKey: internal error"-          Just (DocInfo _key doctermids _) -> Just doctermids--lookupDocKeyDocId :: Ord key => SearchIndex key field feature -> key -> Maybe DocId-lookupDocKeyDocId SearchIndex{docKeyMap} key = Map.lookup key docKeyMap---getTerm :: SearchIndex key field feature -> TermId -> Term-getTerm SearchIndex{termIdMap} termId =-    case termIdMap IntMap.! fromEnum termId of TermIdInfo term _ -> term--getTermId :: SearchIndex key field feature -> Term -> TermId-getTermId SearchIndex{termMap} term =-    case termMap Map.! term of TermInfo termid _ -> termid--getDocKey :: SearchIndex key field feature -> DocId -> key-getDocKey SearchIndex{docIdMap} docid =-    case docIdMap IntMap.! fromEnum docid of-      DocInfo dockey _ _ -> dockey--getDocTermIds :: SearchIndex key field feature -> DocId -> DocTermIds field-getDocTermIds SearchIndex{docIdMap} docid =-    case docIdMap IntMap.! fromEnum docid of-      DocInfo _ doctermids _ -> doctermids------------------------- Insert & delete------- Procedure for adding a new doc...--- (key, field -> [Term])--- alloc docid for key--- add term occurences for docid (include rev map for termid)--- construct indexdoc now that we have all the term -> termid entries--- insert indexdoc---- Procedure for updating a doc...--- (key, field -> [Term])--- find docid for key--- lookup old terms for docid (using termid rev map)--- calc term occurrences to add, term occurrences to delete--- add new term occurrences, delete old term occurrences--- construct indexdoc now that we have all the term -> termid entries--- insert indexdoc---- Procedure for deleting a doc...--- (key, field -> [Term])--- find docid for key--- lookup old terms for docid (using termid rev map)--- delete old term occurrences--- delete indexdoc---- | This is the representation for documents to be added to the index.--- Documents may ----type DocTerms         field   = field   -> [Term]-type DocFeatureValues feature = feature -> Float--insertDoc :: (Ord key, Ix field, Bounded field, Ix feature, Bounded feature) =>-              key -> DocTerms field -> DocFeatureValues feature ->-              SearchIndex key field feature -> SearchIndex key field feature-insertDoc key userDocTerms userDocFeats si@SearchIndex{docKeyMap}-  | Just docid <- Map.lookup key docKeyMap-  = -- Some older version of the doc is already present in the index,-    -- So we keep its docid. Now have to update the doc itself-    -- and update the terms by removing old ones and adding new ones.-    let oldTermsIds   = getDocTermIds si docid-        userDocTerms' = memoiseDocTerms userDocTerms-        newTerms      = docTermSet userDocTerms'-        oldTerms      = docTermIdsTermSet si oldTermsIds-        -- We optimise for the typical case of significant overlap between-        -- the terms in the old and new versions of the document.-        delTerms      = oldTerms `Set.difference` newTerms-        addTerms      = newTerms `Set.difference` oldTerms--     -- Note: adding the doc relies on all the terms being in the termMap-     -- already, so we first add all the term occurences for the docid.-     in checkInvariant-      . insertDocIdToDocEntry docid key userDocTerms' userDocFeats-      . insertTermToDocIdEntries (Set.toList addTerms) docid-      . deleteTermToDocIdEntries (Set.toList delTerms) docid-      $ si--  | otherwise-  = -- We're dealing with a new doc, so allocate a docid for the key-    let (si', docid)  = allocFreshDocId si-        userDocTerms' = memoiseDocTerms userDocTerms-        addTerms      = docTermSet userDocTerms'--     -- Note: adding the doc relies on all the terms being in the termMap-     -- already, so we first add all the term occurences for the docid.-     in checkInvariant-      . insertDocIdToDocEntry docid key userDocTerms' userDocFeats-      . insertDocKeyToIdEntry key docid-      . insertTermToDocIdEntries (Set.toList addTerms) docid-      $ si'--deleteDoc :: (Ord key, Ix field, Bounded field) =>-             key ->-             SearchIndex key field feature -> SearchIndex key field feature-deleteDoc key si@SearchIndex{docKeyMap}-  | Just docid <- Map.lookup key docKeyMap-  = let oldTermsIds = getDocTermIds si docid-        oldTerms    = docTermIdsTermSet si oldTermsIds-     in checkInvariant-      . deleteDocEntry docid key-      . deleteTermToDocIdEntries (Set.toList oldTerms) docid-      $ si-  -  | otherwise = si---------------------------------------- Insert & delete support utils------memoiseDocTerms :: (Ix field, Bounded field) => DocTerms field -> DocTerms field-memoiseDocTerms docTermsFn =-    \field -> vecIndexIx vec field-  where-    vec = vecCreateIx docTermsFn--docTermSet :: (Bounded t, Ix t) => DocTerms t -> Set.Set Term-docTermSet docterms =-    Set.unions [ Set.fromList (docterms field)-               | field <- Ix.range (minBound, maxBound) ]--docTermIdsTermSet :: (Bounded field, Ix field) =>-                     SearchIndex key field feature ->-                     DocTermIds field -> Set.Set Term-docTermIdsTermSet si doctermids =-    Set.unions [ Set.fromList terms-               | field <- Ix.range (minBound, maxBound)-               , let termids = DocTermIds.fieldElems doctermids field-                     terms   = map (getTerm si) termids ]------- The Term <-> DocId mapping------- | Add an entry into the 'Term' to 'DocId' mapping.-insertTermToDocIdEntry :: Term -> DocId -> -                          SearchIndex key field feature ->-                          SearchIndex key field feature-insertTermToDocIdEntry term !docid si@SearchIndex{termMap, termIdMap, nextTermId} =-    case Map.lookup term termMap of-      Nothing ->-        let docIdSet'    = DocIdSet.singleton docid-            !termInfo'   = TermInfo nextTermId docIdSet'-            !termIdInfo' = TermIdInfo term     docIdSet'-         in si { termMap    = Map.insert term termInfo' termMap-               , termIdMap  = IntMap.insert (fromEnum nextTermId)-                                            termIdInfo' termIdMap-               , nextTermId = succ nextTermId }--      Just (TermInfo termId docIdSet) ->-        let docIdSet'    = DocIdSet.insert docid docIdSet-            !termInfo'   = TermInfo termId docIdSet'-            !termIdInfo' = TermIdInfo term docIdSet'-         in si { termMap   = Map.insert term termInfo' termMap-               , termIdMap = IntMap.insert (fromEnum termId)-                                           termIdInfo' termIdMap-               }---- | Add multiple entries into the 'Term' to 'DocId' mapping: many terms that--- map to the same document.-insertTermToDocIdEntries :: [Term] -> DocId ->-                            SearchIndex key field feature ->-                            SearchIndex key field feature-insertTermToDocIdEntries terms !docid si =-    foldl' (\si' term -> insertTermToDocIdEntry term docid si') si terms---- | Delete an entry from the 'Term' to 'DocId' mapping.-deleteTermToDocIdEntry :: Term -> DocId ->-                          SearchIndex key field feature ->-                          SearchIndex key field feature-deleteTermToDocIdEntry term !docid si@SearchIndex{termMap, termIdMap} =-    case  Map.lookup term termMap of-      Nothing -> si-      Just (TermInfo termId docIdSet) ->-        let docIdSet'    = DocIdSet.delete docid docIdSet-            !termInfo'   = TermInfo termId docIdSet'-            !termIdInfo' = TermIdInfo term docIdSet'-        in if DocIdSet.null docIdSet'-            then si { termMap = Map.delete term termMap-                    , termIdMap = IntMap.delete (fromEnum termId) termIdMap }-            else si { termMap   = Map.insert term termInfo' termMap-                    , termIdMap = IntMap.insert (fromEnum termId)-                                                termIdInfo' termIdMap-                    }---- | Delete multiple entries from the 'Term' to 'DocId' mapping: many terms--- that map to the same document.-deleteTermToDocIdEntries :: [Term] -> DocId ->-                            SearchIndex key field feature ->-                            SearchIndex key field feature-deleteTermToDocIdEntries terms !docid si =-    foldl' (\si' term -> deleteTermToDocIdEntry term docid si') si terms------- The DocId <-> Doc mapping-----allocFreshDocId :: SearchIndex key field feature ->-                  (SearchIndex key field feature, DocId)-allocFreshDocId si@SearchIndex{nextDocId} =-    let !si' = si { nextDocId = succ nextDocId }-     in (si', nextDocId)--insertDocKeyToIdEntry :: Ord key => key -> DocId ->-                         SearchIndex key field feature ->-                         SearchIndex key field feature-insertDocKeyToIdEntry dockey !docid si@SearchIndex{docKeyMap} =-    si { docKeyMap = Map.insert dockey docid docKeyMap }--insertDocIdToDocEntry :: (Ix field, Bounded field,-                          Ix feature, Bounded feature) =>-                         DocId -> key ->-                         DocTerms field ->-                         DocFeatureValues feature ->-                         SearchIndex key field feature ->-                         SearchIndex key field feature-insertDocIdToDocEntry !docid dockey userdocterms userdocfeats-                       si@SearchIndex{docIdMap} =-    let doctermids = DocTermIds.create (map (getTermId si) . userdocterms)-        docfeatvals= DocFeatVals.create userdocfeats-        !docinfo   = DocInfo dockey doctermids docfeatvals-     in si { docIdMap  = IntMap.insert (fromEnum docid) docinfo docIdMap }--deleteDocEntry :: Ord key => DocId -> key ->-                  SearchIndex key field feature -> SearchIndex key field feature-deleteDocEntry docid key si@SearchIndex{docIdMap, docKeyMap} =-     si { docIdMap  = IntMap.delete (fromEnum docid) docIdMap-        , docKeyMap = Map.delete key docKeyMap }------- Data.Map utils------- Data.Map does not support prefix lookups directly (unlike a trie)--- but we can implement it reasonably efficiently using split:---- | Lookup values for a range of keys (inclusive lower bound and exclusive--- upper bound)----lookupRange :: Ord k => (k, k) -> Map k v -> [v]-lookupRange (lb, ub) m =-  let (_, mv, gt)  = Map.splitLookup lb m-      (between, _) = Map.split       ub gt-   in case mv of-        Just v  -> v : Map.elems between-        Nothing ->     Map.elems between--lookupPrefix :: Text -> Map Text v -> [v]-lookupPrefix t _ | T.null t = []-lookupPrefix t m = lookupRange (t, prefixUpperBound t) m--prefixUpperBound :: Text -> Text-prefixUpperBound = succLast . T.dropWhileEnd (== maxBound)-  where-    succLast t = T.init t `T.snoc` succ (T.last t)-
− Data/SearchEngine/TermBag.hs
@@ -1,263 +0,0 @@-{-# LANGUAGE BangPatterns, GeneralizedNewtypeDeriving, MultiParamTypeClasses,-             TypeFamilies #-}-module Data.SearchEngine.TermBag (-    TermId(TermId), TermCount,-    TermBag,-    size,-    fromList,-    toList,-    elems,-    termCount,-    denseTable,-    invariant-  ) where--import qualified Data.Vector.Unboxed         as Vec-import qualified Data.Vector.Unboxed.Mutable as MVec-import qualified Data.Vector.Generic         as GVec-import qualified Data.Vector.Generic.Mutable as GMVec-import Control.Monad.ST-import Control.Monad (liftM)-import qualified Data.Map as Map-import Data.Word (Word32, Word8)-import Data.Bits-import Data.List (sortBy, foldl')-import Data.Function (on)--newtype TermId = TermId { unTermId :: Word32 }-  deriving (Eq, Ord, Show, Enum)--instance Bounded TermId where-  minBound = TermId 0-  maxBound = TermId 0x00FFFFFF--data TermBag = TermBag !Int !(Vec.Vector TermIdAndCount)-  deriving Show---- We sneakily stuff both the TermId and the bag count into one 32bit word-type TermIdAndCount = Word32-type TermCount      = Word8---- Bottom 24 bits is the TermId, top 8 bits is the bag count-termIdAndCount :: TermId -> Int -> TermIdAndCount-termIdAndCount (TermId termid) freq =-      (min (fromIntegral freq) 255 `shiftL` 24)-  .|. (termid .&. 0x00FFFFFF)--getTermId :: TermIdAndCount -> TermId-getTermId word = TermId (word .&. 0x00FFFFFF)--getTermCount :: TermIdAndCount -> TermCount-getTermCount word = fromIntegral (word `shiftR` 24)--invariant :: TermBag -> Bool-invariant (TermBag _ vec) =-    strictlyAscending (Vec.toList vec)-  where-    strictlyAscending (a:xs@(b:_)) = getTermId a < getTermId b-                                  && strictlyAscending xs-    strictlyAscending _  = True--size :: TermBag -> Int-size (TermBag sz _) = sz--elems :: TermBag -> [TermId]-elems (TermBag _ vec) = map getTermId (Vec.toList vec)--toList :: TermBag -> [(TermId, TermCount)]-toList (TermBag _ vec) = [ (getTermId x, getTermCount x)-                         | x <- Vec.toList vec ]--termCount :: TermBag -> TermId -> TermCount-termCount (TermBag _ vec) =-    binarySearch 0 (Vec.length vec - 1)-  where-    binarySearch :: Int -> Int -> TermId -> TermCount-    binarySearch !a !b !key-      | a > b     = 0-      | otherwise =-        let mid         = (a + b) `div` 2-            tidAndCount = vec Vec.! mid-         in case compare key (getTermId tidAndCount) of-              LT -> binarySearch a (mid-1) key-              EQ -> getTermCount tidAndCount-              GT -> binarySearch (mid+1) b key--fromList :: [TermId] -> TermBag-fromList termids =-    let bag = Map.fromListWith (+) [ (t, 1) | t <- termids ]-        sz  = Map.foldl' (+) 0 bag-        vec = Vec.fromListN (Map.size bag)-                            [ termIdAndCount termid freq-                            | (termid, freq) <- Map.toAscList bag ]-     in TermBag sz vec---- | Given a bunch of term bags, merge them into a table for easier subsequent--- processing. This is bascially a sparse to dense conversion. Missing entries--- are filled in with 0. We represent the table as one vector for the--- term ids and a 2d array for the counts.------ Unfortunately vector does not directly support 2d arrays and array does--- not make it easy to trim arrays.----denseTable :: [TermBag] -> (Vec.Vector TermId, Vec.Vector TermCount)-denseTable termbags =-    (tids, tcts)-  where-    -- First merge the TermIds into one array-    -- then make a linear pass to create the counts array-    -- filling in 0s or the counts as we find them-    !numBags   = length termbags-    !tids      = unionsTermId termbags-    !numTerms  = Vec.length tids-    !numCounts = numTerms * numBags-    !tcts      = Vec.create (do-                   out <- MVec.new numCounts-                   sequence_-                     [ writeMergedTermCounts tids bag out i-                     | (n, TermBag _ bag) <- zip [0..] termbags-                     , let i = n * numTerms ]-                   return out-                 )--writeMergedTermCounts :: Vec.Vector TermId -> Vec.Vector TermIdAndCount ->-                         MVec.MVector s TermCount -> Int -> ST s ()-writeMergedTermCounts xs0 ys0 !out i0 =-    -- assume xs & ys are sorted, and ys contains a subset of xs-    go xs0 ys0 i0-  where-    go !xs !ys !i-      | Vec.null ys = MVec.set (MVec.slice i (Vec.length xs) out) 0-      | Vec.null xs = return ()-      | otherwise   = let x   = Vec.head xs-                          ytc = Vec.head ys-                          y   = getTermId ytc-                          c   = getTermCount ytc-                      in case x == y of-                           True  -> do MVec.write out i c-                                       go (Vec.tail xs) (Vec.tail ys) (i+1)-                           False -> do MVec.write out i 0-                                       go (Vec.tail xs)           ys  (i+1)---- | Given a set of term bags, form the set of TermIds----unionsTermId :: [TermBag] -> Vec.Vector TermId-unionsTermId tbs =-    case sortBy (compare `on` bagVecLength) tbs of-      []             -> Vec.empty-      [TermBag _ xs] -> (Vec.map getTermId xs)-      (x0:x1:xs)     -> foldl' union3 (union2 x0 x1) xs-  where-    bagVecLength (TermBag _ vec) = Vec.length vec--union2 :: TermBag -> TermBag -> Vec.Vector TermId-union2 (TermBag _ xs) (TermBag _ ys) =-    Vec.create (MVec.new sizeBound >>= writeMergedUnion2 xs ys)-  where-    sizeBound = Vec.length xs + Vec.length ys--writeMergedUnion2 :: Vec.Vector TermIdAndCount -> Vec.Vector TermIdAndCount ->-                     MVec.MVector s TermId -> ST s (MVec.MVector s TermId)-writeMergedUnion2 xs0 ys0 !out = do-    i <- go xs0 ys0 0-    return $! MVec.take i out-  where-    go !xs !ys !i-      | Vec.null xs = do Vec.copy (MVec.slice i (Vec.length ys) out)-                                  (Vec.map getTermId ys)-                         return (i + Vec.length ys)-      | Vec.null ys = do Vec.copy (MVec.slice i (Vec.length xs) out)-                                  (Vec.map getTermId xs)-                         return (i + Vec.length xs)-      | otherwise   = let x = getTermId (Vec.head xs)-                          y = getTermId (Vec.head ys)-                      in case compare x y of-                          GT -> do MVec.write out i y-                                   go           xs  (Vec.tail ys) (i+1)-                          EQ -> do MVec.write out i x-                                   go (Vec.tail xs) (Vec.tail ys) (i+1)-                          LT -> do MVec.write out i x-                                   go (Vec.tail xs)           ys  (i+1)--union3 :: Vec.Vector TermId -> TermBag -> Vec.Vector TermId-union3 xs (TermBag _ ys) =-    Vec.create (MVec.new sizeBound >>= writeMergedUnion3 xs ys)-  where-    sizeBound = Vec.length xs + Vec.length ys--writeMergedUnion3 :: Vec.Vector TermId -> Vec.Vector TermIdAndCount ->-                     MVec.MVector s TermId -> ST s (MVec.MVector s TermId)-writeMergedUnion3 xs0 ys0 !out = do-    i <- go xs0 ys0 0-    return $! MVec.take i out-  where-    go !xs !ys !i-      | Vec.null xs = do Vec.copy (MVec.slice i (Vec.length ys) out)-                                  (Vec.map getTermId ys)-                         return (i + Vec.length ys)-      | Vec.null ys = do Vec.copy (MVec.slice i (Vec.length xs) out) xs-                         return (i + Vec.length xs)-      | otherwise   = let x =            Vec.head xs-                          y = getTermId (Vec.head ys)-                      in case compare x y of-                          GT -> do MVec.write out i y-                                   go           xs  (Vec.tail ys) (i+1)-                          EQ -> do MVec.write out i x-                                   go (Vec.tail xs) (Vec.tail ys) (i+1)-                          LT -> do MVec.write out i x-                                   go (Vec.tail xs)           ys  (i+1)----------------------------------------------------------------------------------- verbose Unbox instances-----instance MVec.Unbox TermId--newtype instance MVec.MVector s TermId = MV_TermId (MVec.MVector s Word32)--instance GMVec.MVector MVec.MVector TermId where-    basicLength          (MV_TermId v) = GMVec.basicLength v-    basicUnsafeSlice i l (MV_TermId v) = MV_TermId (GMVec.basicUnsafeSlice i l v)-    basicUnsafeNew     l               = MV_TermId `liftM` GMVec.basicUnsafeNew l-    basicInitialize      (MV_TermId v) = GMVec.basicInitialize v-    basicUnsafeReplicate l x           = MV_TermId `liftM` GMVec.basicUnsafeReplicate l (unTermId x)-    basicUnsafeRead  (MV_TermId v) i   = TermId `liftM`    GMVec.basicUnsafeRead v i-    basicUnsafeWrite (MV_TermId v) i x = GMVec.basicUnsafeWrite v i (unTermId x)-    basicClear       (MV_TermId v)     = GMVec.basicClear v-    basicSet         (MV_TermId v) x   = GMVec.basicSet v (unTermId x)-    basicUnsafeGrow  (MV_TermId v) l   = MV_TermId `liftM` GMVec.basicUnsafeGrow v l-    basicUnsafeCopy  (MV_TermId v) (MV_TermId v') = GMVec.basicUnsafeCopy v v'-    basicUnsafeMove  (MV_TermId v) (MV_TermId v') = GMVec.basicUnsafeMove v v'-    basicOverlaps    (MV_TermId v) (MV_TermId v') = GMVec.basicOverlaps   v v'-    {-# INLINE basicLength #-}-    {-# INLINE basicUnsafeSlice #-}-    {-# INLINE basicOverlaps #-}-    {-# INLINE basicUnsafeNew #-}-    {-# INLINE basicInitialize #-}-    {-# INLINE basicUnsafeReplicate #-}-    {-# INLINE basicUnsafeRead #-}-    {-# INLINE basicUnsafeWrite #-}-    {-# INLINE basicClear #-}-    {-# INLINE basicSet #-}-    {-# INLINE basicUnsafeCopy #-}-    {-# INLINE basicUnsafeMove #-}-    {-# INLINE basicUnsafeGrow #-}--newtype instance Vec.Vector TermId = V_TermId (Vec.Vector Word32)--instance GVec.Vector Vec.Vector TermId where-    basicUnsafeFreeze (MV_TermId mv)  = V_TermId  `liftM` GVec.basicUnsafeFreeze mv-    basicUnsafeThaw   (V_TermId  v)   = MV_TermId `liftM` GVec.basicUnsafeThaw v-    basicLength       (V_TermId  v)   = GVec.basicLength v-    basicUnsafeSlice i l (V_TermId v) = V_TermId (GVec.basicUnsafeSlice i l v)-    basicUnsafeIndexM (V_TermId  v) i = TermId `liftM` GVec.basicUnsafeIndexM v i-    basicUnsafeCopy   (MV_TermId mv)-                      (V_TermId  v)   = GVec.basicUnsafeCopy mv v-    elemseq           (V_TermId  v) x = GVec.elemseq v (unTermId x)-    {-# INLINE basicUnsafeFreeze #-}-    {-# INLINE basicUnsafeThaw #-}-    {-# INLINE basicLength #-}-    {-# INLINE basicUnsafeSlice #-}-    {-# INLINE basicUnsafeIndexM #-}-    {-# INLINE basicUnsafeCopy #-}-    {-# INLINE elemseq #-}
− Data/SearchEngine/Types.hs
@@ -1,124 +0,0 @@-{-# LANGUAGE NamedFieldPuns, RecordWildCards #-}--module Data.SearchEngine.Types (-    -- * Search engine types and helper functions-    SearchEngine(..),-    SearchConfig(..),-    SearchRankParameters(..),-    BM25F.FeatureFunction(..),-    initSearchEngine,-    cacheBM25Context,--    -- ** Helper type for non-term features-    NoFeatures,-    noFeatures,--    -- * Re-export SearchIndex and other types-    SearchIndex, Term, TermId,-    DocIdSet, DocId,-    DocTermIds, DocFeatVals,--    -- * Internal sanity check-    invariant,-  ) where--import Data.SearchEngine.SearchIndex (SearchIndex, Term, TermId)-import qualified Data.SearchEngine.SearchIndex as SI-import Data.SearchEngine.DocIdSet (DocIdSet, DocId)-import qualified Data.SearchEngine.DocIdSet as DocIdSet-import Data.SearchEngine.DocFeatVals (DocFeatVals)-import Data.SearchEngine.DocTermIds  (DocTermIds)-import qualified Data.SearchEngine.BM25F as BM25F--import Data.Ix-import Data.Array.Unboxed----data SearchConfig doc key field feature = SearchConfig {-       documentKey          :: doc -> key,-       extractDocumentTerms :: doc -> field -> [Term],-       transformQueryTerm   :: Term -> field -> Term,-       documentFeatureValue :: doc -> feature -> Float-     }--data SearchRankParameters field feature = SearchRankParameters {-       paramK1                 :: !Float,-       paramB                  :: field -> Float,-       paramFieldWeights       :: field -> Float,-       paramFeatureWeights     :: feature -> Float,-       paramFeatureFunctions   :: feature -> BM25F.FeatureFunction,--       paramResultsetSoftLimit   :: !Int,-       paramResultsetHardLimit   :: !Int,-       paramAutosuggestPrefilterLimit  :: !Int,-       paramAutosuggestPostfilterLimit :: !Int-     }--data SearchEngine doc key field feature = SearchEngine {-       searchIndex      :: !(SearchIndex      key field feature),-       searchConfig     :: !(SearchConfig doc key field feature),-       searchRankParams :: !(SearchRankParameters field feature),--       -- cached info-       sumFieldLengths :: !(UArray field Int),-       bm25Context     :: BM25F.Context TermId field feature-     }--invariant :: (Ord key, Ix field, Bounded field) =>-             SearchEngine doc key field feature -> Bool-invariant SearchEngine{searchIndex} =-    SI.invariant searchIndex--- && check caches--initSearchEngine :: (Ix field, Bounded field, Ix feature, Bounded feature) =>-                    SearchConfig doc key field feature ->-                    SearchRankParameters field feature ->-                    SearchEngine doc key field feature-initSearchEngine config params =-    cacheBM25Context-      SearchEngine {-        searchIndex      = SI.emptySearchIndex,-        searchConfig     = config,-        searchRankParams = params,-        sumFieldLengths  = listArray (minBound, maxBound) (repeat 0),-        bm25Context      = undefined-      }--cacheBM25Context :: Ix field =>-                    SearchEngine doc key field feature ->-                    SearchEngine doc key field feature-cacheBM25Context-    se@SearchEngine {-      searchRankParams = SearchRankParameters{..},-      searchIndex,-      sumFieldLengths-    }-  = se { bm25Context = bm25Context' }-  where-    bm25Context' = BM25F.Context {-      BM25F.numDocsTotal    = SI.docCount searchIndex,-      BM25F.avgFieldLength  = \f -> fromIntegral (sumFieldLengths ! f)-                                  / fromIntegral (SI.docCount searchIndex),-      BM25F.numDocsWithTerm = DocIdSet.size . SI.lookupTermId searchIndex,-      BM25F.paramK1         = paramK1,-      BM25F.paramB          = paramB,-      BM25F.fieldWeight     = paramFieldWeights,-      BM25F.featureWeight   = paramFeatureWeights,-      BM25F.featureFunction = paramFeatureFunctions-    }----------------------------------data NoFeatures = NoFeatures-  deriving (Eq, Ord, Bounded, Show)--instance Ix NoFeatures where-  range   _   = []-  inRange _ _ = False-  index   _ _ = -1--noFeatures :: NoFeatures -> a-noFeatures _ = error "noFeatures"-
− Data/SearchEngine/Update.hs
@@ -1,90 +0,0 @@-{-# LANGUAGE BangPatterns, NamedFieldPuns, RecordWildCards #-}--module Data.SearchEngine.Update (--    -- * Managing documents to be searched-    insertDoc,-    insertDocs,-    deleteDoc,--  ) where--import Data.SearchEngine.Types-import qualified Data.SearchEngine.SearchIndex as SI-import qualified Data.SearchEngine.DocTermIds as DocTermIds--import Data.Ix-import Data.Array.Unboxed-import Data.List---insertDocs :: (Ord key, Ix field, Bounded field, Ix feature, Bounded feature) =>-              [doc] ->-              SearchEngine doc key field feature ->-              SearchEngine doc key field feature-insertDocs docs se = foldl' (\se' doc -> insertDoc doc se') se docs---insertDoc :: (Ord key, Ix field, Bounded field, Ix feature, Bounded feature) =>-             doc ->-             SearchEngine doc key field feature ->-             SearchEngine doc key field feature-insertDoc doc se@SearchEngine{ searchConfig = SearchConfig {-                                 documentKey,-                                 extractDocumentTerms,-                                 documentFeatureValue-                               }-                             , searchIndex } =-    let key = documentKey doc-        searchIndex' = SI.insertDoc key (extractDocumentTerms doc)-                                        (documentFeatureValue doc)-                                        searchIndex-        oldDoc       = SI.lookupDocKey searchIndex  key-        newDoc       = SI.lookupDocKey searchIndex' key--     in cacheBM25Context $-        updateCachedFieldLengths oldDoc newDoc $-          se { searchIndex = searchIndex' }---deleteDoc :: (Ord key, Ix field, Bounded field) =>-             key ->-             SearchEngine doc key field feature ->-             SearchEngine doc key field feature-deleteDoc key se@SearchEngine{searchIndex} =-    let searchIndex' = SI.deleteDoc key searchIndex-        oldDoc       = SI.lookupDocKey searchIndex key--     in cacheBM25Context $-        updateCachedFieldLengths oldDoc Nothing $-          se { searchIndex = searchIndex' }---updateCachedFieldLengths :: (Ix field, Bounded field) =>-                            Maybe (DocTermIds field) -> Maybe (DocTermIds field) ->-                            SearchEngine doc key field feature ->-                            SearchEngine doc key field feature-updateCachedFieldLengths Nothing (Just newDoc) se@SearchEngine{sumFieldLengths} =-    se {-      sumFieldLengths =-        array (bounds sumFieldLengths)-              [ (i, n + DocTermIds.fieldLength newDoc i)-              | (i, n) <- assocs sumFieldLengths ]-    }-updateCachedFieldLengths (Just oldDoc) (Just newDoc) se@SearchEngine{sumFieldLengths} =-    se {-      sumFieldLengths =-        array (bounds sumFieldLengths)-              [ (i, n - DocTermIds.fieldLength oldDoc i-                      + DocTermIds.fieldLength newDoc i)-              | (i, n) <- assocs sumFieldLengths ]-    }-updateCachedFieldLengths (Just oldDoc) Nothing se@SearchEngine{sumFieldLengths} =-    se {-      sumFieldLengths =-        array (bounds sumFieldLengths)-              [ (i, n - DocTermIds.fieldLength oldDoc i)-              | (i, n) <- assocs sumFieldLengths ]-    }-updateCachedFieldLengths Nothing Nothing se = se-
changelog view
@@ -1,3 +1,7 @@+0.2.2.3 Ben Gamari <ben@well-typed.com> January 2025+	* Introduce compatibility with GHCs up to 9.12+	* Drop compatibility with GHC versions pre-9.2+ 0.2.2.2 Adam Gundry <adam@well-typed.com> March 2023 	* Fix bug in 0.2.2.1 autosuggest patch 
demo/ExtractNameTerms.hs view
@@ -93,8 +93,8 @@   case parts t of     []             -> return ()     ts             -> forEach (t:ts) >>= put-   + splitDot :: String -> [String] splitDot = split (dropBlanks $ dropDelims $ whenElt (=='.')) @@ -149,7 +149,7 @@     let mostFreq :: [String]         pkgs     :: [PackageDescription]         (mostFreq, pkgs) = read pkgsFile-        + --    wordsFile <- T.readFile "/usr/share/dict/words" --    let ws = Set.fromList (map T.toLower $ T.lines wordsFile) 
full-text-search.cabal view
@@ -1,47 +1,52 @@-name:                full-text-search-version:             0.2.2.2-synopsis:            In-memory full text search engine-description:         An in-memory full text search engine library. It lets you-                     run full-text queries on a collection of your documents.-                     .-                     Features:-                     .-                     * Keyword queries and auto-complete\/auto-suggest queries.-                     .-                     * Can search over any type of \"document\".-                       (You explain how to extract search terms from them.)-                     .-                     * Supports documents with multiple fields-                       (e.g. title, body)-                     .-                     * Supports documents with non-term features-                       (e.g. quality score, page rank)-                     .-                     * Uses the state of the art BM25F ranking function-                     .-                     * Adjustable ranking parameters (including field weights-                       and non-term feature scores)-                     .-                     * In-memory but quite compact. It does not keep a copy of-                       your original documents.-                     .-                     * Quick incremental index updates, making it possible to-                       keep your text search in-sync with your data.-                     .-                     It is independent of the document type, so you have to-                     write the document-specific parts: extracting search terms-                     and any stop words, case-normalisation or stemming. This-                     is quite easy using libraries such as-                     <http://hackage.haskell.org/package/tokenize tokenize> and-                     <http://hackage.haskell.org/package/snowball snowball>.-                     .-                     The source package includes a demo to illustrate how to-                     use the library. The demo is a simplified version of how-                     the library is used in the-                     <http://hackage.haskell.org/package/hackage-server hackage-server>-                     where it provides the backend for the package search feature.+cabal-version: 3.0++name: full-text-search+version: 0.2.2.3+synopsis: In-memory full text search engine++description:+  An in-memory full text search engine library. It lets you+  run full-text queries on a collection of your documents.++  Features:++  * Keyword queries and auto-complete\/auto-suggest queries.++  * Can search over any type of \"document\".+    (You explain how to extract search terms from them.)++  * Supports documents with multiple fields+    (e.g. title, body)++  * Supports documents with non-term features+    (e.g. quality score, page rank)++  * Uses the state of the art BM25F ranking function++  * Adjustable ranking parameters (including field weights+    and non-term feature scores)++  * In-memory but quite compact. It does not keep a copy of+    your original documents.++  * Quick incremental index updates, making it possible to+    keep your text search in-sync with your data.++  It is independent of the document type, so you have to+  write the document-specific parts: extracting search terms+  and any stop words, case-normalisation or stemming. This+  is quite easy using libraries such as+  <https://hackage.haskell.org/package/tokenize tokenize> and+  <https://hackage.haskell.org/package/snowball snowball>.++  The source package includes a demo to illustrate how to+  use the library. The demo is a simplified version of how+  the library is used in the+  <https://hackage.haskell.org/package/hackage-server hackage-server>+  where it provides the backend for the package search feature.+ bug-reports:         https://github.com/well-typed/full-text-search/issues-license:             BSD3+license:             BSD-3-Clause license-file:        LICENSE author:              Duncan Coutts maintainer:          Duncan Coutts <duncan@well-typed.com>,@@ -50,10 +55,9 @@                      2014-2023 IRIS Connect Ltd. category:            Data, Text, Search build-type:          Simple-cabal-version:       >=1.10-extra-source-files:  changelog+extra-doc-files:     changelog -tested-with:         GHC ==8.10.7 || ==9.0.2 || ==9.2.7 || ==9.4.4 || ==9.6.1+tested-with:         GHC ==9.2.8 || ==9.4.8 || ==9.6.6 || ==9.8.2 || ==9.10.1 || ==9.12.1  source-repository head   type:              git@@ -64,7 +68,15 @@   description:       Build a little program illustrating the use of the library   manual:            True +common base-deps+  build-depends:       base       >=4.16 && <4.22,+                       array      >=0.4  && <0.6,+                       vector     >=0.11 && <0.14,+                       containers >=0.4  && <0.8,+                       text       >=0.11 && <2.2+ library+  import:              base-deps   exposed-modules:     Data.SearchEngine,                        Data.SearchEngine.BM25F   other-modules:       Data.SearchEngine.Types,@@ -76,16 +88,12 @@                        Data.SearchEngine.TermBag,                        Data.SearchEngine.DocTermIds,                        Data.SearchEngine.DocIdSet+  hs-source-dirs:      src   other-extensions:    BangPatterns,                        NamedFieldPuns,                        RecordWildCards,                        GeneralizedNewtypeDeriving,                        ScopedTypeVariables-  build-depends:       base       >=4.5  && <4.19,-                       array      >=0.4  && <0.6,-                       vector     >=0.11 && <0.14,-                       containers >=0.4  && <0.7,-                       text       >=0.11 && <2.1   default-language:    Haskell2010   ghc-options:         -Wall -funbox-strict-fields @@ -106,28 +114,32 @@     buildable:         False   else     build-depends:     full-text-search,-                       base, text, containers, array,-                       tokenize      >= 0.1,-                       snowball      == 1.0.*,-                       transformers,-                       split         >= 0.2,-                       Cabal         >= 1.14 && < 3,-                       bytestring, filepath, directory, tar, time, mtl-  build-tools:         alex, happy+                       base,+                       text,+                       containers,+                       array,+                       tokenize      >= 0.1  && <0.4,+                       snowball      >= 1.0  && <1.1,+                       transformers  >= 0.5  && <0.6,+                       split         >= 0.2  && <0.3,+                       Cabal         >= 1.14 && <3.15,+                       bytestring    >= 0.12 && <0.13,+                       filepath      >= 1.5  && <1.6,+                       directory     >= 1.3  && <1.5,+                       tar           >= 0.6  && <0.7,+                       time          >= 1.14 && <1.15,+                       mtl           >= 2.2  && <2.4+  build-tool-depends:  alex:alex, happy:happy   default-language:    Haskell2010   other-extensions:    GeneralizedNewtypeDeriving   ghc-options:         -Wall  test-suite qc-props+  import:              base-deps   type:                exitcode-stdio-1.0   main-is:             Main.hs-  hs-source-dirs:      . tests-  build-depends:       base,-                       array,-                       vector,-                       containers,-                       text,-                       QuickCheck       ==2.*,+  hs-source-dirs:      src, tests+  build-depends:       QuickCheck       ==2.*,                        tasty            >=0.8,                        tasty-quickcheck >=0.8   other-modules:       Test.Data.SearchEngine.TermBag,
+ src/Data/SearchEngine.hs view
@@ -0,0 +1,44 @@+module Data.SearchEngine (++    -- * Basic interface++    -- ** Querying+    Term,+    query,++    -- *** Query auto-completion \/ auto-suggestion+    queryAutosuggest,+    ResultsFilter(..),+    queryAutosuggestPredicate,+    queryAutosuggestMatchingDocuments,++    -- ** Making a search engine instance+    initSearchEngine,+    SearchEngine,+    SearchConfig(..),+    SearchRankParameters(..),+    FeatureFunction(..),++    -- ** Helper type for non-term features+    NoFeatures,+    noFeatures,++    -- ** Managing documents to be searched+    insertDoc,+    insertDocs,+    deleteDoc,++    -- * Explain mode for query result rankings+    queryExplain,+    Explanation(..),+    setRankParams,++    -- * Internal sanity check+    invariant,+  ) where++import Data.SearchEngine.Types+import Data.SearchEngine.Update+import Data.SearchEngine.Query+import Data.SearchEngine.Autosuggest+
+ src/Data/SearchEngine/Autosuggest.hs view
@@ -0,0 +1,573 @@+{-# LANGUAGE BangPatterns, NamedFieldPuns, RecordWildCards,+             ScopedTypeVariables #-}++module Data.SearchEngine.Autosuggest (++    -- * Query auto-completion \/ auto-suggestion+    queryAutosuggest,+    ResultsFilter(..),++    queryAutosuggestPredicate,+    queryAutosuggestMatchingDocuments++  ) where++import Data.SearchEngine.Types+import Data.SearchEngine.Query (ResultsFilter(..))+import qualified Data.SearchEngine.Query       as Query+import qualified Data.SearchEngine.SearchIndex as SI+import qualified Data.SearchEngine.DocIdSet    as DocIdSet+import qualified Data.SearchEngine.DocTermIds  as DocTermIds+import qualified Data.SearchEngine.BM25F       as BM25F++import Data.Ix+import Data.Ord+import Data.List+import Data.Maybe+import qualified Data.Map as Map+import qualified Data.IntSet as IntSet+import qualified Data.Vector.Unboxed as Vec+++-- | Execute an \"auto-suggest\" query. This is where one of the search terms+-- is an incomplete prefix and we are looking for possible completions of that+-- search term, and result documents to go with the possible completions.+--+-- An auto-suggest query only gives useful results when the 'SearchEngine' is+-- configured to use a non-term feature score. That is, when we can give+-- documents an importance score independent of what terms we are looking for.+-- This is because an auto-suggest query is backwards from a normal query: we+-- are asking for relevant terms occurring in important or popular documents+-- so we need some notion of important or popular. Without this we would just+-- be ranking based on term frequency which while it makes sense for normal+-- \"forward\" queries is pretty meaningless for auto-suggest \"reverse\"+-- queries. Indeed for single-term auto-suggest queries the ranking function+-- we use will assign 0 for all documents and completions if there is no +-- non-term feature scores.+--+queryAutosuggest :: (Ix field, Bounded field, Ix feature, Bounded feature) =>+                    SearchEngine doc key field feature ->+                    ResultsFilter key ->+                    [Term] -> Term -> ([(Term, Float)], [(key, Float)])+queryAutosuggest se resultsFilter precedingTerms partialTerm =++     step_external+   . step_rank+   . step_scoreDs+   . step_scoreTs+   . step_cache+   . step_postfilterlimit+   . step_filter+   . step_prefilterlimit+   . step_process+   $ step_prep+       precedingTerms partialTerm++  where+    -- Construct the auto-suggest query from the query terms+    step_prep pre_ts t = mkAutosuggestQuery se pre_ts t++    -- Find the appropriate subset of ts and ds+    -- and an intermediate result that will be useful later:+    -- { (t, ds ∩ ds_t) | t ∈ ts, ds ∩ ds_t ≠ ∅ }+    step_process (ts, ds, pre_ts) = (ts', ds', tdss', pre_ts)+      where+        (tdss', ts', ds') = processAutosuggestQuery se (ts, ds, pre_ts)++    -- If the number of docs results is huge then we may not want to bother+    -- and just return no results. Even the filtering of a huge number of+    -- docs can be expensive.+    step_prefilterlimit args@(_, ds, _, _)+      | withinPrefilterLimit se ds = args+      | otherwise                  = ([], DocIdSet.empty, [], [])++    -- Filter ds to those that are visible for this query+    -- and at the same time, do the docid -> docinfo lookup+    -- (needed at this step anyway to do the filter)+    step_filter (ts, ds, tdss, pre_ts) = (ts, ds_info, tdss, pre_ts)+      where+        ds_info = filterAutosuggestQuery se resultsFilter ds++    -- If the number of docs results is huge then we may not want to bother+    -- and just return no results. Scoring a large number of docs is expensive.+    step_postfilterlimit args@(_, ds_info, _, _)+      | withinPostfilterLimit se ds_info = args+      | otherwise                        = ([], [], [], [])++    -- For all ds, calculate and cache a couple bits of info needed+    -- later for scoring completion terms and doc results+    step_cache (ts, ds_info, tdss, pre_ts) = (ds_info', tdss)+      where+        ds_info' = cacheDocScoringInfo se ts ds_info pre_ts++    -- Score the completion terms+    step_scoreTs (ds_info, tdss) = (ds_info, tdss, ts_scored)+      where+        ts_scored = scoreAutosuggestQueryCompletions tdss ds_info++    -- Score the doc results (making use of the completion scores)+    step_scoreDs (ds_info, tdss, ts_scored) = (ts_scored, ds_scored)+      where+        ds_scored = scoreAutosuggestQueryResults tdss ds_info ts_scored++    -- Rank the completions and results based on their scores+    step_rank = sortResults++    -- Convert from internal Ids into external forms: Term and doc key+    step_external = convertIdsToExternal se+++-- | Given an incomplete prefix query, find the set of documents that match+-- possible completions of that query.  This should be less computationally+-- expensive than 'queryAutosuggest' as it does not do any ranking of documents.+-- However, it does not apply the pre-filter or post-filter limits, and the list+-- may be large when the query terms occur in many documents.  The order of+-- returned keys is unspecified.+queryAutosuggestMatchingDocuments :: (Ix field, Bounded field, Ord key) =>+                                     SearchEngine doc key field feature ->+                                     [Term] -> Term -> [key]+queryAutosuggestMatchingDocuments se@SearchEngine{searchIndex} precedingTerms partialTerm =+    let (_, _, ds) = processAutosuggestQuery se (mkAutosuggestQuery se precedingTerms partialTerm)+    in map (SI.getDocKey searchIndex) (DocIdSet.toList ds)++-- | Given an incomplete prefix query, return a predicate that indicates whether+-- a key is in the set of documents that match possible completions of that+-- query.  This is equivalent to calling 'queryAutosuggestMatchingDocuments' and+-- testing whether the key is in the list, but should be more efficient.+--+-- This does not apply the pre-filter or post-filter limits.+queryAutosuggestPredicate :: (Ix field, Bounded field, Ord key) =>+                             SearchEngine doc key field feature ->+                             [Term] -> Term -> (key -> Bool)+queryAutosuggestPredicate se@SearchEngine{searchIndex} precedingTerms partialTerm =+    let (_, _, ds) = processAutosuggestQuery se (mkAutosuggestQuery se precedingTerms partialTerm)+    in (\ key -> maybe False (flip DocIdSet.member ds) (SI.lookupDocKeyDocId searchIndex key))+++-- We apply hard limits both before and after filtering.+-- The post-filter limit is to avoid scoring 1000s of documents.+-- The pre-filter limit is to avoid filtering 1000s of docs (which in some+-- apps may be expensive itself)++withinPrefilterLimit :: SearchEngine doc key field feature ->+                        DocIdSet -> Bool+withinPrefilterLimit SearchEngine{searchRankParams} ds =+    DocIdSet.size ds <= paramAutosuggestPrefilterLimit searchRankParams++withinPostfilterLimit :: SearchEngine doc key field feature ->+                         [a] -> Bool+withinPostfilterLimit SearchEngine{searchRankParams} ds_info =+    length ds_info <= paramAutosuggestPostfilterLimit searchRankParams+++sortResults :: (Ord av, Ord bv) => ([(a,av)], [(b,bv)]) -> ([(a,av)], [(b,bv)])+sortResults (xs, ys) =+    ( sortBySndDescending xs+    , sortBySndDescending ys )+  where+    sortBySndDescending :: Ord v => [(x,v)] -> [(x,v)]+    sortBySndDescending = sortBy (flip (comparing snd))++convertIdsToExternal :: SearchEngine doc key field feature ->+                        ([(TermId, v)], [(DocId, v)]) -> ([(Term, v)], [(key, v)])+convertIdsToExternal SearchEngine{searchIndex} (termids, docids) =+    ( [ (SI.getTerm   searchIndex termid, s) | (termid, s) <- termids ]+    , [ (SI.getDocKey searchIndex docid,  s) | (docid,  s) <- docids  ]+    )+++-- From Bast and Weber:+--+--   An autocompletion query is a pair (T, D), where T is a range of terms+--   (all possible completions of the last term which the user has started+--   typing) and D is a set of documents (the hits for the preceding part of+--   the query).+--+-- We augment this with the preceding terms because we will need these to+-- score the set of documents D.+--+-- Note that the set D will be the entire collection in the case that the+-- preceding part of the query is empty. For efficiency we represent that+-- case specially with Maybe.++type AutosuggestQuery = (Map.Map TermId DocIdSet, Maybe DocIdSet, [TermId])++mkAutosuggestQuery :: (Ix field, Bounded field) =>+                      SearchEngine doc key field feature ->+                      [Term] -> Term -> AutosuggestQuery+mkAutosuggestQuery se@SearchEngine{ searchIndex }+                   precedingTerms partialTerm =+    (completionTerms, precedingDocHits, precedingTerms')+  where+    completionTerms =+      Map.unions+        [ Map.fromList (SI.lookupTermsByPrefix searchIndex partialTerm')+        | partialTerm' <- Query.expandTransformedQueryTerm se partialTerm+        ]++    (precedingTerms', precedingDocHits)+      | null precedingTerms = ([], Nothing)+      | otherwise           = fmap carefulUnions+                                   (lookupRawResults precedingTerms)++    -- For the preceding terms, we compute the union of the sets of documents in+    -- which they appear.  This means that a query like "Apple Blackberry C"+    -- will look for documents containing "Apple" or "Blackberry", then later+    -- intersect that set with documents containing completions of "C".+    --+    -- In general we want to use union rather than intersection here, because+    -- the preceding terms might contain some useful and some missing terms, and+    -- if we took the intersection we would end up with no results; thus we rely+    -- on scoring to rank the best matches highest.+    --+    -- However, this leads to an issue: if some of the terms are extremely+    -- common, we might end up taking unions of very large document sets, which+    -- is a performance disaster.  We address this by unioning only sets smaller+    -- than the pre-filter limit (but falling back on the whole collection if+    -- all sets are too large).  This means that:+    --+    --  * A query containing a mixture of common and uncommon preceding terms+    --    will be completed/ranked solely based on the uncommon terms.  For+    --    example, "Apple Blackberry C" will be equivalent to "Blackberry C" if+    --    there are many apples.+    --+    --  * A query containing only common preceding terms will be+    --    completed/ranked as if only the final term was present.  For example,+    --    "Apple Blackberry C" will be equivalent to "C" if there are many+    --    apples and blackberries.+    --+    carefulUnions :: [DocIdSet] -> Maybe DocIdSet+    carefulUnions dss+      | null dss  = Just DocIdSet.empty+      | null dss' = Nothing+      | otherwise = Just (DocIdSet.unions dss')+      where+        dss' = filter (withinPrefilterLimit se) dss++    lookupRawResults :: [Term] -> ([TermId], [DocIdSet])+    lookupRawResults ts =+      unzip $ catMaybes+        [ SI.lookupTerm searchIndex t'+        | t  <- ts+        , t' <- Query.expandTransformedQueryTerm se t+        ]++++-- From Bast and Weber:+--+--   To process the query means to compute the subset T' ⊆ T of terms that+--   occur in at least one document from D, as well as the subset D' ⊆ D of+--   documents that contain at least one of these words.+--+--   The obvious way to use an inverted index to process an autocompletion+--   query (T, D) is to compute, for each t ∈ T, the intersections D ∩ Dt.+--   Then, T' is simply the set of all t for which the intersection was+--   non-empty, and D' is the union of all (non-empty) intersections.+--+-- We will do this but additionally we will return all the non-empty+-- intersections because they will be useful when scoring.++processAutosuggestQuery :: SearchEngine doc key field feature ->+                           AutosuggestQuery ->+                           ([(TermId, DocIdSet)], [TermId], DocIdSet)+processAutosuggestQuery se (completionTerms, precedingDocHits, _)+  -- Check all the individual document sets are smaller than the pre-filter+  -- limit.  If any are larger, their union must also be too large, so we return+  -- no results now rather than having to compute the union (which may be+  -- expensive) only for it to inevitably hit the limit.+  | all (withinPrefilterLimit se) docSets =+    ( completionTermAndDocSets+    , completionTerms'+    , allTermDocSet+    )+  | otherwise = ([], [], DocIdSet.empty)+  where+    -- We look up each candidate completion to find the set of documents+    -- it appears in, and filtering (intersecting) down to just those+    -- appearing in the existing partial query results (if any).+    -- Candidate completions not appearing at all within the existing+    -- partial query results are excluded at this stage.+    --+    -- We have to keep these doc sets for the whole process, so we keep+    -- them as the compact DocIdSet type.+    --+    completionTermAndDocSets :: [(TermId, DocIdSet)]+    completionTermAndDocSets =+      [ (t, ds_t')+      | (t, ds_t) <- Map.toList completionTerms+      , let ds_t' = case precedingDocHits of+                      Just ds -> ds `DocIdSet.intersection` ds_t+                      Nothing -> ds_t+      , not (DocIdSet.null ds_t')+      ]++    -- The remaining candidate completions+    completionTerms' :: [TermId]+    docSets :: [DocIdSet]+    (completionTerms', docSets) = unzip completionTermAndDocSets++    -- The union of all these is this set of documents that form the results.+    allTermDocSet :: DocIdSet+    allTermDocSet = DocIdSet.unions docSets+++filterAutosuggestQuery :: SearchEngine doc key field feature ->+                          ResultsFilter key ->+                          DocIdSet ->+                          [(DocId, (key, DocTermIds field, DocFeatVals feature))]+filterAutosuggestQuery SearchEngine{ searchIndex } resultsFilter ds =+    case resultsFilter of+      NoFilter ->+        [ (docid, doc)+        | docid <- DocIdSet.toList ds+        , let doc = SI.lookupDocId searchIndex docid ]++      FilterPredicate predicate ->+        [ (docid, doc)+        | docid <- DocIdSet.toList ds+        , let doc@(k,_,_) = SI.lookupDocId searchIndex docid+        , predicate k ]++      FilterBulkPredicate bulkPredicate ->+        [ (docid, doc)+        | let docids = DocIdSet.toList ds+              docinf = map (SI.lookupDocId searchIndex) docids+              keep   = bulkPredicate [ k | (k,_,_) <- docinf ]+        , (docid, doc, True) <- zip3 docids docinf keep ]+++-- Scoring+-------------+--+-- From Bast and Weber:+--   In practice, only a selection of items from these lists can and will be+--   presented to the user, and it is of course crucial that the most relevant+--   completions and hits are selected.+--+--   A standard approach for this task in ad-hoc retrieval is to have a+--   precomputed score for each term-in-document pair, and when a query is+--   being processed, to aggregate these scores for each candidate document,+--   and return documents with the highest such aggregated scores.+--+--   Both INV and HYB can be easily adapted to implement any such scoring and+--   aggregation scheme: store by each term-in-document pair its precomputed+--   score, and when intersecting, aggregate the scores. A decision has to be+--   made on how to reconcile scores from different completions within the+--   same document. We suggest the following: when merging the intersections+--   (which gives the set D' according to Definition 1), compute for each+--   document in D' the maximal score achieved for some completion in T'+--   contained in that document, and compute for each completion in T' the+--   maximal score achieved for a hit from D' achieved for this completion.+--+-- So firstly let us explore what this means and then discuss why it does not+-- work for BM25.+--+-- The "precomputed score for each term-in-document pair" refers to the bm25+-- score for this term in this document (and obviously doesn't have to be+-- precomputed, though that'd be faster).+--+-- So the score for a document d ∈ D' is:+--   maximum of score for d ∈ D ∩ Dt, for any t ∈ T'+--+-- While the score for a completion t ∈ T' is:+--   maximum of score for d ∈ D ∩ Dt+--+-- So for documents we say their score is their best score for any of the+-- completion terms they contain. While for completions we say their score+-- is their best score for any of the documents they appear in.+--+-- For a scoring function like BM25 this appears to be not a good method, both+-- in principle and in practice. Consider what terms get high BM25 scores:+-- very rare ones. So this means we're going to score highly documents that+-- contain the least frequent terms, and completions that are themselves very+-- rare. This is silly.+--+-- Another important thing to note is that if we use this scoring method then+-- we are using the BM25 score in a way that makes no sense. The BM25 score+-- for different documents for the /same/ set of terms are comparable. The+-- score for the same for different document with different terms are simply+-- not comparable.+--+-- This also makes sense if you consider what question the BM25 score is+-- answering: "what is the likelihood that this document is relevant given that+-- I judge these terms to be relevant". However an auto-suggest query is+-- different: "what is the likelihood that this term is relevant given the+-- importance/popularity of the documents (and any preceding terms I've judged+-- to be relevant)". They are both conditional likelihood questions but with+-- different premises.+--+-- More generally, term frequency information simply isn't useful for+-- auto-suggest queries. We don't want results that have the most obscure terms+-- nor the most common terms, not even something in-between. Term frequency+-- just doesn't tell us anything unless we've already judged terms to be+-- relevant, and in an auto-suggest query we've not done that yet.+--+-- What we really need is information on the importance/popularity of the+-- documents. We can actually do something with that.+--+-- So, instead we follow a different strategy. We require that we have+-- importance/popularity info for the documents.+--+-- A first approximation would be to rank result documents by their importance+-- and completion terms by the sum of the importance of the documents each+-- term appears in.+--+-- Score for a document d ∈ D'+--   importance score for d+--+-- Score for a completion t ∈ T'+--   sum of importance score for d ∈ D ∩ Dt+--+-- The only problem with this is that just because a term appears in an+-- important document, doesn't mean that term is /about/ that document, or to+-- put it another way, that term may not be relevant for that document. For+-- example common words like "the" likely appear in all important documents+-- but this doesn't really tell us anything because "the" isn't an important+-- keyword.+--+-- So what we want to do is to weight the document importance by the relevance+-- of the keyword to the document. So now if we have an important document and+-- a relevant keyword for that document then we get a high score, but an+-- irrelevant term like "the" would get a very low weighting and so would not+-- contribute much to the score, even for very important documents.+--+-- The intuition is that we will score term completions by taking the+-- document importance weighted by the relevance of that term to that document+-- and summing over all the documents where the term occurs.+--+-- We define document importance (for the set D') to be the BM25F score for+-- the documents with any preceding terms. So this includes the non-term+-- feature score for the importance/popularity, and also takes account of+-- preceding terms if there were any.+--+-- We define term relevance (for terms in documents) to be the BM25F score for+-- that term in that document as a fraction of the total BM25F score for all+-- terms in the document. Thus the relevance of all terms in a document sums+-- to 1.+--+-- Now we can re-weight the document importance by the term relevance:+--+-- Score for a completion t ∈ T'+--   sum (for d ∈ D ∩ Dt) of ( importance for d * relevance for t in d )+--+-- And now for document result scores. We don't want to just stick with the+-- innate document importance. We want to re-weight by the completion term+-- scores:+--+-- Score for a document d ∈ D'+--   sum (for t ∈ T' ∩ d) (importance score for d * score for completion t)+--+-- Clear as mud?++type DocImportance = Float+type TermRelevanceBreakdown = Map.Map TermId Float++-- | Precompute the document importance and the term relevance breakdown for+-- all the documents. This will be used in scoring the term completions+-- and the result documents. They will all be used and some used many+-- times so it's better to compute up-front and share.+--+-- This is actually the expensive bit (which is why we've filtered already).+--+cacheDocScoringInfo :: (Ix field, Bounded field, Ix feature, Bounded feature) =>+                       SearchEngine doc key field feature ->+                       [TermId] ->+                       [(DocId, (key, DocTermIds field, DocFeatVals feature))] ->+                       [TermId] ->+                       Map.Map DocId (DocImportance, TermRelevanceBreakdown)+cacheDocScoringInfo se completionTerms allTermDocInfo precedingTerms =+    Map.fromList+      [ (docid, (docImportance, termRelevances))+      | (docid, (_dockey, doctermids, docfeatvals)) <- allTermDocInfo+      , let docImportance  = Query.relevanceScore se precedingTerms+                                                  doctermids docfeatvals+            termRelevances = relevanceBreakdown se doctermids docfeatvals+                                                completionTerms+      ]++-- | Calculate the relevance of each of a given set of terms to the given+-- document.+--+-- We define the \"relevance\" of each term in a document to be its+-- term-in-document score as a fraction of the total of the scores for all+-- terms in the document. Thus the sum of all the relevance values in the+-- document is 1.+--+-- Note: we have to calculate the relevance for all terms in the document+-- but we only keep the relevance value for the terms of interest.+--+relevanceBreakdown :: forall doc key field feature.+                      (Ix field, Bounded field, Ix feature, Bounded feature) =>+                      SearchEngine doc key field feature ->+                      DocTermIds field -> DocFeatVals feature ->+                      [TermId] -> TermRelevanceBreakdown+relevanceBreakdown SearchEngine{ bm25Context } doctermids docfeatvals ts =+    let -- We'll calculate the bm25 score for each term in this document+        bm25Doc     = Query.indexDocToBM25Doc doctermids docfeatvals++        -- Cache the info that depends only on this doc, not the terms+        termScore   :: (TermId -> (field -> Int) -> Float)+        termScore   = BM25F.scoreTermsBulk bm25Context bm25Doc++        -- The DocTermIds has the info we need to do bulk scoring, but it's+        -- a sparse representation, so we first convert it to a dense table+        term        :: Int -> TermId+        count       :: Int -> field -> Int+        (!numTerms, term, count) = DocTermIds.denseTable doctermids++        -- We generate the vector of scores for all terms, based on looking up+        -- the termid and the per-field counts in the dense table+        termScores  :: Vec.Vector Float+        !termScores = Vec.generate numTerms $ \i ->+                       termScore (term i) (\f -> count i f)++        -- We keep only the values for the terms we're interested in+        -- and normalise so we get the relevence fraction+        !scoreSum   = Vec.sum termScores+        !tset       = IntSet.fromList (map fromEnum ts)+     in Map.fromList+      . Vec.toList+      . Vec.map    (\(t,s) -> (t, s/scoreSum))+      . Vec.filter (\(t,_) -> fromEnum t `IntSet.member` tset)+      . Vec.imap   (\i s   -> (term i, s))+      $ termScores+++scoreAutosuggestQueryCompletions :: [(TermId, DocIdSet)]+                                 -> Map.Map DocId (Float, Map.Map TermId Float)+                                 -> [(TermId, Float)]+scoreAutosuggestQueryCompletions completionTermAndDocSets allTermDocInfo =+    [ (t, candidateScore t ds_t)+    | (t, ds_t) <- completionTermAndDocSets ]+  where+    -- The score for a completion is the sum of the importance of the+    -- documents in which that completion occurs, weighted by the relevance+    -- of the term to each document. For example we can have a very+    -- important document and our completion term is highly relevant to it+    -- or we could have a large number of moderately important documents+    -- that our term is quite relevant to. In either example the completion+    -- term would score highly.+    candidateScore :: TermId -> DocIdSet -> Float+    candidateScore t ds_t =+      sum [ docImportance * termRelevance+          | Just (docImportance, termRelevances) <-+               map (`Map.lookup` allTermDocInfo) (DocIdSet.toList ds_t)+          , let termRelevance = termRelevances Map.! t+          ]+++scoreAutosuggestQueryResults :: [(TermId, DocIdSet)] ->+                                Map.Map DocId (Float, Map.Map TermId Float) ->+                                [(TermId, Float)] ->+                                [(DocId, Float)]+scoreAutosuggestQueryResults completionTermAndDocSets allTermDocInfo+                             scoredCandidates =+  Map.toList $ Map.fromListWith (+)+    [ (docid, docImportance * score_t)+    | ((_, ds_t), (_, score_t)) <- zip completionTermAndDocSets scoredCandidates+    , let docids  = DocIdSet.toList ds_t+          docinfo = map (`Map.lookup` allTermDocInfo) docids+    , (docid, Just (docImportance, _)) <- zip docids docinfo+    ]+
+ src/Data/SearchEngine/BM25F.hs view
@@ -0,0 +1,253 @@+{-# LANGUAGE RecordWildCards, BangPatterns, ScopedTypeVariables #-}++-- | An implementation of BM25F ranking. See:+--+-- * A quick overview: <http://en.wikipedia.org/wiki/Okapi_BM25>+--+-- * /The Probabilistic Relevance Framework: BM25 and Beyond/+--   <http://www.staff.city.ac.uk/~sbrp622/papers/foundations_bm25_review.pdf>+--+-- * /An Introduction to Information Retrieval/+--   <http://nlp.stanford.edu/IR-book/pdf/irbookonlinereading.pdf>+--+module Data.SearchEngine.BM25F (+    -- * The ranking function+    score,+    Context(..),+    FeatureFunction(..),+    Doc(..),+    -- ** Specialised variants+    scoreTermsBulk,++    -- * Explaining the score+    Explanation(..),+    explain,+  ) where++import Data.Ix+import Data.Array.Unboxed++data Context term field feature = Context {+         numDocsTotal     :: !Int,+         avgFieldLength   :: field -> Float,+         numDocsWithTerm  :: term -> Int,+         paramK1          :: !Float,+         paramB           :: field -> Float,+         -- consider minimum length to prevent massive B bonus?+         fieldWeight      :: field -> Float,+         featureWeight    :: feature -> Float,+         featureFunction  :: feature -> FeatureFunction+       }++data Doc term field feature = Doc {+         docFieldLength        :: field -> Int,+         docFieldTermFrequency :: field -> term -> Int,+         docFeatureValue       :: feature -> Float+       }+++-- | The BM25F score for a document for a given set of terms.+--+score :: (Ix field, Bounded field, Ix feature, Bounded feature) =>+         Context term field feature ->+         Doc term field feature -> [term] -> Float+score ctx doc terms =+    sum (map (weightedTermScore    ctx doc) terms)+  + sum (map (weightedNonTermScore ctx doc) features)++  where+    features = range (minBound, maxBound)+++weightedTermScore :: (Ix field, Bounded field) =>+                     Context term field feature ->+                     Doc term field feature -> term -> Float+weightedTermScore ctx doc t =+    weightIDF ctx t *     tf'+                     / (k1 + tf')+  where+    tf' = weightedDocTermFrequency ctx doc t+    k1  = paramK1 ctx+++weightIDF :: Context term field feature -> term -> Float+weightIDF ctx t =+    log ((n - n_t + 0.5) / (n_t + 0.5))+  where+    n   = fromIntegral (numDocsTotal ctx)+    n_t = fromIntegral (numDocsWithTerm ctx t)+++weightedDocTermFrequency :: (Ix field, Bounded field) =>+                            Context term field feature ->+                            Doc term field feature -> term -> Float+weightedDocTermFrequency ctx doc t =+    sum [ w_f * tf_f / _B_f+        | field <- range (minBound, maxBound)+        , let w_f  = fieldWeight ctx field+              tf_f = fromIntegral (docFieldTermFrequency doc field t)+              _B_f = lengthNorm ctx doc field+        , not (isNaN _B_f)+        ]+    -- When the avgFieldLength is 0 we have a field which is empty for all+    -- documents. Unfortunately it leads to a NaN because the+    -- docFieldTermFrequency will also be 0 so we get 0/0. What we want to+    -- do in this situation is have that field contribute nothing to the+    -- score. The simplest way to achieve that is to skip if _B_f is NaN.+    -- So I think this is fine and not an ugly hack.++lengthNorm :: Context term field feature ->+              Doc term field feature -> field -> Float+lengthNorm ctx doc field =+    (1-b_f) + b_f * sl_f / avgsl_f+  where+    b_f     = paramB ctx field+    sl_f    = fromIntegral (docFieldLength doc field)+    avgsl_f = avgFieldLength ctx field+++weightedNonTermScore :: (Ix feature, Bounded feature) =>+                        Context term field feature ->+                        Doc term field feature -> feature -> Float+weightedNonTermScore ctx doc feature =+    w_f * _V_f f_f+  where+    w_f  = featureWeight ctx feature+    _V_f = applyFeatureFunction (featureFunction ctx feature)+    f_f  = docFeatureValue doc feature+++data FeatureFunction+   = LogarithmicFunction   Float -- ^ @log (\lambda_i + f_i)@+   | RationalFunction      Float -- ^ @f_i / (\lambda_i + f_i)@+   | SigmoidFunction Float Float -- ^ @1 / (\lambda + exp(-(\lambda' * f_i))@++applyFeatureFunction :: FeatureFunction -> (Float -> Float)+applyFeatureFunction (LogarithmicFunction p1) = \fi -> log (p1 + fi)+applyFeatureFunction (RationalFunction    p1) = \fi -> fi / (p1 + fi)+applyFeatureFunction (SigmoidFunction  p1 p2) = \fi -> 1 / (p1 + exp (-fi * p2))+++-----------------------------+-- Bulk scoring of many terms+--++-- | Most of the time we want to score several different documents for the same+-- set of terms, but sometimes we want to score one document for many terms+-- and in that case we can save a bit of work by doing it in bulk. It lets us+-- calculate once and share things that depend only on the document, and not+-- the term.+--+-- To take advantage of the sharing you must partially apply and name the+-- per-doc score functon, e.g.+--+-- > let score :: term -> (field -> Int) -> Float+-- >     score = BM25.bulkScorer ctx doc+-- >  in sum [ score t (\f -> counts ! (t, f)) | t <- ts ]+--+scoreTermsBulk :: forall field term feature. (Ix field, Bounded field) =>+                  Context term field feature ->+                  Doc term field feature ->+                  (term -> (field -> Int) -> Float)+scoreTermsBulk ctx doc = +    -- This is just a rearrangement of weightedTermScore and+    -- weightedDocTermFrequency above, with the doc-constant bits hoisted out.++    \t tFreq ->+    let !tf' = sum [ w!f * tf_f / _B!f+                   | f <- range (minBound, maxBound)+                   , let tf_f = fromIntegral (tFreq f)+                         _B_f = _B!f+                   , not (isNaN _B_f)+                   ]++     in weightIDF ctx t *     tf'+                         / (k1 + tf')+  where+    -- So long as the caller does the partial application thing then these+    -- values can all be shared between many calls with different terms.++    !k1 = paramK1 ctx+    w, _B :: UArray field Float+    !w  = array (minBound, maxBound)+                [ (field, fieldWeight ctx field)+                | field <- range (minBound, maxBound) ]+    !_B = array (minBound, maxBound)+                [ (field, lengthNorm ctx doc field)+                | field <- range (minBound, maxBound) ]+++------------------+-- Explanation+--++-- | A breakdown of the BM25F score, to explain somewhat how it relates to+-- the inputs, and so you can compare the scores of different documents.+--+data Explanation field feature term = Explanation {+       -- | The overall score is the sum of the 'termScores', 'positionScore'+       -- and 'nonTermScore'+       overallScore  :: Float,++       -- | There is a score contribution from each query term. This is the+       -- score for the term across all fields in the document (but see+       -- 'termFieldScores').+       termScores    :: [(term, Float)],+{-+       -- | There is a score contribution for positional information. Terms+       -- appearing in the document close together give a bonus.+       positionScore :: [(field, Float)],+-}+       -- | The document can have an inate bonus score independent of the terms+       -- in the query. For example this might be a popularity score.+       nonTermScores :: [(feature, Float)],++       -- | This does /not/ contribute to the 'overallScore'. It is an+       -- indication of how the 'termScores' relates to per-field scores.+       -- Note however that the term score for all fields is /not/ simply+       -- sum of the per-field scores. The point of the BM25F scoring function+       -- is that a linear combination of per-field scores is wrong, and BM25F+       -- does a more cunning non-linear combination.+       --+       -- However, it is still useful as an indication to see scores for each+       -- field for a term, to see how the compare.+       --+       termFieldScores :: [(term, [(field, Float)])]+     }+  deriving Show++instance Functor (Explanation field feature) where+  fmap f e@Explanation{..} =+    e {+      termScores      = [ (f t, s)  | (t, s)  <- termScores ],+      termFieldScores = [ (f t, fs) | (t, fs) <- termFieldScores ]+    }++explain :: (Ix field, Bounded field, Ix feature, Bounded feature) =>+           Context term field feature ->+           Doc term field feature -> [term] -> Explanation field feature term+explain ctx doc ts =+    Explanation {..}+  where+    overallScore  = sum (map snd termScores)+--                  + sum (map snd positionScore)+                  + sum (map snd nonTermScores)+    termScores    = [ (t, weightedTermScore ctx doc t) | t <- ts ]+--    positionScore = [ (f, 0) | f <- range (minBound, maxBound) ]+    nonTermScores = [ (feature, weightedNonTermScore ctx doc feature)+                    | feature <- range (minBound, maxBound) ]++    termFieldScores =+      [ (t, fieldScores)+      | t <- ts+      , let fieldScores =+              [ (f, weightedTermScore ctx' doc t)+              | f <- range (minBound, maxBound)+              , let ctx' = ctx { fieldWeight = fieldWeightOnly f }+              ]+      ]+    fieldWeightOnly f f' | sameField f f' = fieldWeight ctx f'+                         | otherwise      = 0++    sameField f f' = index (minBound, maxBound) f+                  == index (minBound, maxBound) f'
+ src/Data/SearchEngine/DocFeatVals.hs view
@@ -0,0 +1,25 @@+{-# LANGUAGE BangPatterns, GeneralizedNewtypeDeriving #-}+module Data.SearchEngine.DocFeatVals (+    DocFeatVals,+    featureValue,+    create,+  ) where++import Data.SearchEngine.DocTermIds (vecIndexIx, vecCreateIx)+import Data.Vector (Vector)+import Data.Ix (Ix)+++-- | Storage for the non-term feature values i a document.+--+newtype DocFeatVals feature = DocFeatVals (Vector Float)+  deriving (Show)++featureValue :: (Ix feature, Bounded feature) => DocFeatVals feature -> feature -> Float+featureValue (DocFeatVals featVec) = vecIndexIx featVec++create :: (Ix feature, Bounded feature) =>+          (feature -> Float) -> DocFeatVals feature+create docFeatVals =+    DocFeatVals (vecCreateIx docFeatVals)+
+ src/Data/SearchEngine/DocIdSet.hs view
@@ -0,0 +1,207 @@+{-# LANGUAGE BangPatterns, GeneralizedNewtypeDeriving, MultiParamTypeClasses,+             TypeFamilies #-}+module Data.SearchEngine.DocIdSet (+    DocId(DocId),+    DocIdSet(..),+    null,+    size,+    empty,+    singleton,+    fromList,+    toList,+    insert,+    delete,+    member,+    union,+    unions,+    intersection,+    invariant,+  ) where++import Data.Word+import qualified Data.Vector.Unboxed         as Vec+import qualified Data.Vector.Unboxed.Mutable as MVec+import qualified Data.Vector.Generic         as GVec+import qualified Data.Vector.Generic.Mutable as GMVec+import Control.Monad.ST+import Control.Monad (liftM)+import qualified Data.Set as Set+import qualified Data.List as List+import Data.Function (on)++import Prelude hiding (null)+++newtype DocId = DocId { unDocId :: Word32 }+  deriving (Eq, Ord, Show, Enum, Bounded)++newtype DocIdSet = DocIdSet (Vec.Vector DocId)+  deriving (Eq, Show)++-- represented as a sorted sequence of ids+invariant :: DocIdSet -> Bool+invariant (DocIdSet vec) =+    strictlyAscending (Vec.toList vec)+  where+    strictlyAscending (a:xs@(b:_)) = a < b && strictlyAscending xs+    strictlyAscending _  = True+++size :: DocIdSet -> Int+size (DocIdSet vec) = Vec.length vec++null :: DocIdSet -> Bool+null (DocIdSet vec) = Vec.null vec++empty :: DocIdSet+empty = DocIdSet Vec.empty++singleton :: DocId -> DocIdSet+singleton = DocIdSet . Vec.singleton++fromList :: [DocId] -> DocIdSet+fromList = DocIdSet . Vec.fromList . Set.toAscList . Set.fromList++toList ::  DocIdSet -> [DocId]+toList (DocIdSet vec) = Vec.toList vec++insert :: DocId -> DocIdSet -> DocIdSet+insert x (DocIdSet vec) =+    case binarySearch vec 0 (Vec.length vec - 1) x of+      (_, True)  -> DocIdSet vec+      (i, False) -> case Vec.splitAt i vec of+                      (before, after) ->+                        DocIdSet (Vec.concat [before, Vec.singleton x, after])++delete :: DocId -> DocIdSet -> DocIdSet+delete x (DocIdSet vec) =+    case binarySearch vec 0 (Vec.length vec - 1) x of+      (_, False) -> DocIdSet vec+      (i, True)  -> case Vec.splitAt i vec of+                      (before, after) ->+                        DocIdSet (before Vec.++ Vec.tail after)++member :: DocId -> DocIdSet -> Bool+member x (DocIdSet vec) = snd (binarySearch vec 0 (Vec.length vec - 1) x)++binarySearch :: Vec.Vector DocId -> Int -> Int -> DocId -> (Int, Bool)+binarySearch vec !a !b !key+  | a > b     = (a, False)+  | otherwise =+    let mid = (a + b) `div` 2+     in case compare key (vec Vec.! mid) of+          LT -> binarySearch vec a (mid-1) key+          EQ -> (mid, True)+          GT -> binarySearch vec (mid+1) b key++unions :: [DocIdSet] -> DocIdSet+unions = List.foldl' union empty+         -- a bit more effecient if we merge small ones first+       . List.sortBy (compare `on` size)++union :: DocIdSet -> DocIdSet -> DocIdSet+union x y | null x = y+          | null y = x+union (DocIdSet xs) (DocIdSet ys) =+    DocIdSet (Vec.create (MVec.new sizeBound >>= writeMergedUnion xs ys))+  where+    sizeBound = Vec.length xs + Vec.length ys++writeMergedUnion :: Vec.Vector DocId -> Vec.Vector DocId ->+                    MVec.MVector s DocId -> ST s (MVec.MVector s DocId)+writeMergedUnion xs0 ys0 !out = do+    i <- go xs0 ys0 0+    return $! MVec.take i out+  where+    go !xs !ys !i+      | Vec.null xs = do Vec.copy (MVec.slice i (Vec.length ys) out) ys+                         return (i + Vec.length ys)+      | Vec.null ys = do Vec.copy (MVec.slice i (Vec.length xs) out) xs+                         return (i + Vec.length xs)+      | otherwise   = let x = Vec.head xs; y = Vec.head ys+                      in case compare x y of+                          GT -> do MVec.write out i y+                                   go           xs  (Vec.tail ys) (i+1)+                          EQ -> do MVec.write out i x+                                   go (Vec.tail xs) (Vec.tail ys) (i+1)+                          LT -> do MVec.write out i x+                                   go (Vec.tail xs)           ys  (i+1)++intersection :: DocIdSet -> DocIdSet -> DocIdSet+intersection x y | null x = empty+                 | null y = empty+intersection (DocIdSet xs) (DocIdSet ys) =+    DocIdSet (Vec.create (MVec.new sizeBound >>= writeMergedIntersection xs ys))+  where+    sizeBound = max (Vec.length xs) (Vec.length ys)++writeMergedIntersection :: Vec.Vector DocId -> Vec.Vector DocId ->+                           MVec.MVector s DocId -> ST s (MVec.MVector s DocId)+writeMergedIntersection xs0 ys0 !out = do+    i <- go xs0 ys0 0+    return $! MVec.take i out+  where+    go !xs !ys !i+      | Vec.null xs = return i+      | Vec.null ys = return i+      | otherwise   = let x = Vec.head xs; y = Vec.head ys+                      in case compare x y of+                          GT ->    go           xs  (Vec.tail ys)  i+                          EQ -> do MVec.write out i x+                                   go (Vec.tail xs) (Vec.tail ys) (i+1)+                          LT ->    go (Vec.tail xs)           ys   i++------------------------------------------------------------------------------+-- verbose Unbox instances+--++instance MVec.Unbox DocId++newtype instance MVec.MVector s DocId = MV_DocId (MVec.MVector s Word32)++instance GMVec.MVector MVec.MVector DocId where+    basicLength          (MV_DocId v) = GMVec.basicLength v+    basicUnsafeSlice i l (MV_DocId v) = MV_DocId (GMVec.basicUnsafeSlice i l v)+    basicUnsafeNew     l              = MV_DocId `liftM` GMVec.basicUnsafeNew l+    basicInitialize      (MV_DocId v) = GMVec.basicInitialize v+    basicUnsafeReplicate l x          = MV_DocId `liftM` GMVec.basicUnsafeReplicate l (unDocId x)+    basicUnsafeRead  (MV_DocId v) i   = DocId `liftM`    GMVec.basicUnsafeRead v i+    basicUnsafeWrite (MV_DocId v) i x = GMVec.basicUnsafeWrite v i (unDocId x)+    basicClear       (MV_DocId v)     = GMVec.basicClear v+    basicSet         (MV_DocId v) x   = GMVec.basicSet v (unDocId x)+    basicUnsafeGrow  (MV_DocId v) l   = MV_DocId `liftM` GMVec.basicUnsafeGrow v l+    basicUnsafeCopy  (MV_DocId v) (MV_DocId v') = GMVec.basicUnsafeCopy v v'+    basicUnsafeMove  (MV_DocId v) (MV_DocId v') = GMVec.basicUnsafeMove v v'+    basicOverlaps    (MV_DocId v) (MV_DocId v') = GMVec.basicOverlaps   v v'+    {-# INLINE basicLength #-}+    {-# INLINE basicUnsafeSlice #-}+    {-# INLINE basicOverlaps #-}+    {-# INLINE basicUnsafeNew #-}+    {-# INLINE basicInitialize #-}+    {-# INLINE basicUnsafeReplicate #-}+    {-# INLINE basicUnsafeRead #-}+    {-# INLINE basicUnsafeWrite #-}+    {-# INLINE basicClear #-}+    {-# INLINE basicSet #-}+    {-# INLINE basicUnsafeCopy #-}+    {-# INLINE basicUnsafeMove #-}+    {-# INLINE basicUnsafeGrow #-}++newtype instance Vec.Vector DocId = V_DocId (Vec.Vector Word32)++instance GVec.Vector Vec.Vector DocId where+    basicUnsafeFreeze (MV_DocId mv)  = V_DocId  `liftM` GVec.basicUnsafeFreeze mv+    basicUnsafeThaw   (V_DocId  v)   = MV_DocId `liftM` GVec.basicUnsafeThaw v+    basicLength       (V_DocId  v)   = GVec.basicLength v+    basicUnsafeSlice i l (V_DocId v) = V_DocId (GVec.basicUnsafeSlice i l v)+    basicUnsafeIndexM (V_DocId  v) i = DocId `liftM` GVec.basicUnsafeIndexM v i+    basicUnsafeCopy   (MV_DocId mv)+                      (V_DocId  v)   = GVec.basicUnsafeCopy mv v+    elemseq           (V_DocId  v) x = GVec.elemseq v (unDocId x)+    {-# INLINE basicUnsafeFreeze #-}+    {-# INLINE basicUnsafeThaw #-}+    {-# INLINE basicLength #-}+    {-# INLINE basicUnsafeSlice #-}+    {-# INLINE basicUnsafeIndexM #-}+    {-# INLINE basicUnsafeCopy #-}+    {-# INLINE elemseq #-}
+ src/Data/SearchEngine/DocTermIds.hs view
@@ -0,0 +1,81 @@+{-# LANGUAGE BangPatterns, GeneralizedNewtypeDeriving #-}+module Data.SearchEngine.DocTermIds (+    DocTermIds,+    TermId,+    fieldLength,+    fieldTermCount,+    fieldElems,+    create,+    denseTable,+    vecIndexIx,+    vecCreateIx,+  ) where++import Data.SearchEngine.TermBag (TermBag, TermId)+import qualified Data.SearchEngine.TermBag as TermBag++import Data.Vector (Vector, (!))+import qualified Data.Vector as Vec+import qualified Data.Vector.Unboxed as UVec+import Data.Ix (Ix)+import qualified Data.Ix as Ix+++-- | The 'TermId's for the 'Term's that occur in a document. Documents may have+-- multiple fields and the 'DocTerms' type holds them separately for each field.+--+newtype DocTermIds field = DocTermIds (Vector TermBag)+  deriving (Show)++getField :: (Ix field, Bounded field) => DocTermIds field -> field -> TermBag+getField (DocTermIds fieldVec) = vecIndexIx fieldVec++create :: (Ix field, Bounded field) =>+          (field -> [TermId]) -> DocTermIds field+create docTermIds =+    DocTermIds (vecCreateIx (TermBag.fromList . docTermIds))++-- | The number of terms in a field within the document.+fieldLength :: (Ix field, Bounded field) => DocTermIds field -> field -> Int+fieldLength docterms field =+    TermBag.size (getField docterms field)++-- | /O(log n)/ The frequency of a particular term in a field within the document.+--+fieldTermCount :: (Ix field, Bounded field) =>+                  DocTermIds field -> field -> TermId -> Int+fieldTermCount docterms field termid =+    fromIntegral (TermBag.termCount (getField docterms field) termid)++fieldElems :: (Ix field, Bounded field) => DocTermIds field -> field -> [TermId]+fieldElems docterms field =+    TermBag.elems (getField docterms field)++-- | The 'DocTermIds' is really a sparse 2d array, and doing lookups with+-- 'fieldTermCount' has a O(log n) cost. This function converts to a dense+-- tabular representation which then enables linear scans.+--+denseTable :: (Ix field, Bounded field) => DocTermIds field ->+              (Int, Int -> TermId, Int -> field -> Int)+denseTable (DocTermIds fieldVec) =+    let (!termids, !termcounts) = TermBag.denseTable (Vec.toList fieldVec)+        !numTerms = UVec.length termids+     in ( numTerms+        , \i    -> termids UVec.! i+        , \i ix -> let j = Ix.index (minBound, maxBound) ix+                    in fromIntegral (termcounts UVec.! (j * numTerms + i))+        )++---------------------------------+-- Vector indexed by Ix Bounded+--++vecIndexIx  :: (Ix ix, Bounded ix) => Vector a -> ix -> a+vecIndexIx vec ix = vec ! Ix.index (minBound, maxBound) ix++vecCreateIx :: (Ix ix, Bounded ix) => (ix -> a) -> Vector a+vecCreateIx f = Vec.fromListN (Ix.rangeSize bounds)+                  [ y | ix <- Ix.range bounds, let !y = f ix ]+  where+    bounds = (minBound, maxBound)+
+ src/Data/SearchEngine/Query.hs view
@@ -0,0 +1,257 @@+{-# LANGUAGE BangPatterns, NamedFieldPuns, RecordWildCards #-}++module Data.SearchEngine.Query (++    -- * Querying+    query,+    ResultsFilter(..),++    -- * Explain mode for query result rankings+    queryExplain,+    BM25F.Explanation(..),+    setRankParams,++    -- ** Utils used by autosuggest+    relevanceScore,+    indexDocToBM25Doc,+    expandTransformedQueryTerm,+  ) where++import Data.SearchEngine.Types+import qualified Data.SearchEngine.SearchIndex as SI+import qualified Data.SearchEngine.DocIdSet    as DocIdSet+import qualified Data.SearchEngine.DocTermIds  as DocTermIds+import qualified Data.SearchEngine.DocFeatVals as DocFeatVals+import qualified Data.SearchEngine.BM25F       as BM25F++import Data.Ix+import Data.List+import Data.Function+import Data.Maybe+++-- | Execute a normal query. Find the documents in which one or more of+-- the search terms appear and return them in ranked order.+--+-- The number of documents returned is limited by the 'paramResultsetSoftLimit'+-- and 'paramResultsetHardLimit' paramaters. This also limits the cost of the+-- query (which is primarily the cost of scoring each document).+--+-- The given terms are all assumed to be complete (as opposed to prefixes+-- like with 'queryAutosuggest').+--+query :: (Ix field, Bounded field, Ix feature, Bounded feature) =>+         SearchEngine doc key field feature ->+         [Term] -> [key]+query se@SearchEngine{ searchIndex,+                       searchRankParams = SearchRankParameters{..} }+      terms =++  let -- Start by transforming/normalising all the query terms.+      -- This can be done differently for each field we search by.+      lookupTerms :: [Term]+      lookupTerms = concatMap (expandTransformedQueryTerm se) terms++      -- Then we look up all the normalised terms in the index.+      rawresults :: [Maybe (TermId, DocIdSet)]+      rawresults = map (SI.lookupTerm searchIndex) lookupTerms++      -- For the terms that occur in the index, this gives us the term's id+      -- and the set of documents that the term occurs in.+      termids   :: [TermId]+      docidsets :: [DocIdSet]+      (termids, docidsets) = unzip (catMaybes rawresults)++      -- We looked up the documents that *any* of the term occur in (not all)+      -- so this could be rather a lot of docs if the user uses a few common+      -- terms. Scoring these result docs is a non-trivial cost so we want to+      -- limit the number that we have to score. The standard trick is to+      -- consider the doc sets in the order of size, smallest to biggest. Once+      -- we have gone over a certain threshold of docs then don't bother with+      -- the doc sets for the remaining terms. This tends to work because the+      -- scoring gives lower weight to terms that occur in many documents.+      unrankedResults :: DocIdSet+      unrankedResults = pruneRelevantResults+                          paramResultsetSoftLimit+                          paramResultsetHardLimit+                          docidsets++      --TODO: technically this isn't quite correct. Because each field can+      -- be normalised differently, we can end up with different termids for+      -- the same original search term, and then we score those as if they+      -- were different terms, which makes a difference when the term appears+      -- in multiple fields (exactly the case BM25F is supposed to deal with).+      -- What we ought to have instead is an Array (Int, field) TermId, and+      -- make the scoring use the appropriate termid for each field, but to+      -- consider them the "same" term.+   in rankResults se termids (DocIdSet.toList unrankedResults)++-- | Before looking up a term in the main index we need to normalise it+-- using the 'transformQueryTerm'. Of course the transform can be different+-- for different fields, so we have to collect all the forms (eliminating+-- duplicates).+--+expandTransformedQueryTerm :: (Ix field, Bounded field) =>+                              SearchEngine doc key field feature ->+                              Term -> [Term]+expandTransformedQueryTerm SearchEngine{searchConfig} term =+    nub [ transformForField field+        | let transformForField = transformQueryTerm searchConfig term+        , field <- range (minBound, maxBound) ]+++rankResults :: (Ix field, Bounded field, Ix feature, Bounded feature) =>+               SearchEngine doc key field feature ->+               [TermId] -> [DocId] -> [key]+rankResults se@SearchEngine{searchIndex} queryTerms docids =+    map snd+  $ sortBy (flip compare `on` fst)+      [ (relevanceScore se queryTerms doctermids docfeatvals, dockey)+      | docid <- docids+      , let (dockey, doctermids, docfeatvals) = SI.lookupDocId searchIndex docid ]++relevanceScore :: (Ix field, Bounded field, Ix feature, Bounded feature) =>+                  SearchEngine doc key field feature ->+                  [TermId] -> DocTermIds field -> DocFeatVals feature -> Float+relevanceScore SearchEngine{bm25Context} queryTerms doctermids docfeatvals =+    BM25F.score bm25Context doc queryTerms+  where+    doc = indexDocToBM25Doc doctermids docfeatvals++indexDocToBM25Doc :: (Ix field, Bounded field, Ix feature, Bounded feature) =>+                     DocTermIds field ->+                     DocFeatVals feature ->+                     BM25F.Doc TermId field feature+indexDocToBM25Doc doctermids docfeatvals =+    BM25F.Doc {+      BM25F.docFieldLength        = DocTermIds.fieldLength    doctermids,+      BM25F.docFieldTermFrequency = DocTermIds.fieldTermCount doctermids,+      BM25F.docFeatureValue       = DocFeatVals.featureValue docfeatvals+    }++pruneRelevantResults :: Int -> Int -> [DocIdSet] -> DocIdSet+pruneRelevantResults softLimit hardLimit =+    -- Look at the docsets starting with the smallest ones. Smaller docsets+    -- correspond to the rarer terms, which are the ones that score most highly.+    go DocIdSet.empty . sortBy (compare `on` DocIdSet.size)+  where+    go !acc [] = acc+    go !acc (d:ds)+        -- If this is the first one, we add it anyway, otherwise we're in+        -- danger of returning no results at all.+      | DocIdSet.null acc = go d ds+        -- We consider the size our docset would be if we add this extra one...+        -- If it puts us over the hard limit then stop.+      | size > hardLimit  = acc+        -- If it puts us over soft limit then we add it and stop+      | size > softLimit  = DocIdSet.union acc d+        -- Otherwise we can add it and carry on to consider the remainder+      | otherwise         = go (DocIdSet.union acc d) ds+      where+        size = DocIdSet.size acc + DocIdSet.size d+++--------------------------------+-- Normal query with explanation+--++queryExplain :: (Ix field, Bounded field, Ix feature, Bounded feature) =>+                SearchEngine doc key field feature ->+                [Term] -> [(BM25F.Explanation field feature Term, key)]+queryExplain se@SearchEngine{ searchIndex,+                              searchConfig     = SearchConfig{transformQueryTerm},+                              searchRankParams = SearchRankParameters{..} }+      terms =++  -- See 'query' above for explanation. Really we ought to combine them.+  let lookupTerms :: [Term]+      lookupTerms = [ term'+                    | term  <- terms+                    , let transformForField = transformQueryTerm term+                    , term' <- nub [ transformForField field+                                   | field <- range (minBound, maxBound) ]+                    ]++      rawresults :: [Maybe (TermId, DocIdSet)]+      rawresults = map (SI.lookupTerm searchIndex) lookupTerms++      termids   :: [TermId]+      docidsets :: [DocIdSet]+      (termids, docidsets) = unzip (catMaybes rawresults)++      unrankedResults :: DocIdSet+      unrankedResults = pruneRelevantResults+                          paramResultsetSoftLimit+                          paramResultsetHardLimit+                          docidsets++   in rankExplainResults se termids (DocIdSet.toList unrankedResults)++rankExplainResults :: (Ix field, Bounded field, Ix feature, Bounded feature) =>+                      SearchEngine doc key field feature ->+                      [TermId] ->+                      [DocId] ->+                      [(BM25F.Explanation field feature Term, key)]+rankExplainResults se@SearchEngine{searchIndex} queryTerms docids =+    sortBy (flip compare `on` (BM25F.overallScore . fst))+      [ (explainRelevanceScore se queryTerms doctermids docfeatvals, dockey)+      | docid <- docids+      , let (dockey, doctermids, docfeatvals) = SI.lookupDocId searchIndex docid ]+++explainRelevanceScore :: (Ix field, Bounded field, Ix feature, Bounded feature) =>+                         SearchEngine doc key field feature ->+                         [TermId] ->+                         DocTermIds field ->+                         DocFeatVals feature ->+                         BM25F.Explanation field feature Term+explainRelevanceScore SearchEngine{bm25Context, searchIndex}+                      queryTerms doctermids docfeatvals =+    fmap (SI.getTerm searchIndex) (BM25F.explain bm25Context doc queryTerms)+  where+    doc = indexDocToBM25Doc doctermids docfeatvals+++setRankParams :: SearchRankParameters field feature ->+                 SearchEngine doc key field feature ->+                 SearchEngine doc key field feature+setRankParams params@SearchRankParameters{..} se =+    se {+      searchRankParams = params,+      bm25Context      = (bm25Context se) {+        BM25F.paramK1         = paramK1,+        BM25F.paramB          = paramB,+        BM25F.fieldWeight     = paramFieldWeights,+        BM25F.featureWeight   = paramFeatureWeights,+        BM25F.featureFunction = paramFeatureFunctions+      }+    }+++--------------------------------+-- Results filter+--++-- | In some applications it is necessary to enforce some security or+-- visibility rule about the query results (e.g. in a typical DB-based+-- application different users can see different data items). Typically+-- it would be too expensive to build different search indexes for the+-- different contexts and so the strategy is to use one index containing+-- everything and filter for visibility in the results. This means the+-- filter condition is different for different queries (e.g. performed+-- on behalf of different users).+--+-- Filtering the results after a query is possible but not the most efficient+-- thing to do because we've had to score all the not-visible documents.+-- The better thing to do is to filter as part of the query, this way we can+-- filter before the expensive scoring.+--+-- We provide one further optimisation: bulk predicates. In some applications+-- it can be quicker to check the security\/visibility of a whole bunch of+-- results all in one go.+--+data ResultsFilter key = NoFilter+                       | FilterPredicate     (key -> Bool)+                       | FilterBulkPredicate ([key] -> [Bool])+--TODO: allow filtering & non-feature score lookup in one bulk op+
+ src/Data/SearchEngine/SearchIndex.hs view
@@ -0,0 +1,456 @@+{-# LANGUAGE BangPatterns, NamedFieldPuns #-}++module Data.SearchEngine.SearchIndex (+    SearchIndex,+    Term,+    TermId,+    DocId,++    emptySearchIndex,+    insertDoc,+    deleteDoc,++    docCount,+    lookupTerm,+    lookupTermsByPrefix,+    lookupTermId,+    lookupDocId,+    lookupDocKey,+    lookupDocKeyDocId,++    getTerm,+    getDocKey,++    invariant,+  ) where++import Data.SearchEngine.DocIdSet (DocIdSet, DocId)+import qualified Data.SearchEngine.DocIdSet as DocIdSet+import Data.SearchEngine.DocTermIds (DocTermIds, TermId, vecIndexIx, vecCreateIx)+import qualified Data.SearchEngine.DocTermIds as DocTermIds+import Data.SearchEngine.DocFeatVals (DocFeatVals)+import qualified Data.SearchEngine.DocFeatVals as DocFeatVals++import Data.Ix (Ix)+import qualified Data.Ix as Ix+import Data.Map (Map)+import qualified Data.Map as Map+import Data.IntMap (IntMap)+import qualified Data.IntMap as IntMap+import qualified Data.Set as Set+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.List as List++import Control.Exception (assert)++-- | Terms are short strings, usually whole words.+--+type Term = Text++-- | The search index is essentially a many-to-many mapping between documents+-- and terms. Each document contains many terms and each term occurs in many+-- documents. It is a bidirectional mapping as we need to support lookups in+-- both directions.+--+-- Documents are identified by a key (in Ord) while terms are text values.+-- Inside the index however we assign compact numeric ids to both documents and+-- terms. The advantage of this is a much more compact in-memory representation+-- and the disadvantage is greater complexity. In particular it means we have+-- to manage bidirectional mappings between document keys and ids, and between+-- terms and term ids.+--+-- So the mappings we maintain can be depicted as:+--+-- >  Term   <-- 1:1 -->   TermId+-- >          \              ^+-- >           \             |+-- >           1:many    many:many+-- >                \        |+-- >                 \->     v+-- > DocKey  <-- 1:1 -->   DocId+--+-- For efficiency, these details are exposed in the interface. In particular+-- the mapping from TermId to many DocIds is exposed via a 'DocIdSet',+-- and the mapping from DocIds to TermIds is exposed via 'DocTermIds'.+--+-- The main reason we need to keep the DocId -> TermId is to allow for+-- efficient incremental updates.+--+data SearchIndex key field feature = SearchIndex {+       -- the indexes+       termMap           :: !(Map Term TermInfo),+       termIdMap         :: !(IntMap TermIdInfo),+       docIdMap          :: !(IntMap (DocInfo key field feature)),+       docKeyMap         :: !(Map key DocId),++       -- auto-increment key counters+       nextTermId        :: TermId,+       nextDocId         :: DocId+     }+  deriving Show++data TermInfo = TermInfo !TermId !DocIdSet+  deriving Show++data TermIdInfo = TermIdInfo !Term !DocIdSet+  deriving (Show, Eq)++data DocInfo key field feature = DocInfo !key !(DocTermIds field)+                                              !(DocFeatVals feature)+  deriving Show+++-----------------------+-- SearchIndex basics+--++emptySearchIndex :: SearchIndex key field feature+emptySearchIndex =+    SearchIndex+      Map.empty+      IntMap.empty+      IntMap.empty+      Map.empty+      minBound+      minBound++checkInvariant :: (Ord key, Ix field, Bounded field) =>+                  SearchIndex key field feature -> SearchIndex key field feature+checkInvariant si = assert (invariant si) si++invariant :: (Ord key, Ix field, Bounded field) =>+             SearchIndex key field feature -> Bool+invariant SearchIndex{termMap, termIdMap, docKeyMap, docIdMap} =+      and [ IntMap.lookup (fromEnum termId) termIdMap+            == Just (TermIdInfo term docidset)+          | (term, (TermInfo termId docidset)) <- Map.assocs termMap ]+  &&  and [ case Map.lookup term termMap of+              Just (TermInfo termId' docidset') -> toEnum termId == termId'+                                                   && docidset == docidset'+              Nothing                           -> False+          | (termId, (TermIdInfo term docidset)) <- IntMap.assocs termIdMap ]+  &&  and [ case IntMap.lookup (fromEnum docId) docIdMap of+              Just (DocInfo docKey' _ _) -> docKey == docKey'+              Nothing                  -> False+          | (docKey, docId) <- Map.assocs docKeyMap ]+  &&  and [ Map.lookup docKey docKeyMap == Just (toEnum docId)+          | (docId, DocInfo docKey _ _) <- IntMap.assocs docIdMap ]+  &&  and [ DocIdSet.invariant docIdSet+          | (_term, (TermInfo _ docIdSet)) <- Map.assocs termMap ]+  &&  and [ any (\field -> DocTermIds.fieldTermCount docterms field termId > 0) fields+          | (_term, (TermInfo termId docIdSet)) <- Map.assocs termMap+          , docId <- DocIdSet.toList docIdSet+          , let DocInfo _ docterms _ = docIdMap IntMap.! fromEnum docId ]+  &&  and [ IntMap.member (fromEnum termid) termIdMap+          | (_docId, DocInfo _ docTerms _) <- IntMap.assocs docIdMap+          , field <- fields+          , termid <- DocTermIds.fieldElems docTerms field ]+  where+    fields = Ix.range (minBound, maxBound)+++-------------------+-- Lookups+--++docCount :: SearchIndex key field feature -> Int+docCount SearchIndex{docIdMap} = IntMap.size docIdMap++lookupTerm :: SearchIndex key field feature -> Term -> Maybe (TermId, DocIdSet)+lookupTerm SearchIndex{termMap} term =+    case Map.lookup term termMap of+      Nothing                         -> Nothing+      Just (TermInfo termid docidset) -> Just (termid, docidset)++lookupTermsByPrefix :: SearchIndex key field feature ->+                       Term -> [(TermId, DocIdSet)]+lookupTermsByPrefix SearchIndex{termMap} term =+    [ (termid, docidset)+    | (TermInfo termid docidset) <- lookupPrefix term termMap ]++lookupTermId :: SearchIndex key field feature -> TermId -> DocIdSet+lookupTermId SearchIndex{termIdMap} termid =+    case IntMap.lookup (fromEnum termid) termIdMap of+      Nothing -> error $ "lookupTermId: not found " ++ show termid+      Just (TermIdInfo _ docidset) -> docidset++lookupDocId :: SearchIndex key field feature ->+               DocId -> (key, DocTermIds field, DocFeatVals feature)+lookupDocId SearchIndex{docIdMap} docid =+    case IntMap.lookup (fromEnum docid) docIdMap of+      Nothing                                   -> errNotFound+      Just (DocInfo key doctermids docfeatvals) -> (key, doctermids, docfeatvals)+  where+    errNotFound = error $ "lookupDocId: not found " ++ show docid++lookupDocKey :: Ord key => SearchIndex key field feature ->+                key -> Maybe (DocTermIds field)+lookupDocKey SearchIndex{docKeyMap, docIdMap} key = do+    case Map.lookup key docKeyMap of+      Nothing    -> Nothing+      Just docid ->+        case IntMap.lookup (fromEnum docid) docIdMap of+          Nothing                          -> error "lookupDocKey: internal error"+          Just (DocInfo _key doctermids _) -> Just doctermids++lookupDocKeyDocId :: Ord key => SearchIndex key field feature -> key -> Maybe DocId+lookupDocKeyDocId SearchIndex{docKeyMap} key = Map.lookup key docKeyMap+++getTerm :: SearchIndex key field feature -> TermId -> Term+getTerm SearchIndex{termIdMap} termId =+    case termIdMap IntMap.! fromEnum termId of TermIdInfo term _ -> term++getTermId :: SearchIndex key field feature -> Term -> TermId+getTermId SearchIndex{termMap} term =+    case termMap Map.! term of TermInfo termid _ -> termid++getDocKey :: SearchIndex key field feature -> DocId -> key+getDocKey SearchIndex{docIdMap} docid =+    case docIdMap IntMap.! fromEnum docid of+      DocInfo dockey _ _ -> dockey++getDocTermIds :: SearchIndex key field feature -> DocId -> DocTermIds field+getDocTermIds SearchIndex{docIdMap} docid =+    case docIdMap IntMap.! fromEnum docid of+      DocInfo _ doctermids _ -> doctermids++--------------------+-- Insert & delete+--++-- Procedure for adding a new doc...+-- (key, field -> [Term])+-- alloc docid for key+-- add term occurences for docid (include rev map for termid)+-- construct indexdoc now that we have all the term -> termid entries+-- insert indexdoc++-- Procedure for updating a doc...+-- (key, field -> [Term])+-- find docid for key+-- lookup old terms for docid (using termid rev map)+-- calc term occurrences to add, term occurrences to delete+-- add new term occurrences, delete old term occurrences+-- construct indexdoc now that we have all the term -> termid entries+-- insert indexdoc++-- Procedure for deleting a doc...+-- (key, field -> [Term])+-- find docid for key+-- lookup old terms for docid (using termid rev map)+-- delete old term occurrences+-- delete indexdoc++-- | This is the representation for documents to be added to the index.+-- Documents may +--+type DocTerms         field   = field   -> [Term]+type DocFeatureValues feature = feature -> Float++insertDoc :: (Ord key, Ix field, Bounded field, Ix feature, Bounded feature) =>+              key -> DocTerms field -> DocFeatureValues feature ->+              SearchIndex key field feature -> SearchIndex key field feature+insertDoc key userDocTerms userDocFeats si@SearchIndex{docKeyMap}+  | Just docid <- Map.lookup key docKeyMap+  = -- Some older version of the doc is already present in the index,+    -- So we keep its docid. Now have to update the doc itself+    -- and update the terms by removing old ones and adding new ones.+    let oldTermsIds   = getDocTermIds si docid+        userDocTerms' = memoiseDocTerms userDocTerms+        newTerms      = docTermSet userDocTerms'+        oldTerms      = docTermIdsTermSet si oldTermsIds+        -- We optimise for the typical case of significant overlap between+        -- the terms in the old and new versions of the document.+        delTerms      = oldTerms `Set.difference` newTerms+        addTerms      = newTerms `Set.difference` oldTerms++     -- Note: adding the doc relies on all the terms being in the termMap+     -- already, so we first add all the term occurences for the docid.+     in checkInvariant+      . insertDocIdToDocEntry docid key userDocTerms' userDocFeats+      . insertTermToDocIdEntries (Set.toList addTerms) docid+      . deleteTermToDocIdEntries (Set.toList delTerms) docid+      $ si++  | otherwise+  = -- We're dealing with a new doc, so allocate a docid for the key+    let (si', docid)  = allocFreshDocId si+        userDocTerms' = memoiseDocTerms userDocTerms+        addTerms      = docTermSet userDocTerms'++     -- Note: adding the doc relies on all the terms being in the termMap+     -- already, so we first add all the term occurences for the docid.+     in checkInvariant+      . insertDocIdToDocEntry docid key userDocTerms' userDocFeats+      . insertDocKeyToIdEntry key docid+      . insertTermToDocIdEntries (Set.toList addTerms) docid+      $ si'++deleteDoc :: (Ord key, Ix field, Bounded field) =>+             key ->+             SearchIndex key field feature -> SearchIndex key field feature+deleteDoc key si@SearchIndex{docKeyMap}+  | Just docid <- Map.lookup key docKeyMap+  = let oldTermsIds = getDocTermIds si docid+        oldTerms    = docTermIdsTermSet si oldTermsIds+     in checkInvariant+      . deleteDocEntry docid key+      . deleteTermToDocIdEntries (Set.toList oldTerms) docid+      $ si+  +  | otherwise = si+++----------------------------------+-- Insert & delete support utils+--+++memoiseDocTerms :: (Ix field, Bounded field) => DocTerms field -> DocTerms field+memoiseDocTerms docTermsFn =+    \field -> vecIndexIx vec field+  where+    vec = vecCreateIx docTermsFn++docTermSet :: (Bounded t, Ix t) => DocTerms t -> Set.Set Term+docTermSet docterms =+    Set.unions [ Set.fromList (docterms field)+               | field <- Ix.range (minBound, maxBound) ]++docTermIdsTermSet :: (Bounded field, Ix field) =>+                     SearchIndex key field feature ->+                     DocTermIds field -> Set.Set Term+docTermIdsTermSet si doctermids =+    Set.unions [ Set.fromList terms+               | field <- Ix.range (minBound, maxBound)+               , let termids = DocTermIds.fieldElems doctermids field+                     terms   = map (getTerm si) termids ]++--+-- The Term <-> DocId mapping+--++-- | Add an entry into the 'Term' to 'DocId' mapping.+insertTermToDocIdEntry :: Term -> DocId ->+                          SearchIndex key field feature ->+                          SearchIndex key field feature+insertTermToDocIdEntry term !docid si@SearchIndex{termMap, termIdMap, nextTermId} =+    case Map.lookup term termMap of+      Nothing ->+        let docIdSet'    = DocIdSet.singleton docid+            !termInfo'   = TermInfo nextTermId docIdSet'+            !termIdInfo' = TermIdInfo term     docIdSet'+         in si { termMap    = Map.insert term termInfo' termMap+               , termIdMap  = IntMap.insert (fromEnum nextTermId)+                                            termIdInfo' termIdMap+               , nextTermId = succ nextTermId }++      Just (TermInfo termId docIdSet) ->+        let docIdSet'    = DocIdSet.insert docid docIdSet+            !termInfo'   = TermInfo termId docIdSet'+            !termIdInfo' = TermIdInfo term docIdSet'+         in si { termMap   = Map.insert term termInfo' termMap+               , termIdMap = IntMap.insert (fromEnum termId)+                                           termIdInfo' termIdMap+               }++-- | Add multiple entries into the 'Term' to 'DocId' mapping: many terms that+-- map to the same document.+insertTermToDocIdEntries :: [Term] -> DocId ->+                            SearchIndex key field feature ->+                            SearchIndex key field feature+insertTermToDocIdEntries terms !docid si =+    List.foldl' (\si' term -> insertTermToDocIdEntry term docid si') si terms++-- | Delete an entry from the 'Term' to 'DocId' mapping.+deleteTermToDocIdEntry :: Term -> DocId ->+                          SearchIndex key field feature ->+                          SearchIndex key field feature+deleteTermToDocIdEntry term !docid si@SearchIndex{termMap, termIdMap} =+    case  Map.lookup term termMap of+      Nothing -> si+      Just (TermInfo termId docIdSet) ->+        let docIdSet'    = DocIdSet.delete docid docIdSet+            !termInfo'   = TermInfo termId docIdSet'+            !termIdInfo' = TermIdInfo term docIdSet'+        in if DocIdSet.null docIdSet'+            then si { termMap = Map.delete term termMap+                    , termIdMap = IntMap.delete (fromEnum termId) termIdMap }+            else si { termMap   = Map.insert term termInfo' termMap+                    , termIdMap = IntMap.insert (fromEnum termId)+                                                termIdInfo' termIdMap+                    }++-- | Delete multiple entries from the 'Term' to 'DocId' mapping: many terms+-- that map to the same document.+deleteTermToDocIdEntries :: [Term] -> DocId ->+                            SearchIndex key field feature ->+                            SearchIndex key field feature+deleteTermToDocIdEntries terms !docid si =+    List.foldl' (\si' term -> deleteTermToDocIdEntry term docid si') si terms++--+-- The DocId <-> Doc mapping+--++allocFreshDocId :: SearchIndex key field feature ->+                  (SearchIndex key field feature, DocId)+allocFreshDocId si@SearchIndex{nextDocId} =+    let !si' = si { nextDocId = succ nextDocId }+     in (si', nextDocId)++insertDocKeyToIdEntry :: Ord key => key -> DocId ->+                         SearchIndex key field feature ->+                         SearchIndex key field feature+insertDocKeyToIdEntry dockey !docid si@SearchIndex{docKeyMap} =+    si { docKeyMap = Map.insert dockey docid docKeyMap }++insertDocIdToDocEntry :: (Ix field, Bounded field,+                          Ix feature, Bounded feature) =>+                         DocId -> key ->+                         DocTerms field ->+                         DocFeatureValues feature ->+                         SearchIndex key field feature ->+                         SearchIndex key field feature+insertDocIdToDocEntry !docid dockey userdocterms userdocfeats+                       si@SearchIndex{docIdMap} =+    let doctermids = DocTermIds.create (map (getTermId si) . userdocterms)+        docfeatvals= DocFeatVals.create userdocfeats+        !docinfo   = DocInfo dockey doctermids docfeatvals+     in si { docIdMap  = IntMap.insert (fromEnum docid) docinfo docIdMap }++deleteDocEntry :: Ord key => DocId -> key ->+                  SearchIndex key field feature -> SearchIndex key field feature+deleteDocEntry docid key si@SearchIndex{docIdMap, docKeyMap} =+     si { docIdMap  = IntMap.delete (fromEnum docid) docIdMap+        , docKeyMap = Map.delete key docKeyMap }++--+-- Data.Map utils+--++-- Data.Map does not support prefix lookups directly (unlike a trie)+-- but we can implement it reasonably efficiently using split:++-- | Lookup values for a range of keys (inclusive lower bound and exclusive+-- upper bound)+--+lookupRange :: Ord k => (k, k) -> Map k v -> [v]+lookupRange (lb, ub) m =+  let (_, mv, gt)  = Map.splitLookup lb m+      (between, _) = Map.split       ub gt+   in case mv of+        Just v  -> v : Map.elems between+        Nothing ->     Map.elems between++lookupPrefix :: Text -> Map Text v -> [v]+lookupPrefix t _ | T.null t = []+lookupPrefix t m = lookupRange (t, prefixUpperBound t) m++prefixUpperBound :: Text -> Text+prefixUpperBound = succLast . T.dropWhileEnd (== maxBound)+  where+    succLast t = T.init t `T.snoc` succ (T.last t)+
+ src/Data/SearchEngine/TermBag.hs view
@@ -0,0 +1,268 @@+{-# LANGUAGE BangPatterns, GeneralizedNewtypeDeriving, MultiParamTypeClasses,+             TypeFamilies #-}+{-# LANGUAGE CPP #-}+#if __GLASGOW_HASKELL__ >= 908+{-# OPTIONS_GHC -Wno-x-partial #-}+#endif++module Data.SearchEngine.TermBag (+    TermId(TermId), TermCount,+    TermBag,+    size,+    fromList,+    toList,+    elems,+    termCount,+    denseTable,+    invariant+  ) where++import qualified Data.Vector.Unboxed         as Vec+import qualified Data.Vector.Unboxed.Mutable as MVec+import qualified Data.Vector.Generic         as GVec+import qualified Data.Vector.Generic.Mutable as GMVec+import Control.Monad.ST+import Control.Monad (liftM)+import qualified Data.Map as Map+import Data.Word (Word32, Word8)+import Data.Bits+import qualified Data.List as List+import Data.Function (on)++newtype TermId = TermId { unTermId :: Word32 }+  deriving (Eq, Ord, Show, Enum)++instance Bounded TermId where+  minBound = TermId 0+  maxBound = TermId 0x00FFFFFF++data TermBag = TermBag !Int !(Vec.Vector TermIdAndCount)+  deriving Show++-- We sneakily stuff both the TermId and the bag count into one 32bit word+type TermIdAndCount = Word32+type TermCount      = Word8++-- Bottom 24 bits is the TermId, top 8 bits is the bag count+termIdAndCount :: TermId -> Int -> TermIdAndCount+termIdAndCount (TermId termid) freq =+      (min (fromIntegral freq) 255 `shiftL` 24)+  .|. (termid .&. 0x00FFFFFF)++getTermId :: TermIdAndCount -> TermId+getTermId word = TermId (word .&. 0x00FFFFFF)++getTermCount :: TermIdAndCount -> TermCount+getTermCount word = fromIntegral (word `shiftR` 24)++invariant :: TermBag -> Bool+invariant (TermBag _ vec) =+    strictlyAscending (Vec.toList vec)+  where+    strictlyAscending (a:xs@(b:_)) = getTermId a < getTermId b+                                  && strictlyAscending xs+    strictlyAscending _  = True++size :: TermBag -> Int+size (TermBag sz _) = sz++elems :: TermBag -> [TermId]+elems (TermBag _ vec) = map getTermId (Vec.toList vec)++toList :: TermBag -> [(TermId, TermCount)]+toList (TermBag _ vec) = [ (getTermId x, getTermCount x)+                         | x <- Vec.toList vec ]++termCount :: TermBag -> TermId -> TermCount+termCount (TermBag _ vec) =+    binarySearch 0 (Vec.length vec - 1)+  where+    binarySearch :: Int -> Int -> TermId -> TermCount+    binarySearch !a !b !key+      | a > b     = 0+      | otherwise =+        let mid         = (a + b) `div` 2+            tidAndCount = vec Vec.! mid+         in case compare key (getTermId tidAndCount) of+              LT -> binarySearch a (mid-1) key+              EQ -> getTermCount tidAndCount+              GT -> binarySearch (mid+1) b key++fromList :: [TermId] -> TermBag+fromList termids =+    let bag = Map.fromListWith (+) [ (t, 1) | t <- termids ]+        sz  = Map.foldl' (+) 0 bag+        vec = Vec.fromListN (Map.size bag)+                            [ termIdAndCount termid freq+                            | (termid, freq) <- Map.toAscList bag ]+     in TermBag sz vec++-- | Given a bunch of term bags, merge them into a table for easier subsequent+-- processing. This is bascially a sparse to dense conversion. Missing entries+-- are filled in with 0. We represent the table as one vector for the+-- term ids and a 2d array for the counts.+--+-- Unfortunately vector does not directly support 2d arrays and array does+-- not make it easy to trim arrays.+--+denseTable :: [TermBag] -> (Vec.Vector TermId, Vec.Vector TermCount)+denseTable termbags =+    (tids, tcts)+  where+    -- First merge the TermIds into one array+    -- then make a linear pass to create the counts array+    -- filling in 0s or the counts as we find them+    !numBags   = length termbags+    !tids      = unionsTermId termbags+    !numTerms  = Vec.length tids+    !numCounts = numTerms * numBags+    !tcts      = Vec.create (do+                   out <- MVec.new numCounts+                   sequence_+                     [ writeMergedTermCounts tids bag out i+                     | (n, TermBag _ bag) <- zip [0..] termbags+                     , let i = n * numTerms ]+                   return out+                 )++writeMergedTermCounts :: Vec.Vector TermId -> Vec.Vector TermIdAndCount ->+                         MVec.MVector s TermCount -> Int -> ST s ()+writeMergedTermCounts xs0 ys0 !out i0 =+    -- assume xs & ys are sorted, and ys contains a subset of xs+    go xs0 ys0 i0+  where+    go !xs !ys !i+      | Vec.null ys = MVec.set (MVec.slice i (Vec.length xs) out) 0+      | Vec.null xs = return ()+      | otherwise   = let x   = Vec.head xs+                          ytc = Vec.head ys+                          y   = getTermId ytc+                          c   = getTermCount ytc+                      in case x == y of+                           True  -> do MVec.write out i c+                                       go (Vec.tail xs) (Vec.tail ys) (i+1)+                           False -> do MVec.write out i 0+                                       go (Vec.tail xs)           ys  (i+1)++-- | Given a set of term bags, form the set of TermIds+--+unionsTermId :: [TermBag] -> Vec.Vector TermId+unionsTermId tbs =+    case List.sortBy (compare `on` bagVecLength) tbs of+      []             -> Vec.empty+      [TermBag _ xs] -> (Vec.map getTermId xs)+      (x0:x1:xs)     -> List.foldl' union3 (union2 x0 x1) xs+  where+    bagVecLength (TermBag _ vec) = Vec.length vec++union2 :: TermBag -> TermBag -> Vec.Vector TermId+union2 (TermBag _ xs) (TermBag _ ys) =+    Vec.create (MVec.new sizeBound >>= writeMergedUnion2 xs ys)+  where+    sizeBound = Vec.length xs + Vec.length ys++writeMergedUnion2 :: Vec.Vector TermIdAndCount -> Vec.Vector TermIdAndCount ->+                     MVec.MVector s TermId -> ST s (MVec.MVector s TermId)+writeMergedUnion2 xs0 ys0 !out = do+    i <- go xs0 ys0 0+    return $! MVec.take i out+  where+    go !xs !ys !i+      | Vec.null xs = do Vec.copy (MVec.slice i (Vec.length ys) out)+                                  (Vec.map getTermId ys)+                         return (i + Vec.length ys)+      | Vec.null ys = do Vec.copy (MVec.slice i (Vec.length xs) out)+                                  (Vec.map getTermId xs)+                         return (i + Vec.length xs)+      | otherwise   = let x = getTermId (Vec.head xs)+                          y = getTermId (Vec.head ys)+                      in case compare x y of+                          GT -> do MVec.write out i y+                                   go           xs  (Vec.tail ys) (i+1)+                          EQ -> do MVec.write out i x+                                   go (Vec.tail xs) (Vec.tail ys) (i+1)+                          LT -> do MVec.write out i x+                                   go (Vec.tail xs)           ys  (i+1)++union3 :: Vec.Vector TermId -> TermBag -> Vec.Vector TermId+union3 xs (TermBag _ ys) =+    Vec.create (MVec.new sizeBound >>= writeMergedUnion3 xs ys)+  where+    sizeBound = Vec.length xs + Vec.length ys++writeMergedUnion3 :: Vec.Vector TermId -> Vec.Vector TermIdAndCount ->+                     MVec.MVector s TermId -> ST s (MVec.MVector s TermId)+writeMergedUnion3 xs0 ys0 !out = do+    i <- go xs0 ys0 0+    return $! MVec.take i out+  where+    go !xs !ys !i+      | Vec.null xs = do Vec.copy (MVec.slice i (Vec.length ys) out)+                                  (Vec.map getTermId ys)+                         return (i + Vec.length ys)+      | Vec.null ys = do Vec.copy (MVec.slice i (Vec.length xs) out) xs+                         return (i + Vec.length xs)+      | otherwise   = let x =            Vec.head xs+                          y = getTermId (Vec.head ys)+                      in case compare x y of+                          GT -> do MVec.write out i y+                                   go           xs  (Vec.tail ys) (i+1)+                          EQ -> do MVec.write out i x+                                   go (Vec.tail xs) (Vec.tail ys) (i+1)+                          LT -> do MVec.write out i x+                                   go (Vec.tail xs)           ys  (i+1)++------------------------------------------------------------------------------+-- verbose Unbox instances+--++instance MVec.Unbox TermId++newtype instance MVec.MVector s TermId = MV_TermId (MVec.MVector s Word32)++instance GMVec.MVector MVec.MVector TermId where+    basicLength          (MV_TermId v) = GMVec.basicLength v+    basicUnsafeSlice i l (MV_TermId v) = MV_TermId (GMVec.basicUnsafeSlice i l v)+    basicUnsafeNew     l               = MV_TermId `liftM` GMVec.basicUnsafeNew l+    basicInitialize      (MV_TermId v) = GMVec.basicInitialize v+    basicUnsafeReplicate l x           = MV_TermId `liftM` GMVec.basicUnsafeReplicate l (unTermId x)+    basicUnsafeRead  (MV_TermId v) i   = TermId `liftM`    GMVec.basicUnsafeRead v i+    basicUnsafeWrite (MV_TermId v) i x = GMVec.basicUnsafeWrite v i (unTermId x)+    basicClear       (MV_TermId v)     = GMVec.basicClear v+    basicSet         (MV_TermId v) x   = GMVec.basicSet v (unTermId x)+    basicUnsafeGrow  (MV_TermId v) l   = MV_TermId `liftM` GMVec.basicUnsafeGrow v l+    basicUnsafeCopy  (MV_TermId v) (MV_TermId v') = GMVec.basicUnsafeCopy v v'+    basicUnsafeMove  (MV_TermId v) (MV_TermId v') = GMVec.basicUnsafeMove v v'+    basicOverlaps    (MV_TermId v) (MV_TermId v') = GMVec.basicOverlaps   v v'+    {-# INLINE basicLength #-}+    {-# INLINE basicUnsafeSlice #-}+    {-# INLINE basicOverlaps #-}+    {-# INLINE basicUnsafeNew #-}+    {-# INLINE basicInitialize #-}+    {-# INLINE basicUnsafeReplicate #-}+    {-# INLINE basicUnsafeRead #-}+    {-# INLINE basicUnsafeWrite #-}+    {-# INLINE basicClear #-}+    {-# INLINE basicSet #-}+    {-# INLINE basicUnsafeCopy #-}+    {-# INLINE basicUnsafeMove #-}+    {-# INLINE basicUnsafeGrow #-}++newtype instance Vec.Vector TermId = V_TermId (Vec.Vector Word32)++instance GVec.Vector Vec.Vector TermId where+    basicUnsafeFreeze (MV_TermId mv)  = V_TermId  `liftM` GVec.basicUnsafeFreeze mv+    basicUnsafeThaw   (V_TermId  v)   = MV_TermId `liftM` GVec.basicUnsafeThaw v+    basicLength       (V_TermId  v)   = GVec.basicLength v+    basicUnsafeSlice i l (V_TermId v) = V_TermId (GVec.basicUnsafeSlice i l v)+    basicUnsafeIndexM (V_TermId  v) i = TermId `liftM` GVec.basicUnsafeIndexM v i+    basicUnsafeCopy   (MV_TermId mv)+                      (V_TermId  v)   = GVec.basicUnsafeCopy mv v+    elemseq           (V_TermId  v) x = GVec.elemseq v (unTermId x)+    {-# INLINE basicUnsafeFreeze #-}+    {-# INLINE basicUnsafeThaw #-}+    {-# INLINE basicLength #-}+    {-# INLINE basicUnsafeSlice #-}+    {-# INLINE basicUnsafeIndexM #-}+    {-# INLINE basicUnsafeCopy #-}+    {-# INLINE elemseq #-}
+ src/Data/SearchEngine/Types.hs view
@@ -0,0 +1,124 @@+{-# LANGUAGE NamedFieldPuns, RecordWildCards #-}++module Data.SearchEngine.Types (+    -- * Search engine types and helper functions+    SearchEngine(..),+    SearchConfig(..),+    SearchRankParameters(..),+    BM25F.FeatureFunction(..),+    initSearchEngine,+    cacheBM25Context,++    -- ** Helper type for non-term features+    NoFeatures,+    noFeatures,++    -- * Re-export SearchIndex and other types+    SearchIndex, Term, TermId,+    DocIdSet, DocId,+    DocTermIds, DocFeatVals,++    -- * Internal sanity check+    invariant,+  ) where++import Data.SearchEngine.SearchIndex (SearchIndex, Term, TermId)+import qualified Data.SearchEngine.SearchIndex as SI+import Data.SearchEngine.DocIdSet (DocIdSet, DocId)+import qualified Data.SearchEngine.DocIdSet as DocIdSet+import Data.SearchEngine.DocFeatVals (DocFeatVals)+import Data.SearchEngine.DocTermIds  (DocTermIds)+import qualified Data.SearchEngine.BM25F as BM25F++import Data.Ix+import Data.Array.Unboxed++++data SearchConfig doc key field feature = SearchConfig {+       documentKey          :: doc -> key,+       extractDocumentTerms :: doc -> field -> [Term],+       transformQueryTerm   :: Term -> field -> Term,+       documentFeatureValue :: doc -> feature -> Float+     }++data SearchRankParameters field feature = SearchRankParameters {+       paramK1                 :: !Float,+       paramB                  :: field -> Float,+       paramFieldWeights       :: field -> Float,+       paramFeatureWeights     :: feature -> Float,+       paramFeatureFunctions   :: feature -> BM25F.FeatureFunction,++       paramResultsetSoftLimit   :: !Int,+       paramResultsetHardLimit   :: !Int,+       paramAutosuggestPrefilterLimit  :: !Int,+       paramAutosuggestPostfilterLimit :: !Int+     }++data SearchEngine doc key field feature = SearchEngine {+       searchIndex      :: !(SearchIndex      key field feature),+       searchConfig     :: !(SearchConfig doc key field feature),+       searchRankParams :: !(SearchRankParameters field feature),++       -- cached info+       sumFieldLengths :: !(UArray field Int),+       bm25Context     :: BM25F.Context TermId field feature+     }++invariant :: (Ord key, Ix field, Bounded field) =>+             SearchEngine doc key field feature -> Bool+invariant SearchEngine{searchIndex} =+    SI.invariant searchIndex+-- && check caches++initSearchEngine :: (Ix field, Bounded field, Ix feature, Bounded feature) =>+                    SearchConfig doc key field feature ->+                    SearchRankParameters field feature ->+                    SearchEngine doc key field feature+initSearchEngine config params =+    cacheBM25Context+      SearchEngine {+        searchIndex      = SI.emptySearchIndex,+        searchConfig     = config,+        searchRankParams = params,+        sumFieldLengths  = listArray (minBound, maxBound) (repeat 0),+        bm25Context      = undefined+      }++cacheBM25Context :: Ix field =>+                    SearchEngine doc key field feature ->+                    SearchEngine doc key field feature+cacheBM25Context+    se@SearchEngine {+      searchRankParams = SearchRankParameters{..},+      searchIndex,+      sumFieldLengths+    }+  = se { bm25Context = bm25Context' }+  where+    bm25Context' = BM25F.Context {+      BM25F.numDocsTotal    = SI.docCount searchIndex,+      BM25F.avgFieldLength  = \f -> fromIntegral (sumFieldLengths ! f)+                                  / fromIntegral (SI.docCount searchIndex),+      BM25F.numDocsWithTerm = DocIdSet.size . SI.lookupTermId searchIndex,+      BM25F.paramK1         = paramK1,+      BM25F.paramB          = paramB,+      BM25F.fieldWeight     = paramFieldWeights,+      BM25F.featureWeight   = paramFeatureWeights,+      BM25F.featureFunction = paramFeatureFunctions+    }+++-----------------------------++data NoFeatures = NoFeatures+  deriving (Eq, Ord, Bounded, Show)++instance Ix NoFeatures where+  range   _   = []+  inRange _ _ = False+  index   _ _ = -1++noFeatures :: NoFeatures -> a+noFeatures _ = error "noFeatures"+
+ src/Data/SearchEngine/Update.hs view
@@ -0,0 +1,90 @@+{-# LANGUAGE BangPatterns, NamedFieldPuns, RecordWildCards #-}++module Data.SearchEngine.Update (++    -- * Managing documents to be searched+    insertDoc,+    insertDocs,+    deleteDoc,++  ) where++import Data.SearchEngine.Types+import qualified Data.SearchEngine.SearchIndex as SI+import qualified Data.SearchEngine.DocTermIds as DocTermIds++import qualified Data.List as List+import Data.Ix+import Data.Array.Unboxed+++insertDocs :: (Ord key, Ix field, Bounded field, Ix feature, Bounded feature) =>+              [doc] ->+              SearchEngine doc key field feature ->+              SearchEngine doc key field feature+insertDocs docs se = List.foldl' (\se' doc -> insertDoc doc se') se docs+++insertDoc :: (Ord key, Ix field, Bounded field, Ix feature, Bounded feature) =>+             doc ->+             SearchEngine doc key field feature ->+             SearchEngine doc key field feature+insertDoc doc se@SearchEngine{ searchConfig = SearchConfig {+                                 documentKey,+                                 extractDocumentTerms,+                                 documentFeatureValue+                               }+                             , searchIndex } =+    let key = documentKey doc+        searchIndex' = SI.insertDoc key (extractDocumentTerms doc)+                                        (documentFeatureValue doc)+                                        searchIndex+        oldDoc       = SI.lookupDocKey searchIndex  key+        newDoc       = SI.lookupDocKey searchIndex' key++     in cacheBM25Context $+        updateCachedFieldLengths oldDoc newDoc $+          se { searchIndex = searchIndex' }+++deleteDoc :: (Ord key, Ix field, Bounded field) =>+             key ->+             SearchEngine doc key field feature ->+             SearchEngine doc key field feature+deleteDoc key se@SearchEngine{searchIndex} =+    let searchIndex' = SI.deleteDoc key searchIndex+        oldDoc       = SI.lookupDocKey searchIndex key++     in cacheBM25Context $+        updateCachedFieldLengths oldDoc Nothing $+          se { searchIndex = searchIndex' }+++updateCachedFieldLengths :: (Ix field, Bounded field) =>+                            Maybe (DocTermIds field) -> Maybe (DocTermIds field) ->+                            SearchEngine doc key field feature ->+                            SearchEngine doc key field feature+updateCachedFieldLengths Nothing (Just newDoc) se@SearchEngine{sumFieldLengths} =+    se {+      sumFieldLengths =+        array (bounds sumFieldLengths)+              [ (i, n + DocTermIds.fieldLength newDoc i)+              | (i, n) <- assocs sumFieldLengths ]+    }+updateCachedFieldLengths (Just oldDoc) (Just newDoc) se@SearchEngine{sumFieldLengths} =+    se {+      sumFieldLengths =+        array (bounds sumFieldLengths)+              [ (i, n - DocTermIds.fieldLength oldDoc i+                      + DocTermIds.fieldLength newDoc i)+              | (i, n) <- assocs sumFieldLengths ]+    }+updateCachedFieldLengths (Just oldDoc) Nothing se@SearchEngine{sumFieldLengths} =+    se {+      sumFieldLengths =+        array (bounds sumFieldLengths)+              [ (i, n - DocTermIds.fieldLength oldDoc i)+              | (i, n) <- assocs sumFieldLengths ]+    }+updateCachedFieldLengths Nothing Nothing se = se+
tests/Test/Data/SearchEngine/TermBag.hs view
@@ -1,4 +1,9 @@-{-# OPTIONS_GHC -fno-warn-orphans #-}+{-# OPTIONS_GHC -Wno-orphans #-}+{-# LANGUAGE CPP #-}+#if __GLASGOW_HASKELL__ >= 908+{-# OPTIONS_GHC -Wno-x-partial #-}+#endif+ module Test.Data.SearchEngine.TermBag where  import Data.SearchEngine.TermBag@@ -34,8 +39,8 @@  prop_termCount :: [TermId] -> Bool prop_termCount tids =-    and [ termCount bag tid == count -        | let bag = fromList tids +    and [ termCount bag tid == count+        | let bag = fromList tids         , (tid, count) <- toList bag         ] @@ -52,4 +57,4 @@               numTerms        = Vec.length terms         , (b, bag) <- zip [0..] bags         ,  t       <- [0..Vec.length terms - 1]-        ] +        ]