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.
- 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
- Index only items with a file facet and a .docx, .pptx or .pdf extension; skip folders and images
- 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
- Items with a deleted facet go on a list so their chunks can be removed from the index
- 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
"""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']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.
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.
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.
- pypdf: call extract_text() on each page of PdfReader(path).pages; it is written in pure Python
- pdfplumber: call extract_text() on the pages of pdfplumber.open(path); it also exposes character positions and tables
- PyMuPDF: call get_text() on each page of pymupdf.open(path); it is built on MuPDF and is faster than pdfplumber
- Compare character counts across the three and look at pages where they differ a lot; send near-empty pages to OCR
- Before adopting one, check its licence (see availability) against your company's rules
"""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=在宅勤務規程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.
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.
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.
- 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'
- Join the paragraphs between two headings into one chunk and attach the file name and heading as its source
- For PDFs, take each page's text, join the lines and cut it into fixed-size pieces, advancing by the size minus the overlap
- Keep chunks within what the embedding model reads: multilingual-e5-small truncates text beyond 512 tokens
- Write one chunk per line to out_chunks.jsonl as the input to indexing
"""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日までに申請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.
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).
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.
- 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
- TF-IDF rows are normalised to unit length by default, so the dot product with the query vector works as a similarity score
- rank_bm25 does no preprocessing: tokenise documents and queries with the same function before handing them to BM25Okapi
- get_scores(query tokens) scores every document; sort to get the top results
- If you switch to a morphological analyser for tokenising, the same rule applies: treat documents and queries identically
"""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)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.
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.
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.
- The model is intfloat/multilingual-e5-small; per its model card it has 12 layers, 384-dimensional embeddings and an MIT licence
- e5 models expect a 'passage: ' prefix on documents and 'query: ' on queries, including Japanese text
- encode(..., normalize_embeddings=True) gives unit-length vectors; convert them to a float32 array
- add them to faiss.IndexFlatIP(dim) and call index.search(query_vector, 3); a flat index compares against every vector (brute force)
- Save the index with faiss.write_index and keep the vector-number-to-document-id mapping alongside it
"""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:名刺の発注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.
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.
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.
- Run BM25 and embedding search for the same query and keep a ranked list of the top results from each (depth in the code)
- 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
- To favour one side, multiply each list by a weight (weights in the code), the same idea as vector weighting in Azure AI Search
- Look at the top results of each method for queries where exact words matter (such as product codes) and for paraphrased ones
- Adopt the fused ranking only after checking on the evaluation questions (search-python/e2e) that it beats the single methods
"""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 D01Write 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.
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.
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.
- Take the top candidates from a first stage: BM25, embeddings or hybrid (DEPTH in the code)
- Load CrossEncoder(model_name) and call rank(query, candidate_texts, top_k=3); it returns each candidate's position (corpus_id) and a score
- Map corpus_id back to the first-stage document ids for display
- More candidates means fewer misses but one more model call per pair; check the speed-quality trade-off in the evaluation
- The model is a Japanese reranker built on ModernBERT-Ja and published under the Apache License 2.0
"""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)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.
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.
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.
- Put queryString (a single natural-language sentence) and dataSource (sharePoint, oneDriveBusiness or externalItem) in the body; maximumNumberOfResults goes from 1 to 25
- 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
- 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
- From retrievalHits take webUrl, extracts, resourceMetadata and sensitivityLabel; extracts are unordered, so pass all of them to the LLM
- 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
"""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/"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.
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.
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.
- When building the index, include fields for chunk text, title and URL, plus a vector field (contentVector here)
- 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
- Create SearchClient(endpoint, index_name, DefaultAzureCredential()); the calling user or service principal needs the Search Index Data Reader role
- Iterate over search(search_text=..., vector_queries=[...], select=[...], top=5) and print @search.score and the fields
- 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
"""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 policyWrite 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.
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.
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.
- research/samples_search.py creates the documents and questions; the questions deliberately use different wording from their correct documents
- Store the question set as {question id: set of correct document ids}, the same shape as relevant_docs in sentence-transformers' InformationRetrievalEvaluator
- 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
- 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)
- Run with RERANK=1 to add a cross-encoder rerank of the hybrid top results (slow on a CPU)
- Write the scores to out_search_eval.csv and use it to compare changes to chunking, model or weights
"""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 | お客様への返事が遅いという苦情への対応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.
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.
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.