standinothegr8 commited on
Commit
ce28e98
Β·
verified Β·
1 Parent(s): ffe3943

Upload bm25_index.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. bm25_index.py +111 -0
bm25_index.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lexical (keyword) index over the same 158,945 passages, for the dense-vs-hybrid comparison.
2
+
3
+ Burmese has no spaces between words, so passages are split into syllables with the standard syllable-break
4
+ rule (Ye Kyaw Thu's sylbreak); each passage is indexed on its syllables and syllable bigrams (bigrams
5
+ approximate words), plus lowercased Latin words and digits. Scores are BM25 (k1 = 1.5, b = 0.75), precomputed
6
+ into a sparse matrix so a query is a sum of columns.
7
+
8
+ python bm25_index.py # build data/bm25/ from data/index/meta_*.jsonl (β‰ˆ 5–10 min on the laptop)
9
+ python bm25_index.py --query "ကျောင်းဆရာ α€œα€…α€¬" # try a query
10
+
11
+ from bm25_index import BM25
12
+ bm = BM25() # loads data/bm25/
13
+ scores = bm.scores("question") # numpy array, one score per passage (same order as the dense index)
14
+ """
15
+ import argparse, json, os, re, time
16
+ import numpy as np
17
+ import scipy.sparse as sp
18
+ from common import DATA, INDEX_DIR, embed_text_of, citation_of
19
+
20
+ BM25_DIR = os.path.join(DATA, "bm25")
21
+
22
+ # --- Burmese syllable breaking (sylbreak rule) ---------------------------------------------------------------
23
+ _CONS = "α€€-ထ"; _SS = "α€Ή"; _ATHAT = "α€Ί"
24
+ _OTHER = "α€£α€€α€₯ဦဧဩα€ͺα€ΏαŒαα"
25
+ _BREAK = re.compile(r"((?<!" + _SS + r")[" + _CONS + r"](?!α€·?[" + _ATHAT + _SS + r"])|[" + _OTHER + r"])") # α€· may sit between consonant and α€Ί
26
+ _LATIN = re.compile(r"[A-Za-z]+|[0-9]+|[၀-၉]+")
27
+ _MY_RUN = re.compile(r"[α€€-α‚Ÿ]+")
28
+
29
+ def syllables(run):
30
+ return [s for s in _BREAK.sub(r" \1", run).split() if s]
31
+
32
+ def tokenize(text):
33
+ """Syllables + syllable bigrams for Burmese runs; lowercased words/numbers for Latin and digits."""
34
+ toks = []
35
+ for m in re.finditer(r"[α€€-α‚Ÿ]+|[A-Za-z]+|[0-9]+", text):
36
+ s = m.group()
37
+ if s[0] >= "α€€":
38
+ sy = syllables(s)
39
+ toks.extend(sy); toks.extend(a + b for a, b in zip(sy, sy[1:]))
40
+ else:
41
+ toks.append(s.lower())
42
+ return toks
43
+
44
+ # --- build -----------------------------------------------------------------------------------------------------
45
+ def build(k1=1.5, b=0.75, min_df=2):
46
+ os.makedirs(BM25_DIR, exist_ok=True)
47
+ vocab = {}; R, C, V = [], [], []; rows = []; cols = []; vals = []; dl = []
48
+ t0 = time.time(); n = 0
49
+ import glob
50
+ def flush():
51
+ R.append(np.array(rows, dtype=np.int32)); C.append(np.array(cols, dtype=np.int32)); V.append(np.array(vals, dtype=np.float32))
52
+ rows.clear(); cols.clear(); vals.clear()
53
+ for mp in sorted(glob.glob(os.path.join(INDEX_DIR, "meta_*.jsonl"))):
54
+ for line in open(mp, encoding="utf-8"):
55
+ r = json.loads(line)
56
+ counts = {}
57
+ for t in tokenize(embed_text_of(r)):
58
+ tid = vocab.get(t)
59
+ if tid is None: tid = vocab[t] = len(vocab)
60
+ counts[tid] = counts.get(tid, 0) + 1
61
+ dl.append(sum(counts.values()))
62
+ rows.extend([n] * len(counts)); cols.extend(counts.keys()); vals.extend(counts.values())
63
+ n += 1
64
+ if n % 2000 == 0: flush()
65
+ if n % 20000 == 0: print(f" {n:,} passages Β· {len(vocab):,} terms Β· {time.time()-t0:.0f}s", flush=True)
66
+ flush()
67
+ tf = sp.csr_matrix((np.concatenate(V), (np.concatenate(R), np.concatenate(C))), shape=(n, len(vocab)))
68
+ del R, C, V
69
+ dl = np.array(dl, dtype=np.float32); avgdl = dl.mean()
70
+ df = np.diff(tf.tocsc().indptr).astype(np.float32)
71
+ keep = df >= min_df # drop terms seen in a single passage (mostly noise bigrams)
72
+ inv = {i: t for t, i in vocab.items()}
73
+ new_ids = -np.ones(len(vocab), dtype=np.int64); new_ids[keep] = np.arange(int(keep.sum()))
74
+ vocab = {inv[i]: int(new_ids[i]) for i in np.flatnonzero(keep)}
75
+ tf = tf[:, keep]; df = df[keep]
76
+ idf = np.log(1 + (n - df + 0.5) / (df + 0.5))
77
+ tf = tf.tocoo()
78
+ denom = tf.data + k1 * (1 - b + b * dl[tf.row] / avgdl)
79
+ w = (idf[tf.col] * tf.data * (k1 + 1) / denom).astype(np.float32)
80
+ W = sp.csc_matrix((w, (tf.row, tf.col)), shape=tf.shape)
81
+ sp.save_npz(os.path.join(BM25_DIR, "weights.npz"), W)
82
+ json.dump({"vocab": vocab, "n": n, "k1": k1, "b": b, "avgdl": float(avgdl), "min_df": min_df},
83
+ open(os.path.join(BM25_DIR, "vocab.json"), "w"), ensure_ascii=False)
84
+ print(f"built: {n:,} passages Β· {len(vocab):,} terms (df β‰₯ {min_df}) Β· {W.nnz:,} weights Β· {time.time()-t0:.0f}s β†’ {BM25_DIR}")
85
+
86
+ class BM25:
87
+ def __init__(self, path=BM25_DIR):
88
+ meta = json.load(open(os.path.join(path, "vocab.json"), encoding="utf-8"))
89
+ self.vocab = meta["vocab"]; self.n = meta["n"]
90
+ self.W = sp.load_npz(os.path.join(path, "weights.npz")).tocsc()
91
+
92
+ def scores(self, query):
93
+ ids = [self.vocab[t] for t in tokenize(query) if t in self.vocab]
94
+ if not ids: return np.zeros(self.n, dtype=np.float32)
95
+ return np.asarray(self.W[:, ids].sum(axis=1)).ravel().astype(np.float32)
96
+
97
+ def search(self, query, k=5):
98
+ s = self.scores(query); top = np.argpartition(-s, k)[:k]
99
+ return sorted(((float(s[j]), int(j)) for j in top), reverse=True)
100
+
101
+ if __name__ == "__main__":
102
+ ap = argparse.ArgumentParser(); ap.add_argument("--query"); ap.add_argument("--k", type=int, default=5)
103
+ a = ap.parse_args()
104
+ if a.query:
105
+ from search import load_meta
106
+ bm = BM25(); meta = load_meta()
107
+ t = time.time(); hits = bm.search(a.query, a.k)
108
+ print(f"({time.time()-t:.2f}s) tokens: {tokenize(a.query)[:20]}")
109
+ for r, (s, j) in enumerate(hits, 1): print(f"{r}. [{s:.2f}] {citation_of(meta[j])}\n {meta[j]['text'][:200]}")
110
+ else:
111
+ build()