zephyrproject / scripts /retrieval.py
eoinedge's picture
Zephyr docs RAG index and tooling
c7815d1 verified
Raw
History Blame Contribute Delete
3.42 kB
"""Shared retrieval for the CLI and the Space, so the two cannot drift apart.
Beyond loading the index, this does one thing worth explaining: it down-weights
release notes.
Zephyr's release notes are a quarter of the corpus β€” 1,688 chunks of the 6,949 β€”
and they are written in the same vocabulary as the documentation they describe.
A question like "what does west build actually do?" retrieved
`develop/west/release-notes - v0.7.0` above `develop/west/build-flash-debug`,
because a changelog entry about west build looks a lot like documentation about
west build to an embedding model.
For a coding assistant that is the wrong answer nearly every time: a developer
asking how something works wants the reference page, not what changed in v0.7.0.
So release notes are penalised, unless the question is actually about a version
or a change β€” in which case they are exactly right and the penalty is lifted.
"""
from __future__ import annotations
import json
import re
from functools import lru_cache
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
DEFAULT_INDEX = ROOT / "data" / "index"
# Historical rather than instructional. Not excluded β€” "what changed in 3.5?" is
# a fair question β€” just made to earn its place.
HISTORICAL = re.compile(r"^releases/|/release-notes", re.IGNORECASE)
HISTORICAL_PENALTY = 0.08
# When the question is about a version or a change, the penalty is lifted.
VERSION_QUERY = re.compile(
r"\b(v?\d+\.\d+|release|changelog|changed|new in|migration|deprecat\w*|"
r"breaking|since|upgrade|version)\b",
re.IGNORECASE,
)
@lru_cache(maxsize=4)
def load(index_dir: str):
import faiss
from sentence_transformers import SentenceTransformer
path = Path(index_dir)
if not (path / "docs.faiss").exists():
raise FileNotFoundError(
f"no index at {path} - run scripts/fetch_docs.py then scripts/build_index.py"
)
meta = json.loads((path / "meta.json").read_text(encoding="utf-8"))
chunks = [
json.loads(line)
for line in (path / "chunks.jsonl").read_text(encoding="utf-8").splitlines()
]
embedder = SentenceTransformer(meta["embedding_model"])
return faiss.read_index(str(path / "docs.faiss")), chunks, embedder, meta
def search(question: str, k: int = 6, index_dir: Path | str = DEFAULT_INDEX) -> list[dict]:
index, chunks, embedder, _ = load(str(index_dir))
vector = embedder.encode(
[question], convert_to_numpy=True, normalize_embeddings=True
).astype("float32")
# Over-fetch so re-ranking has something to promote from. Without this the
# penalty could only demote within an already-decided top k.
scores, ids = index.search(vector, min(k * 4, len(chunks)))
wants_history = bool(VERSION_QUERY.search(question))
hits: list[dict] = []
for score, position in zip(scores[0], ids[0]):
if position < 0:
continue
hit = dict(chunks[int(position)])
raw = float(score)
adjusted = raw
if not wants_history and HISTORICAL.search(hit.get("source", "")):
adjusted -= HISTORICAL_PENALTY
hit["score"] = round(adjusted, 4)
hit["raw_score"] = round(raw, 4)
hits.append(hit)
hits.sort(key=lambda item: item["score"], reverse=True)
return hits[:k]
def meta(index_dir: Path | str = DEFAULT_INDEX) -> dict:
return load(str(index_dir))[3]