Spaces:
Sleeping
Sleeping
| """ | |
| Knowledge Engine v2 β BM25-ranked multi-passage retrieval. | |
| Improvements over v1: | |
| Β· BM25 ranking (Okapi BM25) instead of raw keyword overlap | |
| Β· Multi-passage synthesis β retrieves top-K paragraphs and merges them | |
| Β· Phrase-level matching with bonus scoring | |
| Β· TF-IDF-style IDF weighting for rare/common term discrimination | |
| Β· Dynamic MIN_RELEVANCE threshold based on query complexity | |
| Β· Zero external dependencies (pure stdlib + math) | |
| """ | |
| from __future__ import annotations | |
| import math | |
| import os | |
| import re | |
| from collections import Counter | |
| from typing import List, Tuple | |
| KNOWLEDGE_FILE = os.path.join(os.path.dirname(__file__), "knowledge.txt") | |
| # BM25 hyper-parameters | |
| K1: float = 1.5 # term saturation | |
| B: float = 0.75 # length normalisation | |
| # How many top passages to retrieve & synthesise | |
| TOP_K: int = 3 | |
| # Minimum BM25 score for a passage to be considered a real match | |
| MIN_BM25_SCORE: float = 1.0 | |
| STOP_WORDS = { | |
| "a", "an", "the", "is", "are", "was", "were", "be", "been", "being", | |
| "have", "has", "had", "do", "does", "did", "will", "would", "shall", | |
| "should", "may", "might", "must", "can", "could", "to", "of", "in", | |
| "on", "at", "by", "for", "with", "about", "against", "between", "into", | |
| "through", "during", "before", "after", "above", "below", "from", | |
| "up", "down", "out", "off", "over", "under", "again", "and", "but", | |
| "or", "nor", "so", "yet", "both", "either", "neither", "not", "no", | |
| "what", "which", "who", "whom", "this", "that", "these", "those", | |
| "i", "me", "my", "myself", "we", "our", "you", "your", "he", "she", | |
| "it", "they", "them", "their", "tell", "explain", "describe", "give", | |
| "me", "some", "information", "about", "please", "could", "how", "when", | |
| "where", "why", "let", | |
| } | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Text processing helpers | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _load_paragraphs(filepath: str) -> List[str]: | |
| if not os.path.exists(filepath): | |
| return [] | |
| with open(filepath, "r", encoding="utf-8") as f: | |
| content = f.read() | |
| raw = re.split(r"\n\s*\n", content.strip()) | |
| return [p.strip() for p in raw if len(p.strip()) > 10] | |
| def _tokenize(text: str) -> List[str]: | |
| words = re.findall(r"\b[a-z]{2,}\b", text.lower()) | |
| return [w for w in words if w not in STOP_WORDS] | |
| def _extract_phrases(text: str, n: int = 2) -> List[str]: | |
| tokens = _tokenize(text) | |
| return [" ".join(tokens[i:i+n]) for i in range(len(tokens)-n+1)] | |
| def _strip_question_prefixes(text: str) -> str: | |
| prefixes = [ | |
| "what is", "what are", "who is", "who are", "explain", "define", | |
| "tell me about", "describe", "how does", "why is", "when was", | |
| "where is", "history of", "meaning of", "knowledge:", "knowledge :", | |
| "learn about", "facts about", "information about", "can you explain", | |
| "could you explain", "please explain", "give me information about", | |
| ] | |
| lowered = text.lower().strip() | |
| for prefix in prefixes: | |
| if lowered.startswith(prefix): | |
| remainder = text[len(prefix):].strip(" ?.,") | |
| if remainder: | |
| return remainder | |
| return text | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # BM25 index | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class BM25Index: | |
| """Okapi BM25 retrieval index over a list of paragraphs.""" | |
| def __init__(self, paragraphs: List[str]): | |
| self.paragraphs = paragraphs | |
| self.n_docs = len(paragraphs) | |
| self._token_lists = [_tokenize(p) for p in paragraphs] | |
| self._tf_lists = [Counter(tl) for tl in self._token_lists] | |
| self._doc_lengths = [len(tl) for tl in self._token_lists] | |
| self._avgdl = ( | |
| sum(self._doc_lengths) / self.n_docs if self.n_docs else 1.0 | |
| ) | |
| self._idf = self._compute_idf() | |
| def _compute_idf(self) -> dict[str, float]: | |
| idf: dict[str, float] = {} | |
| for token_list in self._token_lists: | |
| for tok in set(token_list): | |
| idf[tok] = idf.get(tok, 0) + 1 | |
| result: dict[str, float] = {} | |
| for tok, df in idf.items(): | |
| result[tok] = math.log( | |
| (self.n_docs - df + 0.5) / (df + 0.5) + 1 | |
| ) | |
| return result | |
| def score(self, query_tokens: List[str], doc_idx: int) -> float: | |
| tf_map = self._tf_lists[doc_idx] | |
| dl = self._doc_lengths[doc_idx] | |
| score = 0.0 | |
| for tok in query_tokens: | |
| if tok not in tf_map: | |
| continue | |
| idf_val = self._idf.get(tok, 0.0) | |
| tf_val = tf_map[tok] | |
| numerator = tf_val * (K1 + 1) | |
| denominator = tf_val + K1 * (1 - B + B * dl / self._avgdl) | |
| score += idf_val * (numerator / denominator) | |
| return score | |
| def phrase_bonus(self, phrase: str, doc_idx: int) -> float: | |
| """Extra score if a phrase appears verbatim in the paragraph.""" | |
| if phrase in self.paragraphs[doc_idx].lower(): | |
| return 2.0 | |
| return 0.0 | |
| def retrieve( | |
| self, query: str, top_k: int = TOP_K | |
| ) -> List[Tuple[float, str]]: | |
| clean = _strip_question_prefixes(query) | |
| q_tokens = _tokenize(clean) | |
| q_phrases = _extract_phrases(clean, 2) | |
| if not q_tokens: | |
| return [] | |
| scores: List[Tuple[float, int]] = [] | |
| for idx in range(self.n_docs): | |
| s = self.score(q_tokens, idx) | |
| for phrase in q_phrases: | |
| s += self.phrase_bonus(phrase, idx) | |
| scores.append((s, idx)) | |
| scores.sort(reverse=True) | |
| return [ | |
| (s, self.paragraphs[idx]) | |
| for s, idx in scores[:top_k] | |
| if s >= MIN_BM25_SCORE | |
| ] | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Multi-passage synthesiser | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _synthesise(passages: List[Tuple[float, str]], query: str) -> str: | |
| """ | |
| Merge the top-K retrieved passages into a coherent response. | |
| Deduplicates sentences that appear across passages. | |
| """ | |
| if not passages: | |
| return "" | |
| if len(passages) == 1: | |
| return passages[0][1] | |
| seen_sentences: set[str] = set() | |
| merged_sentences: List[str] = [] | |
| for _score, para in passages: | |
| sentences = re.split(r"(?<=[.!?])\s+", para.strip()) | |
| for sent in sentences: | |
| normalised = sent.strip().lower() | |
| if normalised not in seen_sentences and len(sent.strip()) > 10: | |
| seen_sentences.add(normalised) | |
| merged_sentences.append(sent.strip()) | |
| return " ".join(merged_sentences) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Public interface | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class KnowledgeEngine: | |
| """ | |
| BM25-powered local knowledge retrieval. | |
| Usage: | |
| ke = KnowledgeEngine() | |
| response, found = ke.query("What is quantum computing?") | |
| """ | |
| def __init__(self, knowledge_file: str = KNOWLEDGE_FILE): | |
| self._paragraphs = _load_paragraphs(knowledge_file) | |
| self._loaded = len(self._paragraphs) > 0 | |
| self._index = BM25Index(self._paragraphs) if self._loaded else None | |
| def is_loaded(self) -> bool: | |
| return self._loaded | |
| def query(self, user_input: str, top_k: int = TOP_K) -> Tuple[str, bool]: | |
| """ | |
| Find the most relevant passage(s) for the given query using BM25. | |
| Returns: | |
| (response_text, found) | |
| found=True β high-confidence match(es) retrieved | |
| found=False β no confident match; caller should escalate to LLM | |
| """ | |
| if not self._loaded or self._index is None: | |
| return ("Knowledge base unavailable. Ensure 'knowledge.txt' exists.", False) | |
| hits = self._index.retrieve(user_input, top_k=top_k) | |
| if not hits: | |
| return ("", False) | |
| response = _synthesise(hits, user_input) | |
| return (response, True) | |
| def get_context(self, user_input: str, top_k: int = 2) -> str: | |
| """ | |
| Return up to top_k relevant passages as a context block (for LLM prompts). | |
| Returns empty string if nothing relevant is found. | |
| """ | |
| if not self._loaded or self._index is None: | |
| return "" | |
| hits = self._index.retrieve(user_input, top_k=top_k) | |
| if not hits: | |
| return "" | |
| return "\n\n".join(para for _, para in hits) | |