01Re-index only changed files with delta queries and cTagVerified

Read the drive's delta changes since the last run and compare each file's cTag (the eTag of its content) with the value recorded last time. Only files whose content changed are downloaded again; deleted files go on a list for removal from the index.

Approach and steps
  1. On the first run, enumerate everything with /drives/{drive-id}/root/delta and save the deltaLink from the last response; later runs start from that deltaLink
  2. Index only items with a file facet and a .docx, .pptx or .pdf extension; skip folders and images
  3. Compare with the saved {id: cTag} map and download /content only for new items or items whose value changed; cTag does not change on metadata-only edits, so a rename does not trigger a download
  4. Items with a deleted facet go on a list so their chunks can be removed from the index
  5. Keep the decision in a plan_changes function and check it against a sample delta response (search_delta.json) before pointing it at a real drive
PythonSign-in required (Microsoft Entra)
"""Keep a search corpus in step with a drive: fetch only new or edited files, drop deleted ones."""
import asyncio
import json
import os
from pathlib import Path

from azure.identity import DeviceCodeCredential
from msgraph import GraphServiceClient

SCOPES = ["Files.Read.All"]
EXTS = (".docx", ".pptx", ".pdf")
STATE = Path(os.environ.get("INDEX_STATE", "out_index_state.json"))
RAW = Path(os.environ.get("RAW_DIR", "out_raw"))


def plan_changes(items, known):
    """items: delta results as dicts; known: {item id: cTag} from the last run."""
    fetch, drop = [], []
    for it in items:
        if it.get("deleted") is not None:
            if it["id"] in known:
                drop.append(it["id"])
            continue
        if not it.get("file") or not it.get("name", "").lower().endswith(EXTS):
            continue  # folders, images and other types are not indexed
        if known.get(it["id"]) != it.get("cTag"):
            fetch.append(it)  # new file, or its content changed (cTag ignores metadata-only edits)
    return fetch, drop


async def read_delta(builder, link):
    items, page = [], await builder.with_url(link).get()
    while True:
        for i in page.value or []:
            items.append({"id": i.id, "name": i.name or "", "cTag": i.c_tag,
                          "file": i.file is not None, "deleted": i.deleted})
        if not page.odata_next_link:
            return items, page.odata_delta_link
        page = await builder.with_url(page.odata_next_link).get()


async def main():
    drive_id = os.environ["DRIVE_ID"]
    cred = DeviceCodeCredential(client_id=os.environ["CLIENT_ID"], tenant_id=os.environ["TENANT_ID"])
    client = GraphServiceClient(credentials=cred, scopes=SCOPES)
    state = {"files": {}, "deltaLink": None}
    if STATE.exists():
        state = json.loads(STATE.read_text(encoding="utf-8"))
    items_rb = client.drives.by_drive_id(drive_id).items
    link = state["deltaLink"] or "https://graph.microsoft.com/v1.0/drives/%s/root/delta" % drive_id
    items, delta_link = await read_delta(items_rb.by_drive_item_id("root").delta, link)
    fetch, drop = plan_changes(items, state["files"])
    RAW.mkdir(exist_ok=True)
    for it in fetch:
        data = await items_rb.by_drive_item_id(it["id"]).content.get()
        (RAW / (it["id"] + Path(it["name"]).suffix.lower())).write_bytes(data)
        state["files"][it["id"]] = it["cTag"]
    for item_id in drop:
        state["files"].pop(item_id, None)  # also remove this id's chunks from your index
    state["deltaLink"] = delta_link
    STATE.write_text(json.dumps(state, indent=1), encoding="utf-8")
    print("to (re)index:", [it["name"] for it in fetch], "to drop:", drop)


if __name__ == "__main__":
    asyncio.run(main())
Output(2026-09-12)
fetch: ['在宅勤務規程.docx', '営業部定例_0910.docx']
drop: ['01FFF']
Example instruction for Copilot

With msgraph-sdk, write Python that reads the DRIVE_ID drive with delta, compares it with the {id: cTag} map saved last time, downloads only .docx, .pptx and .pdf files whose content changed, and lists deleted ids. Put the decision in plan_changes(items, known) and save the deltaLink for the next run.

Caution

If the saved deltaLink can no longer be used, the service returns 410 Gone and you must enumerate everything again (see the delta query tip on the sharepoint-python page). Files.Read.All reaches every file you can read; consider Sites.Selected to confine an app to specific sites. Local copies of the text sit outside the original permissions: decide where they live and who can read them, and stop using a copy and its chunks once the source file is deleted.

Availability
Microsoft Graph v1.0. For driveItem delta the least privileged delegated (work or school) permission is Files.Read; reading an organization's library needs Files.Read.All. Uses msgraph-sdk and azure-identity.
Requires
msgraph-sdk 1.62.0, azure-identity 1.25.3
Permissions
Files.Read.All (delegated)
Tested
run up to the sign-in step (not connected to Microsoft 365); aggregation/checking logic run on sample data (2026-09-12, Python 3.12.10 (venv) / Windows 11, msgraph-sdk 1.62.0, azure-identity 1.25.3)
Source
Microsoft Learn, "driveItem: delta"
Microsoft Learn, "driveItem resource type"
Microsoft Learn, "Download driveItem content"
Verified
2026-09-12 (v1)
Supporting passages from the sources
An eTag for the content of the item. This eTag isn't changed if only the metadata is changed.
Deleted items are returned with the deleted facet.
In these cases the service returns an HTTP 410 Gone error
Only driveItem objects with the file property can be downloaded.

02Extract PDF text with pypdf, pdfplumber and PyMuPDF, and compareVerified

Read the same PDF with three libraries and line up page count, character count and first line. Pages that yield almost no text are flagged as possibly image-only (scanned). Compare licence terms as well as output before choosing a library.

Approach and steps
  1. pypdf: call extract_text() on each page of PdfReader(path).pages; it is written in pure Python
  2. pdfplumber: call extract_text() on the pages of pdfplumber.open(path); it also exposes character positions and tables
  3. PyMuPDF: call get_text() on each page of pymupdf.open(path); it is built on MuPDF and is faster than pdfplumber
  4. Compare character counts across the three and look at pages where they differ a lot; send near-empty pages to OCR
  5. Before adopting one, check its licence (see availability) against your company's rules
PythonRuns locally
"""Extract text from one PDF with pypdf, pdfplumber and PyMuPDF, and compare what each returns."""
import pdfplumber
import pymupdf
from pypdf import PdfReader

PATH = "samples/search_policy.pdf"


def with_pypdf(path):
    return [page.extract_text() or "" for page in PdfReader(path).pages]


def with_pdfplumber(path):
    with pdfplumber.open(path) as pdf:
        return [page.extract_text() or "" for page in pdf.pages]


def with_pymupdf(path):
    with pymupdf.open(path) as doc:
        return [page.get_text() for page in doc]


for name, extract in [("pypdf", with_pypdf), ("pdfplumber", with_pdfplumber), ("pymupdf", with_pymupdf)]:
    pages = extract(PATH)
    chars = sum(len(t.strip()) for t in pages)
    lines = pages[0].strip().splitlines() if pages else []
    print("%-10s pages=%d chars=%d first line=%s" % (name, len(pages), chars, lines[0] if lines else "-"))
    thin = [n for n, t in enumerate(pages, start=1) if len(t.strip()) < 20]
    if thin:
        print("  little or no text on pages", thin, "- possibly scanned; OCR is needed")
Output(2026-09-12)
pypdf      pages=2 chars=256 first line=在宅勤務規程
pdfplumber pages=2 chars=256 first line=在宅勤務規程
pymupdf    pages=2 chars=256 first line=在宅勤務規程
Example instruction for Copilot

Write Python that reads every PDF in a folder with pypdf, pdfplumber and PyMuPDF and writes per-file, per-page character counts to a CSV. Mark pages with fewer than 20 characters in a scanned column. Do not modify the source PDFs.

Caution

Scanned PDFs have no text layer, so no library gets much text out of them (you need OCR). Multi-column layouts and tables can come out in the wrong order or with extra spaces. PyMuPDF is dual-licensed under the AGPL and a commercial licence; if your use cannot meet the AGPL's terms you need a commercial licence from Artifex. Have a person compare a sample of the extracted text with the original PDF.

Availability
pypdf: BSD-3-Clause (PyPI licence expression); pdfplumber: MIT License (PyPI); PyMuPDF: available under both open-source AGPL and commercial licence agreements (official docs). Tested versions are recorded in code.tested.
Requires
pypdf 6.18.1, pdfplumber 0.11.10, pymupdf 1.28.2
Tested
run and checked locally (2026-09-12, Python 3.12.10 (venv) / Windows 11, pypdf 6.18.1, pdfplumber 0.11.10, pymupdf 1.28.2)
Source
PyMuPDF documentation, "About"
PyPI, "pypdf"
PyPI, "pdfplumber"
pypdf documentation, "Extract Text from a PDF"
GitHub (jsvine/pdfplumber), "pdfplumber README"
Verified
2026-09-12 (v1)
Supporting passages from the sources
PyMuPDF and MuPDF are now available under both, open-source AGPL and commercial license agreements.
If you determine you cannot meet the requirements of the AGPL , please contact Artifex for more information regarding a commercial license.
License expression BSD-3-Clause
License MIT License
If a PDF page appears to contain only an image (e.g., a scanned document), the extracted text may be minimal or visually empty.
pymupdf is substantially faster than pdfminer.six (and thus also pdfplumber)
Works best on machine-generated, rather than scanned, PDFs.

03Chunk by headings, or by fixed size with overlapVerified

Split Word documents at heading paragraphs and keep the heading path on each chunk. Cut heading-less PDF text into fixed-size pieces that overlap slightly, so a sentence cut at a boundary survives whole in one of the chunks.

Approach and steps
  1. Read paragraphs in order with python-docx and split wherever style.name starts with Heading; take the level from the trailing number and keep a path such as 'Work > Remote work'
  2. Join the paragraphs between two headings into one chunk and attach the file name and heading as its source
  3. For PDFs, take each page's text, join the lines and cut it into fixed-size pieces, advancing by the size minus the overlap
  4. Keep chunks within what the embedding model reads: multilingual-e5-small truncates text beyond 512 tokens
  5. Write one chunk per line to out_chunks.jsonl as the input to indexing
PythonRuns locally
"""Chunk documents two ways: by Word headings, and by fixed size with overlap (PDF text)."""
import json

import docx
import pymupdf

SIZE, OVERLAP = 120, 30  # characters; keep chunks well inside the embedding model's input limit


def by_heading(path):
    chunks, heads, buf = [], [], []

    def flush():
        if buf:
            chunks.append({"source": path, "heading": " > ".join(heads), "text": "".join(buf)})
            buf.clear()

    for p in docx.Document(path).paragraphs:
        style = p.style.name if p.style is not None else ""
        if style.startswith("Heading"):
            flush()
            level = int(style.split()[-1]) if style.split()[-1].isdigit() else 1
            heads[:] = heads[:level - 1] + [p.text.strip()]
        elif p.text.strip():
            buf.append(p.text.strip())
    flush()
    return chunks


def fixed_size(text, source, size=SIZE, overlap=OVERLAP):
    text = "".join(line.strip() for line in text.splitlines())  # Japanese: join lines without spaces
    step = size - overlap
    return [{"source": source, "start": i, "text": text[i:i + size]}
            for i in range(0, max(len(text) - overlap, 1), step)]


chunks = by_heading("samples/search_handbook.docx")
with pymupdf.open("samples/search_policy.pdf") as pdf:
    for n, page in enumerate(pdf, start=1):
        chunks += fixed_size(page.get_text(), "search_policy.pdf#page=%d" % n)
with open("out_chunks.jsonl", "w", encoding="utf-8") as f:
    for c in chunks:
        f.write(json.dumps(c, ensure_ascii=False) + "\n")
print("chunks:", len(chunks))
for c in chunks[:3] + chunks[-2:]:
    print("-", c.get("heading") or "%s@%d" % (c["source"], c["start"]), "|", c["text"][:40])
Output(2026-09-12)
chunks: 10
- 社員ハンドブック | このハンドブックは、日々の業務で参照する社内のきまりをまとめたものである。
- 1 勤務 > 1.1 在宅勤務 | テレワークを行う日は、前営業日までに勤怠システムで申請する。自宅以外で働くときは
- 1 勤務 > 1.2 時間外労働 | 時間外労働は事前に所属長の承認を受ける。見込みが多い場合は人事部と協議する。
- search_policy.pdf#page=1@90 | 条(情報の取扱い)社外で業務を行うときは、画面ののぞき見と端末の置き忘れに注意す
- search_policy.pdf#page=2@0 | 経費精算規程第1条(申請の期限)立替えた経費は、発生した月の翌月10日までに申請
Example instruction for Copilot

Write Python that uses python-docx to split a Word document into chunks at Heading 1 to 3, attaches the heading path and file name to each chunk, and writes them as JSON Lines. Cut text without headings into 400-character pieces with an 80-character overlap.

Caution

In a Japanese copy of Word the style shows as a localized name, but style.name returns the English name, Heading 1. Headings made only with bold text are not detected by this method. Fixed-size cutting splits sentences, so also try cutting at sentence ends. Choose chunk size and overlap by looking at the evaluation results (search-python/e2e).

Availability
python-docx and PyMuPDF (for licences see search-python/pdf-text). Runs entirely locally.
Requires
python-docx 1.2.0, pymupdf 1.28.2
Tested
run and checked locally (2026-09-12, Python 3.12.10 (venv) / Windows 11, python-docx 1.2.0, pymupdf 1.28.2)
Source
Hugging Face (intfloat), "intfloat/multilingual-e5-small"
python-docx documentation, "Working with Styles"
Verified
2026-09-12 (v1)
Supporting passages from the sources
Long texts will be truncated to at most 512 tokens.
Built-in styles are stored in a WordprocessingML file using their English name, e.g. ‘Heading 1’, even though users working on a localized version of Word will see native language names in the UI

04TF-IDF and BM25 baselines for Japanese using character n-gramsVerified

Japanese has no spaces between words, so TfidfVectorizer uses analyzer="char_wb" to count character sequences, and BM25 gets overlapping two-character tokens. Both do well when the query shares words with the document and poorly on paraphrases, which makes them the baseline for the methods that follow.

Approach and steps
  1. TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 3)) counts two- and three-character sequences; the default analyzer="word" splits on spaces and punctuation, which leaves each Japanese sentence as one long token
  2. TF-IDF rows are normalised to unit length by default, so the dot product with the query vector works as a similarity score
  3. rank_bm25 does no preprocessing: tokenise documents and queries with the same function before handing them to BM25Okapi
  4. get_scores(query tokens) scores every document; sort to get the top results
  5. If you switch to a morphological analyser for tokenising, the same rule applies: treat documents and queries identically
PythonRuns locally
"""Keyword baselines for Japanese text: TF-IDF on character n-grams, and BM25 on character bigrams."""
import json

from rank_bm25 import BM25Okapi
from sklearn.feature_extraction.text import TfidfVectorizer

with open("samples/search_corpus.jsonl", encoding="utf-8") as f:
    docs = [json.loads(line) for line in f]
texts = [d["title"] + " " + d["text"] for d in docs]


def bigrams(s):
    s = "".join(s.split()).lower()  # no word spaces in Japanese: use overlapping 2-character tokens
    return [s[i:i + 2] for i in range(len(s) - 1)]


tfidf = TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 3), sublinear_tf=True)
X = tfidf.fit_transform(texts)  # rows are L2-normalised, so a dot product is cosine similarity
bm25 = BM25Okapi([bigrams(t) for t in texts])


def top(scores, k=3):
    order = sorted(range(len(scores)), key=lambda i: -scores[i])[:k]
    return ", ".join("%s %s (%.2f)" % (docs[i]["id"], docs[i]["title"], scores[i]) for i in order)


for q in ["経費の申請の締め日", "家で仕事をする日の届け出"]:
    s_tfidf = (X @ tfidf.transform([q]).T).toarray().ravel()
    s_bm25 = bm25.get_scores(bigrams(q))
    print("Q:", q)
    print("  tfidf:", top(s_tfidf))
    print("  bm25 :", top(s_bm25))
Output(2026-09-12)
Q: 経費の申請の締め日
  tfidf: D15 FAQ:経費の締め日 (0.52), D02 経費精算規程 (0.09), D23 議事録:業務改善会議 (0.06)
  bm25 : D15 FAQ:経費の締め日 (26.67), D02 経費精算規程 (5.79), D23 議事録:業務改善会議 (4.51)
Q: 家で仕事をする日の届け出
  tfidf: D34 年次有給休暇規程 (0.14), D31 副業規程 (0.13), D01 在宅勤務規程 (0.11)
  bm25 : D31 副業規程 (8.16), D34 年次有給休暇規程 (6.23), D01 在宅勤務規程 (5.01)
Example instruction for Copilot

Write Python that searches documents stored as JSON Lines with scikit-learn's TfidfVectorizer (analyzer="char_wb", ngram_range=(2, 3)) and rank_bm25's BM25Okapi on two-character tokens. For each query, list the top 3 from both with ids and scores.

Caution

Because matching is on character sequences, paraphrases ("notify" vs "apply", "working from home" vs "telework") are missed; in this sample the query about working from home did not put the telework policy first (see the output). BM25 scores are on a different scale for each query, so do not compare scores across queries.

Availability
scikit-learn (PyPI licence expression: BSD-3-Clause) and rank-bm25 (GitHub: Apache-2.0 license). Runs locally; no data leaves the machine.
Requires
scikit-learn 1.9.1, rank-bm25 0.2.2
Tested
run and checked locally (2026-09-12, Python 3.12.10 (venv) / Windows 11, scikit-learn 1.9.1, rank-bm25 0.2.2)
Source
scikit-learn, "TfidfVectorizer"
GitHub (dorianbrown/rank_bm25), "Rank-BM25 README"
PyPI, "scikit-learn"
Verified
2026-09-12 (v1)
Supporting passages from the sources
Option ‘char_wb’ creates character n-grams only from text inside word boundaries
Each output row will have unit norm
Note that this package doesn't do any text preprocessing.
The only requirements is that the class receives a list of lists of strings, which are the document tokens.
Apache-2.0 license
License expression BSD-3-Clause

05Find documents by meaning with multilingual embeddings and FAISSVerified

Turn documents and queries into 384-dimensional vectors with sentence-transformers and search a FAISS index by inner product. With unit-length vectors the inner product equals cosine similarity, so documents close in meaning rank high even when the query uses different words.

Approach and steps
  1. The model is intfloat/multilingual-e5-small; per its model card it has 12 layers, 384-dimensional embeddings and an MIT licence
  2. e5 models expect a 'passage: ' prefix on documents and 'query: ' on queries, including Japanese text
  3. encode(..., normalize_embeddings=True) gives unit-length vectors; convert them to a float32 array
  4. add them to faiss.IndexFlatIP(dim) and call index.search(query_vector, 3); a flat index compares against every vector (brute force)
  5. Save the index with faiss.write_index and keep the vector-number-to-document-id mapping alongside it
PythonRuns locally
"""Semantic search: embed documents with a multilingual model and search them with a FAISS index."""
import json

import faiss
import numpy as np
from sentence_transformers import SentenceTransformer

MODEL = "intfloat/multilingual-e5-small"  # 384-dimensional vectors (see the model card)

with open("samples/search_corpus.jsonl", encoding="utf-8") as f:
    docs = [json.loads(line) for line in f]
model = SentenceTransformer(MODEL)
# e5 models expect a "passage: " prefix on documents and "query: " on queries
vecs = model.encode(["passage: " + d["title"] + " " + d["text"] for d in docs],
                    normalize_embeddings=True, batch_size=16)
vecs = np.asarray(vecs, dtype="float32")
index = faiss.IndexFlatIP(vecs.shape[1])  # inner product of unit vectors = cosine similarity
index.add(vecs)  # vector i <-> docs[i]; keep this id mapping next to the saved index
faiss.write_index(index, "out_corpus.faiss")
print("dim:", vecs.shape[1], "vectors:", index.ntotal)

for q in ["家で仕事をする日の届け出", "ログインの合言葉を忘れてしまった"]:
    qv = np.asarray(model.encode(["query: " + q], normalize_embeddings=True), dtype="float32")
    scores, ids = index.search(qv, 3)
    print("Q:", q)
    for s, i in zip(scores[0], ids[0]):
        print("  %.3f %s %s" % (s, docs[i]["id"], docs[i]["title"]))
Output(2026-09-12)
dim: 384 vectors: 40
Q: 家で仕事をする日の届け出
  0.852 D01 在宅勤務規程
  0.836 D06 育児・介護休業規程
  0.833 D28 手順書:退職時の手続き
Q: ログインの合言葉を忘れてしまった
  0.864 D11 FAQ:パスワードの再設定
  0.822 D18 FAQ:健康診断
  0.820 D14 FAQ:名刺の発注
Example instruction for Copilot

Write Python that embeds JSON Lines documents with sentence-transformers' intfloat/multilingual-e5-small (prefix documents with 'passage: ' and queries with 'query: '), searches them with faiss IndexFlatIP for the top 3, normalises with normalize_embeddings=True, and saves the index to a file.

Caution

The first run downloads the model files from Hugging Face (about 470 MB for this e5-small); check first if your workplace requires approval for that. Afterwards HF_HUB_OFFLINE=1 keeps it to local files. Documents are not sent anywhere; the vectors are computed on the local CPU. Text beyond 512 tokens is truncated, so chunk long documents first. Exact-match needs such as product codes can be missed by embeddings alone.

Availability
sentence-transformers (PyPI: Apache-2.0), faiss-cpu (Faiss is MIT-licensed, GitHub), model intfloat/multilingual-e5-small (model card license: mit). Runs on CPU only.
Requires
sentence-transformers 6.0.1, faiss-cpu 1.15.0, numpy 2.5.3, torch 2.14.0+cpu
Tested
run and checked locally (2026-09-12, Python 3.12.10 (venv) / Windows 11, sentence-transformers 6.0.1, faiss-cpu 1.15.0, numpy 2.5.3, torch 2.14.0+cpu)
Source
GitHub (facebookresearch/faiss), "MetricType and distances"
Hugging Face (intfloat), "intfloat/multilingual-e5-small"
GitHub (facebookresearch/faiss), "Getting started"
Sentence Transformers, "Semantic Search"
Verified
2026-09-12 (v1)
Supporting passages from the sources
By normalizing query and database vectors beforehand, the problem can be mapped back to a maximum inner product search.
This model has 12 layers and the embedding size is 384.
Each input text should start with "query: " or "passage: ", even for non-English texts.
"license":"mit"
Long texts will be truncated to at most 512 tokens.
we are going to use the simplest version that just performs brute-force L2 distance search on them: IndexFlatL2
Faiss is MIT-licensed, refer to the LICENSE file in the top level directory.
License expression Apache-2.0

06Hybrid search: fuse BM25 and embedding rankings with RRFVerified

Merge the two rankings from keyword matching (BM25) and meaning (embeddings) into one with Reciprocal Rank Fusion (RRF). It uses only rank positions, so there is no need to reconcile two scores on different scales.

Approach and steps
  1. Run BM25 and embedding search for the same query and keep a ranked list of the top results from each (depth in the code)
  2. In each list give a document 1/(rank + k), sum per document and re-sort; k is a constant, and the Azure AI Search docs say a small value such as 60 works best
  3. To favour one side, multiply each list by a weight (weights in the code), the same idea as vector weighting in Azure AI Search
  4. Look at the top results of each method for queries where exact words matter (such as product codes) and for paraphrased ones
  5. Adopt the fused ranking only after checking on the evaluation questions (search-python/e2e) that it beats the single methods
PythonRuns locally
"""Hybrid search: fuse a BM25 ranking and an embedding ranking with Reciprocal Rank Fusion (RRF)."""
import json

import numpy as np
from rank_bm25 import BM25Okapi
from sentence_transformers import SentenceTransformer

K_RRF = 60  # the constant k in 1/(rank + k)

with open("samples/search_corpus.jsonl", encoding="utf-8") as f:
    docs = [json.loads(line) for line in f]
texts = [d["title"] + " " + d["text"] for d in docs]


def bigrams(s):
    s = "".join(s.split()).lower()
    return [s[i:i + 2] for i in range(len(s) - 1)]


bm25 = BM25Okapi([bigrams(t) for t in texts])
model = SentenceTransformer("intfloat/multilingual-e5-small")
emb = model.encode(["passage: " + t for t in texts], normalize_embeddings=True)


def rrf(rankings, k=K_RRF, weights=None):
    fused = {}
    for n, ranking in enumerate(rankings):
        w = 1.0 if weights is None else weights[n]
        for rank, doc in enumerate(ranking, start=1):
            fused[doc] = fused.get(doc, 0.0) + w / (rank + k)
    return sorted(fused, key=fused.get, reverse=True)


def search(q, depth=20):
    lexical = [int(i) for i in np.argsort(-bm25.get_scores(bigrams(q)))[:depth]]
    qv = model.encode(["query: " + q], normalize_embeddings=True)[0]
    semantic = [int(i) for i in np.argsort(-(emb @ qv))[:depth]]
    return {"bm25": lexical, "embed": semantic, "hybrid": rrf([lexical, semantic])}


for q in ["VPNにつながらない", "家で仕事をする日の届け出", "高い機械を買うときに必要な承認"]:
    print("Q:", q)
    for method, ranking in search(q).items():
        print("  %-6s %s" % (method, " ".join(docs[i]["id"] for i in ranking[:3])))
Output(2026-09-12)
Q: VPNにつながらない
  bm25   D12 D06 D31
  embed  D12 D11 D05
  hybrid D12 D20 D05
Q: 家で仕事をする日の届け出
  bm25   D31 D34 D01
  embed  D01 D06 D28
  hybrid D01 D31 D28
Q: 高い機械を買うときに必要な承認
  bm25   D01 D19 D32
  embed  D08 D23 D26
  hybrid D23 D32 D01
Example instruction for Copilot

Write a Python function rrf(rankings, k=60, weights=None) that fuses a BM25 ranking and an embedding ranking with Reciprocal Rank Fusion: count ranks from 1, add weight * 1/(rank + k) per document, and return document ids sorted by the fused score.

Caution

RRF also lifts documents that only one method ranked high. In this sample's evaluation, BM25 misses paraphrased queries, so unweighted RRF scored below embeddings alone (see the search-python/e2e output). Hybrid is not automatically better: set the weights, the number of BM25 candidates and whether to rerank by looking at the evaluation.

Availability
Needs only rank-bm25, sentence-transformers and numpy locally. The RRF formula follows the Azure AI Search documentation (hybrid search scoring).
Requires
rank-bm25 0.2.2, sentence-transformers 6.0.1, numpy 2.5.3, torch 2.14.0+cpu
Tested
run and checked locally (2026-09-12, Python 3.12.10 (venv) / Windows 11, rank-bm25 0.2.2, sentence-transformers 6.0.1, numpy 2.5.3, torch 2.14.0+cpu)
Source
Microsoft Learn, "Relevance scoring in hybrid search using Reciprocal Rank Fusion (RRF)"
Verified
2026-09-12 (v1)
Supporting passages from the sources
The score is calculated as 1/(rank + k), where rank is the position of the document in the list and k is a constant.
Experiments show the algorithm performs best when you set k to a small value, such as 60.
If you add vector weighting, the initial scores are subject to a weighting multiplier that increases or decreases the score.

07Rerank only the top candidates with a cross-encoderVerified

Narrow the field with a fast search, then score each query-candidate pair with a cross-encoder and re-sort. Scoring pair by pair is slower but usually ranks better. The model is cl-nagoya/ruri-v3-reranker-310m, a Japanese reranker.

Approach and steps
  1. Take the top candidates from a first stage: BM25, embeddings or hybrid (DEPTH in the code)
  2. Load CrossEncoder(model_name) and call rank(query, candidate_texts, top_k=3); it returns each candidate's position (corpus_id) and a score
  3. Map corpus_id back to the first-stage document ids for display
  4. More candidates means fewer misses but one more model call per pair; check the speed-quality trade-off in the evaluation
  5. The model is a Japanese reranker built on ModernBERT-Ja and published under the Apache License 2.0
PythonRuns locally
"""Rerank the top candidates from a fast first-stage search with a cross-encoder."""
import json

import numpy as np
from rank_bm25 import BM25Okapi
from sentence_transformers import CrossEncoder

RERANKER = "cl-nagoya/ruri-v3-reranker-310m"  # Japanese reranker, Apache 2.0 (model card)
DEPTH = 10  # how many first-stage candidates the reranker reads

with open("samples/search_corpus.jsonl", encoding="utf-8") as f:
    docs = [json.loads(line) for line in f]
texts = [d["title"] + " " + d["text"] for d in docs]


def bigrams(s):
    s = "".join(s.split()).lower()
    return [s[i:i + 2] for i in range(len(s) - 1)]


bm25 = BM25Okapi([bigrams(t) for t in texts])
reranker = CrossEncoder(RERANKER)

for q in ["立て替えたお金を返してもらう手続きの期限", "高い機械を買うときに必要な承認"]:
    cand = [int(i) for i in np.argsort(-bm25.get_scores(bigrams(q)))[:DEPTH]]
    ranked = reranker.rank(q, [texts[i] for i in cand], top_k=3)
    print("Q:", q)
    print("  bm25  :", " ".join(docs[i]["id"] for i in cand[:3]))
    print("  rerank:", " ".join("%s(%.2f)" % (docs[cand[r["corpus_id"]]]["id"], r["score"]) for r in ranked))
Output(2026-09-12)
Q: 立て替えたお金を返してもらう手続きの期限
  bm25  : D28 D02 D07
  rerank: D02(0.17) D30(0.01) D28(0.00)
Q: 高い機械を買うときに必要な承認
  bm25  : D01 D19 D32
  rerank: D01(0.00) D33(0.00) D13(0.00)
Example instruction for Copilot

Write Python that reranks the BM25 top 10 with sentence-transformers' CrossEncoder (cl-nagoya/ruri-v3-reranker-310m) using rank(), and prints the top 3 before and after with document ids and scores.

Caution

A reranker can only reorder the candidates it is given; if the first stage misses the right document, reranking cannot fix it (in the second query of the output, BM25's candidates lack the answer and every score is low). The model files are about 1.2 GB and even ten candidates take seconds on a CPU, which is why the e2e example reranks only with RERANK=1.

Availability
sentence-transformers' CrossEncoder with torch (CPU is fine). The model card for cl-nagoya/ruri-v3-reranker-310m states the Apache License, Version 2.0.
Requires
sentence-transformers 6.0.1, rank-bm25 0.2.2, numpy 2.5.3, torch 2.14.0+cpu, transformers 5.17.0
Tested
run and checked locally (2026-09-12, Python 3.12.10 (venv) / Windows 11, sentence-transformers 6.0.1, rank-bm25 0.2.2, numpy 2.5.3, torch 2.14.0+cpu, transformers 5.17.0)
Source
Sentence Transformers, "Cross Encoder Usage"
Hugging Face (cl-nagoya), "cl-nagoya/ruri-v3-reranker-310m"
GitHub (dorianbrown/rank_bm25), "Rank-BM25 README"
Verified
2026-09-12 (v1)
Supporting passages from the sources
Cross Encoders are often used to re-rank the top-k results from a Sentence Transformer model.
Often slower than a Sentence Transformer model, as it requires computation for each pair rather than each text.
This model is published under the Apache License, Version 2.0.
Ruri-v3 Reranker is a general-purpose Japanese reranker model built on top of ModernBERT-Ja.
from rank_bm25 import BM25Okapi

08Get permission-trimmed extracts with the Copilot Retrieval APINeeds check

Send a natural-language question to POST /copilot/retrieval and get back relevant text extracts from SharePoint, OneDrive or Copilot connectors. There is no separate index to build, and results are trimmed to what the calling user can read.

Approach and steps
  1. Put queryString (a single natural-language sentence) and dataSource (sharePoint, oneDriveBusiness or externalItem) in the body; maximumNumberOfResults goes from 1 to 25
  2. To narrow the scope, write KQL in filterExpression (for example path:"site URL"); if the KQL syntax is wrong the query still runs, without any scoping
  3. The local msgraph-sdk has no dedicated method for this call, so the request is sent through the SDK's request_adapter, reusing the SDK's credential
  4. From retrievalHits take webUrl, extracts, resourceMetadata and sensitivityLabel; extracts are unordered, so pass all of them to the LLM
  5. Choosing: use this API to search Microsoft 365 content within each user's permissions; use a local index when you want to evaluate and tune ranking on your own copy
PythonSign-in required (Microsoft Entra)
"""Get grounding extracts from SharePoint through the Microsoft 365 Copilot Retrieval API (read-only)."""
import asyncio
import json
import os

from azure.identity import DeviceCodeCredential
from kiota_abstractions.method import Method
from kiota_abstractions.request_information import RequestInformation
from msgraph import GraphServiceClient

SCOPES = ["Files.Read.All", "Sites.Read.All"]
URL = "https://graph.microsoft.com/v1.0/copilot/retrieval"


def build_body(query, site_url=None, max_results=10):
    body = {"queryString": query, "dataSource": "sharePoint",
            "resourceMetadata": ["title", "author"], "maximumNumberOfResults": max_results}
    if site_url:
        body["filterExpression"] = 'path:"%s"' % site_url  # KQL: only this site
    return body


def to_rows(resp):
    rows = []
    for hit in resp.get("retrievalHits", []):
        meta = hit.get("resourceMetadata") or {}
        label = (hit.get("sensitivityLabel") or {}).get("displayName", "")
        for ex in hit.get("extracts", []):
            rows.append({"title": meta.get("title", ""), "url": hit.get("webUrl", ""), "label": label,
                         "score": ex.get("relevanceScore"), "text": ex.get("text", "")})
    return rows


async def main():
    cred = DeviceCodeCredential(client_id=os.environ["CLIENT_ID"], tenant_id=os.environ["TENANT_ID"])
    client = GraphServiceClient(credentials=cred, scopes=SCOPES)
    body = build_body(os.environ.get("QUERY", "在宅勤務を申請するときの期限と手順を知りたい"),
                      os.environ.get("SITE_URL"))
    req = RequestInformation(Method.POST)  # POST carries the query; nothing in Microsoft 365 is changed
    req.url = URL
    req.set_stream_content(json.dumps(body, ensure_ascii=False).encode("utf-8"), "application/json")
    raw = await client.request_adapter.send_primitive_async(req, "bytes", None)
    for r in to_rows(json.loads(raw)):
        print("%s | %s | %s" % (r["title"], r["label"] or "-", r["text"][:60]))


if __name__ == "__main__":
    asyncio.run(main())
Output(2026-09-12)
4 在宅勤務規程 社内限
path:"https://example.sharepoint.com/sites/HR/"
Example instruction for Copilot

Write Python that calls POST https://graph.microsoft.com/v1.0/copilot/retrieval through msgraph-sdk's request_adapter with dataSource=sharePoint. Split it into build_body for the request and to_rows that turns the response into title, URL, label and extract rows. Use delegated Files.Read.All and Sites.Read.All.

Caution

Only delegated permissions work; application permissions are not supported. SharePoint and OneDrive need both Files.Read.All and Sites.Read.All. There is a limit of 200 requests per user per hour. The POST only carries the query; it does not change Microsoft 365 data. Extracts are candidate evidence: open the source document to confirm before relying on them.

Availability
Endpoints exist in Microsoft Graph v1.0 and beta (beta APIs may change and are not supported in production). Free of extra cost for users with a Microsoft 365 Copilot add-on licence; users without one can use pay-as-you-go (preview) for SharePoint and Copilot connectors but not OneDrive. Use means agreeing to the Microsoft 365 Copilot APIs Terms of Use (preview). Global service only.
Requires
msgraph-sdk 1.62.0, azure-identity 1.25.3
Permissions
Files.Read.All, Sites.Read.All (delegated)
Tested
run up to the sign-in step (not connected to Microsoft 365); aggregation/checking logic run on sample data (2026-09-12, Python 3.12.10 (venv) / Windows 11, msgraph-sdk 1.62.0, azure-identity 1.25.3)
Practical edition
Grounding Copilot in Your Stored Content ── Copilot surfaces only data you can at least view
Grounding Copilot in Your Stored Content ── How the semantic index works and how to exclude a site
Source
Microsoft Learn, "Overview of the Microsoft 365 Copilot Retrieval API"
Microsoft Learn, "Retrieve grounding data"
Microsoft Learn, "Microsoft 365 Copilot APIs Overview"
Verified
2026-09-12 (v1)
Supporting passages from the sources
The Retrieval API offers a streamlined solution for Retrieval Augmented Generation (RAG) without the need to replicate, index, chunk, and secure your data in a separate index.
The API security trims content for the calling user and respects the defined access controls within the tenant.
You need the Files.Read.All and Sites.Read.All permissions to retrieve SharePoint content using the Retrieval API.
The maximumNumberOfResults request parameter has a maximum value of 25.
Up to 200 requests per user per hour are supported.
If the filterExpression request parameter has incorrect KQL syntax, the query successfully executes with no scoping.
The results and extracts returned by the Retrieval API are unordered.
The Retrieval API is available at no extra cost to users with a Microsoft 365 Copilot add-on license.
For users without a Microsoft 365 Copilot add-on license, the Retrieval API is available via pay-as-you-go consumption (preview) for tenant-level data sources such as SharePoint and Copilot connectors. User-level data sources such as OneDrive aren't available.
APIs under the /beta version are subject to change. Use of these APIs in production applications is not supported.
POST https://graph.microsoft.com/v1.0/copilot/retrieval

09Query Azure AI Search with keywords and vectors in one requestVerified

Passing both search_text and vector_queries to SearchClient.search in azure-search-documents runs full-text and vector search in parallel and merges them with RRF into one result. Sign in with Microsoft Entra ID (DefaultAzureCredential) so no key appears in code.

Approach and steps
  1. When building the index, include fields for chunk text, title and URL, plus a vector field (contentVector here)
  2. If the vector field has a vectorizer, VectorizableTextQuery(text=query, ...) lets the service embed the query text; otherwise pass a locally computed vector with VectorizedQuery
  3. Create SearchClient(endpoint, index_name, DefaultAzureCredential()); the calling user or service principal needs the Search Index Data Reader role
  4. Iterate over search(search_text=..., vector_queries=[...], select=[...], top=5) and print @search.score and the fields
  5. Choosing: to search Microsoft 365 content within each user's permissions, the Retrieval API (separate tip) is enough; with Azure AI Search you design and run the index contents and who can read them yourself
PythonSign-in required (Microsoft Entra)
"""Hybrid (keyword + vector) query against an Azure AI Search index, signed in with Microsoft Entra ID."""
import os

from azure.identity import DefaultAzureCredential
from azure.search.documents import SearchClient
from azure.search.documents.models import VectorizableTextQuery

ENDPOINT = os.environ.get("SEARCH_ENDPOINT", "https://example.search.windows.net")
INDEX = os.environ.get("SEARCH_INDEX", "docs-chunks")


def hybrid(client, text, k=5):
    # the index's vector field needs a vectorizer so the service can embed the query text
    vq = VectorizableTextQuery(text=text, k_nearest_neighbors=50, fields="contentVector")
    return client.search(search_text=text, vector_queries=[vq], select=["title", "url", "chunk"], top=k)


def main():
    client = SearchClient(ENDPOINT, INDEX, DefaultAzureCredential())  # needs "Search Index Data Reader"
    for r in hybrid(client, "在宅勤務を申請するときの期限"):
        print("%.4f | %s | %s" % (r["@search.score"], r["title"], r["url"]))


if __name__ == "__main__":
    main()
Output(2026-09-12)
contentVector 50 5 telework policy
Example instruction for Copilot

Write Python for a hybrid query with azure-search-documents' SearchClient and DefaultAzureCredential that passes search_text and a VectorizableTextQuery (fields="contentVector") together. Read the endpoint and index name from environment variables and do not use an API key.

Caution

An Azure AI Search index is a separate index holding copies of your documents, so the original SharePoint permissions have to be designed again on the index side. The vector field's dimensions must match the embedding model used to fill the index. VectorizableTextQuery does not work on an index without a vectorizer. Charges follow Azure's pricing for the service.

Availability
azure-search-documents (Learn README: Python 3.8 or later). With Entra ID the Search Index Data Reader role is required. Integrated vectorization, which embeds query text in the service, is generally available.
Requires
azure-search-documents 12.0.0, azure-identity 1.25.3
Tested
run up to the sign-in step (not connected to Microsoft 365); aggregation/checking logic run on sample data (2026-09-12, Python 3.12.10 (venv) / Windows 11, azure-search-documents 12.0.0, azure-identity 1.25.3)
Source
Microsoft Learn, "Azure AI Search client library for Python"
Microsoft Learn, "Create a vector query in Azure AI Search"
Microsoft Learn, "Relevance scoring in hybrid search using Reciprocal Rank Fusion (RRF)"
Verified
2026-09-12 (v1)
Supporting passages from the sources
Your user or service principal must be assigned the "Search Index Data Reader" role.
A prerequisite is a search index that has a vectorizer configured and assigned to a vector field.
A second approach is to use integrated vectorization, now generally available, to have Azure AI Search handle your query vectorization inputs and outputs.
In Azure AI Search, RRF is used when two or more queries execute in parallel, such as hybrid queries and multiple vector queries.
Python 3.8 or later is required to use this package.

10End to end: score each search method with recall@k and MRR on paraphrased questionsVerified

Build fictional internal documents (policies, minutes, FAQs) and a set of paraphrased questions with their correct documents. Run TF-IDF, BM25, embeddings with FAISS, and hybrid on the same questions, line up recall@k and MRR, and list which method misses which question.

Approach and steps
  1. research/samples_search.py creates the documents and questions; the questions deliberately use different wording from their correct documents
  2. Store the question set as {question id: set of correct document ids}, the same shape as relevant_docs in sentence-transformers' InformationRetrievalEvaluator
  3. From each method's ranked list, compute recall@k (share of correct documents found in the top k) and MRR (mean reciprocal rank of the first correct document) in your own code; anything below the top 10 counts as not found
  4. For each question, list the rank of the first correct document by method, and have a person read the misses to find the cause (paraphrase, abbreviation, how the document is written)
  5. Run with RERANK=1 to add a cross-encoder rerank of the hybrid top results (slow on a CPU)
  6. Write the scores to out_search_eval.csv and use it to compare changes to chunking, model or weights
PythonRuns locally
"""End to end: search one corpus with several methods and score each with recall@k and MRR."""
import csv
import json
import os

import numpy as np
from rank_bm25 import BM25Okapi
from sklearn.feature_extraction.text import TfidfVectorizer

KS = (1, 3, 5)
DEPTH = 10  # a relevant document ranked below this counts as not found (MRR@10)

with open("samples/search_corpus.jsonl", encoding="utf-8") as f:
    docs = [json.loads(line) for line in f]
with open("samples/search_queries.json", encoding="utf-8") as f:
    queries = json.load(f)  # [{"id", "query", "relevant": [doc ids]}]
texts = [d["title"] + " " + d["text"] for d in docs]
ids = [d["id"] for d in docs]


def bigrams(s):
    s = "".join(s.split()).lower()
    return [s[i:i + 2] for i in range(len(s) - 1)]


def ranking(scores):
    return [ids[i] for i in np.argsort(-np.asarray(scores))[:DEPTH]]


def rrf(rankings, k=60):
    fused = {}
    for r in rankings:
        for rank, d in enumerate(r, start=1):
            fused[d] = fused.get(d, 0.0) + 1.0 / (rank + k)
    return sorted(fused, key=fused.get, reverse=True)[:DEPTH]


def first_hit(got, relevant):
    return next((n for n, d in enumerate(got, start=1) if d in relevant), None)


def evaluate(results):
    """results: {query id: ranked doc ids} -> mean recall@k for each k in KS, and MRR@DEPTH."""
    out = {"recall@%d" % k: 0.0 for k in KS}
    out["MRR@%d" % DEPTH] = 0.0
    for q in queries:
        got, rel = results[q["id"]], set(q["relevant"])
        for k in KS:
            out["recall@%d" % k] += len(rel & set(got[:k])) / len(rel) / len(queries)
        r = first_hit(got, rel)
        out["MRR@%d" % DEPTH] += (1.0 / r if r else 0.0) / len(queries)
    return out


methods = {}
tfidf = TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 3), sublinear_tf=True)
X = tfidf.fit_transform(texts)
methods["tfidf"] = {q["id"]: ranking((X @ tfidf.transform([q["query"]]).T).toarray().ravel()) for q in queries}
bm25 = BM25Okapi([bigrams(t) for t in texts])
methods["bm25"] = {q["id"]: ranking(bm25.get_scores(bigrams(q["query"]))) for q in queries}

try:  # the semantic steps need the model files; set HF_HUB_OFFLINE=1 to stay offline
    import faiss
    from sentence_transformers import CrossEncoder, SentenceTransformer
    model = SentenceTransformer(os.environ.get("EMBED_MODEL", "intfloat/multilingual-e5-small"))
except Exception as e:
    model = None
    print("embedding skipped:", type(e).__name__, e)
if model is not None:
    emb = model.encode(["passage: " + t for t in texts], normalize_embeddings=True)
    index = faiss.IndexFlatIP(emb.shape[1])
    index.add(np.asarray(emb, dtype="float32"))
    qv = model.encode(["query: " + q["query"] for q in queries], normalize_embeddings=True)
    _, hits = index.search(np.asarray(qv, dtype="float32"), DEPTH)
    methods["embed"] = {q["id"]: [ids[i] for i in hits[n]] for n, q in enumerate(queries)}
    methods["hybrid"] = {q["id"]: rrf([methods["bm25"][q["id"]], methods["embed"][q["id"]]]) for q in queries}
    if os.environ.get("RERANK", "0") == "1":  # slow on CPU: opt in with RERANK=1
        try:
            ce = CrossEncoder(os.environ.get("RERANK_MODEL", "cl-nagoya/ruri-v3-reranker-310m"))
            by_id = dict(zip(ids, texts))
            reranked = {}
            for q in queries:
                cand = methods["hybrid"][q["id"]]
                order = ce.rank(q["query"], [by_id[d] for d in cand])
                reranked[q["id"]] = [cand[r["corpus_id"]] for r in order]
            methods["hybrid+rerank"] = reranked
        except Exception as e:
            print("rerank skipped:", type(e).__name__, e)

names = list(methods)
scores = {n: evaluate(methods[n]) for n in names}
cols = list(scores[names[0]])
print("%-14s" % "method" + "".join("%10s" % c for c in cols))
for n in names:
    print("%-14s" % n + "".join("%10.3f" % scores[n][c] for c in cols))
with open("out_search_eval.csv", "w", newline="", encoding="utf-8") as f:
    w = csv.writer(f)
    w.writerow(["method"] + cols)
    w.writerows([[n] + [round(scores[n][c], 4) for c in cols] for n in names])
print("\nrank of the first relevant document per query (- = not in top %d)" % DEPTH)
for q in queries:
    cells = [str(first_hit(methods[n][q["id"]], set(q["relevant"])) or "-") for n in names]
    print(q["id"], " ".join("%s=%s" % (n, c) for n, c in zip(names, cells)), "|", q["query"])
Output(2026-09-12)
method          recall@1  recall@3  recall@5    MRR@10
tfidf              0.438     0.719     0.781     0.578
bm25               0.438     0.594     0.656     0.544
embed              0.906     0.906     0.906     0.944
hybrid             0.594     0.719     0.781     0.711

rank of the first relevant document per query (- = not in top 10)
Q01 tfidf=4 bm25=5 embed=1 hybrid=2 | 家で仕事をする日の届け出はいつまでに出すか
Q02 tfidf=2 bm25=2 embed=1 hybrid=1 | 立て替えたお金を返してもらう手続きの期限
Q03 tfidf=1 bm25=1 embed=1 hybrid=1 | 出張でホテルに泊まるときの金額の上限
Q04 tfidf=1 bm25=1 embed=1 hybrid=1 | ログインの合言葉を忘れてしまった
Q05 tfidf=1 bm25=1 embed=1 hybrid=1 | 自宅から会社のネットワークに入れない
Q06 tfidf=3 bm25=- embed=1 hybrid=6 | 子どもが生まれたので長く休みたい
Q07 tfidf=- bm25=- embed=1 hybrid=8 | 高い機械を買うときに必要な承認
Q08 tfidf=1 bm25=1 embed=1 hybrid=1 | 取引先との約束事の書面を法律の担当に見てもらうには
Q09 tfidf=3 bm25=- embed=1 hybrid=4 | 給料の内訳をネットで確認したい
Q10 tfidf=1 bm25=1 embed=1 hybrid=1 | 怪しいメールが届いたらどうするか
Q11 tfidf=- bm25=- embed=1 hybrid=3 | 会社を辞めるときに返すもの
Q12 tfidf=- bm25=- embed=9 hybrid=- | 残業が多くなりそうなときの相談先
Q13 tfidf=1 bm25=1 embed=1 hybrid=1 | 引っ越しをしたときの通勤手当の変更
Q14 tfidf=3 bm25=2 embed=1 hybrid=1 | パソコンの買い替えの予定
Q15 tfidf=2 bm25=2 embed=1 hybrid=1 | 有休を半日だけ取りたい
Q16 tfidf=1 bm25=1 embed=1 hybrid=1 | お客様への返事が遅いという苦情への対応
Example instruction for Copilot

Write Python that reads JSON Lines documents and a question set of {question, list of correct doc ids}, searches four ways (TF-IDF char_wb, BM25 on two-character tokens, e5 embeddings with faiss, and RRF of the two), and prints a table of recall@1/3/5 and MRR@10, plus the rank of the first correct document per question and method.

Caution

In this sample (local CPU, 2026-09-12) MRR@10 was 0.578 for TF-IDF, 0.544 for BM25, 0.944 for embeddings and 0.711 for hybrid; with RERANK=1, hybrid plus reranking reached 0.938 (about 94 seconds). With so few questions, treat the differences as indicative only. Measure again on your own documents with questions phrased the way people actually ask. Inconsistent relevance labels make the numbers unreliable, so have more than one person check them.

Availability
scikit-learn, rank-bm25, sentence-transformers, faiss-cpu and torch (CPU). Models: intfloat/multilingual-e5-small (MIT) and, with RERANK=1, cl-nagoya/ruri-v3-reranker-310m (Apache 2.0). Once the models are downloaded, HF_HUB_OFFLINE=1 keeps everything local.
Requires
scikit-learn 1.9.1, rank-bm25 0.2.2, numpy 2.5.3, sentence-transformers 6.0.1, faiss-cpu 1.15.0, torch 2.14.0+cpu
Tested
run and checked locally (2026-09-12, Python 3.12.10 (venv) / Windows 11, scikit-learn 1.9.1, rank-bm25 0.2.2, numpy 2.5.3, sentence-transformers 6.0.1, faiss-cpu 1.15.0, torch 2.14.0+cpu)
Source
Sentence Transformers, "Evaluation (InformationRetrievalEvaluator)"
GitHub (dorianbrown/rank_bm25), "Rank-BM25 README"
Microsoft Learn, "Relevance scoring in hybrid search using Reciprocal Rank Fusion (RRF)"
Verified
2026-09-12 (v1)
Supporting passages from the sources
relevant_docs (Dict[str, Set[str]]) – A dictionary mapping query IDs to a set of relevant document IDs.
mrr_at_k (List[int]) – A list of integers representing the values of k for MRR calculation. Defaults to [10].
from rank_bm25 import BM25Okapi
The score is calculated as 1/(rank + k), where rank is the position of the document in the list and k is a constant.