166 lines
5.2 KiB
Python
166 lines
5.2 KiB
Python
#!/usr/bin/env python3
|
|
"""raglite -- minimal retrieval for agents: inverted index + BM25 over passages.
|
|
|
|
Stdlib only. Deterministic. No network. No dependencies.
|
|
|
|
A "passage" is a blank-line-delimited block of a text/markdown file, addressed
|
|
as "<path>::<paragraph-index>". The index is plain JSON so it can be diffed,
|
|
cached and inspected by hand.
|
|
|
|
Library:
|
|
idx = raglite.build_index(["./docs"]) # path may be a file or a directory
|
|
raglite.save_index(idx, "index.json")
|
|
idx = raglite.load_index("index.json")
|
|
hits = raglite.search(idx, "bm25 ranking", k=3)
|
|
|
|
CLI:
|
|
python3 raglite.py index ./docs --out index.json
|
|
python3 raglite.py query index.json "bm25 ranking" -k 3 --json
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import math
|
|
import re
|
|
import sys
|
|
from collections import Counter, defaultdict
|
|
from pathlib import Path
|
|
|
|
TOKEN_RE = re.compile(r"[A-Za-z0-9_']+")
|
|
PASSAGE_SPLIT = re.compile(r"\n[ \t]*\n")
|
|
DEFAULT_EXTENSIONS = (".md", ".txt")
|
|
|
|
|
|
def tokenize(text: str) -> list[str]:
|
|
"""Lowercased alphanumeric tokens; punctuation is a separator."""
|
|
return TOKEN_RE.findall(text.lower())
|
|
|
|
|
|
def split_passages(text: str) -> list[str]:
|
|
"""Split on blank lines, drop empties, keep original order."""
|
|
return [p.strip() for p in PASSAGE_SPLIT.split(text) if p.strip()]
|
|
|
|
|
|
def build_index(paths, k1: float = 1.5, b: float = 0.75,
|
|
extensions=DEFAULT_EXTENSIONS) -> dict:
|
|
"""Build a BM25 index over every passage of every file under `paths`."""
|
|
postings: dict[str, Counter] = defaultdict(Counter)
|
|
lengths: dict[str, int] = {}
|
|
texts: dict[str, str] = {}
|
|
n = 0
|
|
|
|
for raw in paths:
|
|
p = Path(raw)
|
|
if p.is_dir():
|
|
files = sorted(q for q in p.rglob("*")
|
|
if q.suffix.lower() in extensions)
|
|
elif p.is_file():
|
|
files = [p]
|
|
else:
|
|
raise FileNotFoundError(f"no such path: {raw}")
|
|
|
|
for f in files:
|
|
body = f.read_text(encoding="utf-8", errors="replace")
|
|
for i, passage in enumerate(split_passages(body)):
|
|
toks = tokenize(passage)
|
|
if not toks:
|
|
continue
|
|
pid = f"{f}::{i}"
|
|
for term, tf in Counter(toks).items():
|
|
postings[term][pid] = tf
|
|
lengths[pid] = len(toks)
|
|
texts[pid] = passage
|
|
n += 1
|
|
|
|
total = sum(lengths.values())
|
|
return {
|
|
"k1": k1,
|
|
"b": b,
|
|
"n_docs": n,
|
|
"avgdl": (total / n) if n else 0.0,
|
|
"lengths": lengths,
|
|
"texts": texts,
|
|
"postings": {t: dict(c) for t, c in postings.items()},
|
|
}
|
|
|
|
|
|
def _idf(df: int, n: int) -> float:
|
|
"""BM25 idf, Robertson's non-negative form."""
|
|
return math.log(1.0 + (n - df + 0.5) / (df + 0.5))
|
|
|
|
|
|
def search(idx: dict, query: str, k: int = 5) -> list[dict]:
|
|
"""Rank passages for `query`; returns <= k hits, best first.
|
|
|
|
Each hit: {"id", "score", "text"}. Score is the raw BM25 sum (unrounded).
|
|
"""
|
|
k1, b = idx["k1"], idx["b"]
|
|
n, avgdl = idx["n_docs"], idx["avgdl"]
|
|
if not avgdl or k <= 0:
|
|
return []
|
|
|
|
scores: dict[str, float] = defaultdict(float)
|
|
for term in set(tokenize(query)):
|
|
post = idx["postings"].get(term)
|
|
if not post:
|
|
continue
|
|
idf = _idf(len(post), n)
|
|
for pid, tf in post.items():
|
|
dl = idx["lengths"][pid]
|
|
denom = tf + k1 * (1.0 - b + b * dl / avgdl)
|
|
scores[pid] += idf * (tf * (k1 + 1.0)) / denom
|
|
|
|
ranked = sorted(scores.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
return [{"id": pid, "score": s, "text": idx["texts"][pid]}
|
|
for pid, s in ranked[:k]]
|
|
|
|
|
|
def save_index(idx: dict, path) -> None:
|
|
Path(path).write_text(json.dumps(idx, sort_keys=True), encoding="utf-8")
|
|
|
|
|
|
def load_index(path) -> dict:
|
|
return json.loads(Path(path).read_text(encoding="utf-8"))
|
|
|
|
|
|
def main(argv=None) -> int:
|
|
ap = argparse.ArgumentParser(prog="raglite", description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
|
|
pi = sub.add_parser("index", help="build an index over a file or directory")
|
|
pi.add_argument("path")
|
|
pi.add_argument("--out", required=True)
|
|
pi.add_argument("--k1", type=float, default=1.5)
|
|
pi.add_argument("--b", type=float, default=0.75)
|
|
|
|
pq = sub.add_parser("query", help="rank passages in an index")
|
|
pq.add_argument("index")
|
|
pq.add_argument("query")
|
|
pq.add_argument("-k", type=int, default=5)
|
|
pq.add_argument("--json", action="store_true", help="emit JSON hits")
|
|
|
|
args = ap.parse_args(argv)
|
|
|
|
if args.cmd == "index":
|
|
idx = build_index([args.path], k1=args.k1, b=args.b)
|
|
save_index(idx, args.out)
|
|
print(f"indexed {idx['n_docs']} passages from {args.path} -> {args.out}")
|
|
return 0
|
|
|
|
idx = load_index(args.index)
|
|
hits = search(idx, args.query, k=args.k)
|
|
if args.json:
|
|
print(json.dumps(hits, indent=2))
|
|
else:
|
|
for i, h in enumerate(hits, 1):
|
|
print(f"{i}. [{h['score']:.6f}] {h['id']}")
|
|
print(f" {h['text'][:160]}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|