Download src/core/lexical.py from cbratkovics/document-intelligence-ai: direct link, hf CLI and curl.
- Browser
- Download file 5.3 kB
-
https://huggingface.co/spaces/cbratkovics/document-intelligence-ai/resolve/main/src/core/lexical.py
- Command line
-
hf download hf://spaces/cbratkovics/document-intelligence-ai/src/core/lexical.py
-
curl -L -o lexical.py https://huggingface.co/spaces/cbratkovics/document-intelligence-ai/resolve/main/src/core/lexical.py
5.3 kB
| """BM25 lexical index rebuilt deterministically from the manifest. | |
| The corpus statistics cover every READY chunk in the single shared corpus; | |
| scope restrictions filter *candidates* before ranking, so an empty scope | |
| yields nothing and a document filter never leaks other documents. This is the | |
| supported isolation model (one corpus, optional per-request document scope); | |
| it is not multi-tenant isolation. | |
| Scoring is BM25 (k1=1.5, b=0.75) with the Lucene IDF variant | |
| ``log(1 + (N - n + 0.5) / (n + 0.5))``, which stays positive for any corpus | |
| size. (The ``rank_bm25`` package's Okapi variant yields non-positive scores | |
| when a term appears in every document, which makes one-document corpora | |
| unsearchable.) Scores are comparable only within one query. | |
| A short English stopword list is removed from documents and queries alike. | |
| Without it, on a small corpus, function words such as "what" or "does" carry | |
| enough IDF that a question containing no content term still produces lexical | |
| hits, and rank fusion then promotes that noise above genuine dense matches. | |
| """ | |
| from __future__ import annotations | |
| import math | |
| import re | |
| import threading | |
| from collections import Counter | |
| from dataclasses import dataclass | |
| from typing import Dict, Iterable, List, Sequence, Tuple | |
| from .types import QueryScope | |
| _TOKEN_RE = re.compile(r"\w+", re.UNICODE) | |
| STOPWORDS = frozenset( | |
| """ | |
| a an and are as at be been by can could did do does for from had has have he her | |
| his how i if in into is it its may might of on or our shall she should that the | |
| their them then there these they this those to us was we were what when where | |
| which who whom why will with would you your | |
| """.split() | |
| ) | |
| def tokenize(text: str) -> List[str]: | |
| """Lowercased word tokens with stopwords removed (documents and queries alike).""" | |
| return [t for t in _TOKEN_RE.findall(text.lower()) if t not in STOPWORDS] | |
| class LexicalEntry: | |
| chunk_id: str | |
| doc_id: str | |
| text: str | |
| class LexicalHit: | |
| chunk_id: str | |
| doc_id: str | |
| score: float | |
| rank: int | |
| class BM25: | |
| def __init__(self, documents: Sequence[Sequence[str]], k1: float = 1.5, b: float = 0.75): | |
| self.k1 = k1 | |
| self.b = b | |
| self.n_docs = len(documents) | |
| self.doc_freqs: List[Counter] = [Counter(tokens) for tokens in documents] | |
| self.doc_lengths = [len(tokens) for tokens in documents] | |
| self.avg_length = (sum(self.doc_lengths) / self.n_docs) if self.n_docs else 0.0 | |
| df: Counter = Counter() | |
| for freqs in self.doc_freqs: | |
| df.update(freqs.keys()) | |
| self.idf: Dict[str, float] = { | |
| term: math.log(1.0 + (self.n_docs - n + 0.5) / (n + 0.5)) for term, n in df.items() | |
| } | |
| def scores(self, query_tokens: Sequence[str]) -> List[float]: | |
| out = [0.0] * self.n_docs | |
| terms = [t for t in dict.fromkeys(query_tokens) if t in self.idf] | |
| if not terms: | |
| return out | |
| for index, freqs in enumerate(self.doc_freqs): | |
| length_norm = ( | |
| 1.0 | |
| - self.b | |
| + self.b * (self.doc_lengths[index] / self.avg_length if self.avg_length else 0.0) | |
| ) | |
| score = 0.0 | |
| for term in terms: | |
| tf = freqs.get(term, 0) | |
| if tf: | |
| score += self.idf[term] * (tf * (self.k1 + 1.0)) / (tf + self.k1 * length_norm) | |
| out[index] = score | |
| return out | |
| class LexicalIndex: | |
| """In-memory BM25 index. ``rebuild`` is O(corpus); fine for a local demo.""" | |
| def __init__(self) -> None: | |
| self._lock = threading.RLock() | |
| self._entries: List[LexicalEntry] = [] | |
| self._bm25: BM25 | None = None | |
| self.generation: int = -1 | |
| def size(self) -> int: | |
| return len(self._entries) | |
| def rebuild(self, entries: Iterable[LexicalEntry], generation: int) -> None: | |
| entries = list(entries) | |
| bm25 = BM25([tokenize(e.text) for e in entries]) if entries else None | |
| with self._lock: | |
| self._entries = entries | |
| self._bm25 = bm25 | |
| self.generation = generation | |
| def search( | |
| self, | |
| query: str, | |
| n_results: int, | |
| scope: QueryScope = QueryScope.whole_corpus(), | |
| ) -> List[LexicalHit]: | |
| if n_results <= 0 or scope.is_empty: | |
| return [] | |
| tokens = tokenize(query) | |
| if not tokens: | |
| return [] | |
| with self._lock: | |
| entries = self._entries | |
| bm25 = self._bm25 | |
| if bm25 is None: | |
| return [] | |
| scores = bm25.scores(tokens) | |
| candidates: List[Tuple[float, str, str]] = [] | |
| for entry, score in zip(entries, scores): | |
| if score <= 0.0 or not scope.allows(entry.doc_id): | |
| continue | |
| candidates.append((float(score), entry.chunk_id, entry.doc_id)) | |
| candidates.sort(key=lambda c: (-c[0], c[1])) | |
| return [ | |
| LexicalHit(chunk_id=cid, doc_id=doc, score=score, rank=i + 1) | |
| for i, (score, cid, doc) in enumerate(candidates[:n_results]) | |
| ] | |
| def contains_doc(self, doc_id: str) -> bool: | |
| with self._lock: | |
| return any(e.doc_id == doc_id for e in self._entries) | |