vignesh1234 commited on
Commit
850b02b
·
verified ·
1 Parent(s): 04b059a

Upload tools/retrieval/__init__.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. tools/retrieval/__init__.py +198 -0
tools/retrieval/__init__.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pluggable retrieval for serve.py (Phase 5b).
2
+
3
+ Retriever(config_path) loads the sources listed in retrieval/config.yaml. Each
4
+ source implements search(query, k) -> list of passages
5
+ {"text", "title", "source", "score", "meta"}; the Retriever normalises scores
6
+ per source, applies the source weight, and returns the merged top-k.
7
+ Source types: kb (literature KB), wiki_dump (prebuilt BM25 over a Wikipedia
8
+ dump), folder (local .txt/.md/.jsonl, indexed lazily), search_api (HTTP JSON).
9
+ """
10
+ import glob, json, os, time, urllib.parse, urllib.request
11
+ import yaml
12
+ from .bm25 import BM25Index, build as build_bm25
13
+ from .text import toks, chunk_words, nfc
14
+ from . import kb as kbmod
15
+
16
+ HERE = os.path.dirname(os.path.abspath(__file__))
17
+ DEFAULT_CONFIG = os.path.join(HERE, "config.yaml")
18
+
19
+ class Passage(dict):
20
+ pass
21
+
22
+ class KBSource:
23
+ type = "kb"
24
+ def __init__(self, cfg):
25
+ self.name = cfg["name"]; self.k = int(cfg.get("k", 3)); self.weight = float(cfg.get("weight", 1.0))
26
+ self.units = kbmod.load_units(cfg.get("glob", "data/kb/*.jsonl"))
27
+ d = cfg.get("index_dir", "data/index/literature_kb")
28
+ marker = os.path.join(d, "meta.json")
29
+ newest = max((os.path.getmtime(f) for f in glob.glob(cfg.get("glob", "data/kb/*.jsonl"))), default=0)
30
+ if not BM25Index.exists(d) or os.path.getmtime(marker) < newest:
31
+ build_bm25(d, (toks(kbmod.unit_text(u)) for u in self.units))
32
+ self.idx = BM25Index(d)
33
+ def search(self, query, k=None):
34
+ out = []
35
+ for i, s in self.idx.search(query, k or self.k):
36
+ u = self.units[i]
37
+ out.append(Passage(text=kbmod.format_unit(u), title=f"{u.get('work')} {u.get('number', '')}".strip(),
38
+ source=self.name, score=s, meta={"unit": u}))
39
+ return out
40
+
41
+ class WikiDumpSource:
42
+ type = "wiki_dump"
43
+ def __init__(self, cfg):
44
+ self.name = cfg["name"]; self.k = int(cfg.get("k", 4)); self.weight = float(cfg.get("weight", 1.0))
45
+ self.dir = cfg["index_dir"]
46
+ fs_dir = cfg.get("family_safe_index_dir")
47
+ if os.environ.get("FAMILY_SAFE", "1") != "0" and fs_dir and BM25Index.exists(fs_dir) and os.path.exists(os.path.join(fs_dir, "passages.jsonl")):
48
+ self.dir = fs_dir # family-safe index (severe lexicon hits dropped at build time) when FAMILY_SAFE=1
49
+ self.ok = BM25Index.exists(self.dir) and os.path.exists(os.path.join(self.dir, "passages.jsonl"))
50
+ if not self.ok:
51
+ print(f"[retrieval] source {self.name} disabled: index missing at {self.dir} (run retrieval/build_wiki_index.py)")
52
+ return
53
+ self.idx = BM25Index(self.dir)
54
+ self.offsets = __import__("numpy").load(os.path.join(self.dir, "passage_offsets.npy"))
55
+ self.fh = open(os.path.join(self.dir, "passages.jsonl"), "rb")
56
+ def _passage(self, i):
57
+ self.fh.seek(int(self.offsets[i])); return json.loads(self.fh.readline())
58
+ def search(self, query, k=None):
59
+ if not self.ok:
60
+ return []
61
+ kk = k or self.k
62
+ qn = " ".join(t for t in toks(query) if t not in __import__("retrieval.text", fromlist=["STOPWORDS"]).STOPWORDS)
63
+ out = []
64
+ for i, s in self.idx.search(query, max(kk * 5, 20)):
65
+ p = self._passage(i)
66
+ tn = " ".join(toks(p["title"]))
67
+ if qn and tn == qn:
68
+ s *= 1.5 # exact title match: the article about the thing asked
69
+ elif qn and qn in tn:
70
+ s *= 1.15
71
+ out.append(Passage(text=p["text"], title=p["title"], source=self.name, score=s, meta={"title": p["title"]}))
72
+ out.sort(key=lambda h: -h["score"])
73
+ return out[:kk]
74
+
75
+ class FolderSource:
76
+ type = "folder"
77
+ def __init__(self, cfg):
78
+ self.name = cfg["name"]; self.k = int(cfg.get("k", 4)); self.weight = float(cfg.get("weight", 1.0))
79
+ self.path = cfg["path"]; self.dir = cfg.get("index_dir", f"data/index/{self.name}")
80
+ self.ok = os.path.isdir(self.path)
81
+ if not self.ok:
82
+ print(f"[retrieval] source {self.name} disabled: folder {self.path} not found"); return
83
+ files = self._files()
84
+ newest = max((os.path.getmtime(f) for f in files), default=0)
85
+ marker = os.path.join(self.dir, "meta.json")
86
+ if not BM25Index.exists(self.dir) or os.path.getmtime(marker) < newest:
87
+ self._build(files)
88
+ self.idx = BM25Index(self.dir)
89
+ self.passages = [json.loads(l) for l in open(os.path.join(self.dir, "passages.jsonl"), encoding="utf-8")]
90
+ def _files(self):
91
+ out = []
92
+ for root, _, fs in os.walk(self.path):
93
+ out += [os.path.join(root, f) for f in fs if f.endswith((".txt", ".md", ".jsonl"))]
94
+ return sorted(out)
95
+ def _build(self, files):
96
+ os.makedirs(self.dir, exist_ok=True)
97
+ passages = []
98
+ for f in files:
99
+ rel = os.path.relpath(f, self.path)
100
+ if f.endswith(".jsonl"):
101
+ for l in open(f, encoding="utf-8"):
102
+ try:
103
+ d = json.loads(l)
104
+ except Exception:
105
+ continue
106
+ for c in chunk_words(str(d.get("text", "")), target=200):
107
+ passages.append({"title": d.get("title") or rel, "text": c, "file": rel})
108
+ else:
109
+ for c in chunk_words(open(f, encoding="utf-8", errors="replace").read(), target=200):
110
+ passages.append({"title": rel, "text": c, "file": rel})
111
+ with open(os.path.join(self.dir, "passages.jsonl"), "w", encoding="utf-8") as w:
112
+ for p in passages:
113
+ w.write(json.dumps(p, ensure_ascii=False) + "\n")
114
+ build_bm25(self.dir, (toks(p["title"] + " " + p["text"]) for p in passages))
115
+ print(f"[retrieval] built folder index {self.name}: {len(files)} files, {len(passages)} passages")
116
+ def search(self, query, k=None):
117
+ if not self.ok:
118
+ return []
119
+ return [Passage(text=self.passages[i]["text"], title=self.passages[i]["title"], source=self.name, score=s,
120
+ meta={"file": self.passages[i]["file"]}) for i, s in self.idx.search(query, k or self.k)]
121
+
122
+ class SearchAPISource:
123
+ type = "search_api"
124
+ def __init__(self, cfg):
125
+ self.name = cfg["name"]; self.k = int(cfg.get("k", 4)); self.weight = float(cfg.get("weight", 1.0))
126
+ self.url = cfg["url"]; self.results_key = cfg.get("results_key", "results")
127
+ self.text_key = cfg.get("text_key", "text"); self.title_key = cfg.get("title_key", "title")
128
+ self.header = cfg.get("api_key_header", "Authorization"); self.bearer = bool(cfg.get("bearer", True))
129
+ env = cfg.get("api_key_env")
130
+ self.key = os.environ.get(env) if env else None
131
+ self.ok = bool(self.key) or not env
132
+ if not self.ok:
133
+ print(f"[retrieval] source {self.name} disabled: env var {env} not set")
134
+ self.timeout = float(cfg.get("timeout", 8))
135
+ def search(self, query, k=None):
136
+ if not self.ok:
137
+ return []
138
+ url = self.url.replace("{query}", urllib.parse.quote(query))
139
+ req = urllib.request.Request(url, headers={"User-Agent": "tamil-lm-serve/1.0"})
140
+ if self.key:
141
+ req.add_header(self.header, f"Bearer {self.key}" if self.bearer else self.key)
142
+ try:
143
+ with urllib.request.urlopen(req, timeout=self.timeout) as r:
144
+ data = json.loads(r.read().decode("utf-8"))
145
+ except Exception as e:
146
+ print(f"[retrieval] {self.name} request failed: {type(e).__name__}"); return []
147
+ node = data
148
+ for part in self.results_key.split("."):
149
+ node = node.get(part, []) if isinstance(node, dict) else []
150
+ out = []
151
+ for j, hit in enumerate(node[: k or self.k]):
152
+ if not isinstance(hit, dict):
153
+ continue
154
+ out.append(Passage(text=str(hit.get(self.text_key, "")), title=str(hit.get(self.title_key, "")),
155
+ source=self.name, score=float(len(node) - j), meta={"url": hit.get("url")}))
156
+ return out
157
+
158
+ SOURCE_TYPES = {c.type: c for c in (KBSource, WikiDumpSource, FolderSource, SearchAPISource)}
159
+
160
+ class Retriever:
161
+ def __init__(self, config_path=DEFAULT_CONFIG, only=None):
162
+ cfg = yaml.safe_load(open(config_path))
163
+ self.top_k = int(cfg.get("top_k", 4))
164
+ self.sources = []
165
+ for s in cfg.get("sources", []):
166
+ if only and s["name"] not in only:
167
+ continue
168
+ cls = SOURCE_TYPES.get(s.get("type"))
169
+ if cls is None:
170
+ print(f"[retrieval] unknown source type {s.get('type')} for {s.get('name')}; skipped"); continue
171
+ t0 = time.time()
172
+ self.sources.append(cls(s))
173
+ print(f"[retrieval] loaded {s['name']} ({s['type']}) in {time.time() - t0:.1f}s")
174
+ def source(self, name):
175
+ return next((s for s in self.sources if s.name == name), None)
176
+ def search(self, query, k=None):
177
+ merged = []
178
+ for src in self.sources:
179
+ hits = src.search(query)
180
+ if not hits:
181
+ continue
182
+ # rank across sources by the raw BM25 score times the source weight; per-source normalisation
183
+ # made every source's best hit look equally good (a weak literature match outranked the
184
+ # right Wikipedia article for "rasam recipe", 2026-09-07)
185
+ for h in hits:
186
+ h["score_raw"] = h["score"]; h["score"] = src.weight * h["score"]
187
+ merged.append(h)
188
+ merged.sort(key=lambda h: -h["score"])
189
+ return merged[: k or self.top_k]
190
+
191
+ def format_passages(hits):
192
+ out = []
193
+ for h in hits:
194
+ if h["source"] == "literature_kb":
195
+ out.append(h["text"])
196
+ else:
197
+ out.append(f"[{h['source']}: {h['title']}]\n{h['text']}")
198
+ return "\n\n".join(out)