Upload bm25_index.py with huggingface_hub
Browse files- 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()
|