01差分クエリと cTag で、変わった文書だけを索引し直す確認済
ドライブの delta で前回からの変更を受け取り、各ファイルの cTag(本文の eTag)を前回の記録と比べる。本文が変わったファイルだけを取り直し、削除されたファイルは索引から外す一覧に入れる。
- 初回は /drives/{drive-id}/root/delta で全件を列挙し、最後の応答の deltaLink を保存する。次回はその deltaLink から読む
- 索引に入れるのは file ファセットがあり、拡張子が .docx・.pptx・.pdf のものだけにする。フォルダーと画像は飛ばす
- 前回の {id: cTag} と比べ、値が違う項目と新しい項目だけを /content で取り直す。cTag はメタデータだけの変更では変わらないので、名前の変更などでは取り直さない
- deleted ファセットのある項目は、その id のチャンクを索引から外す一覧に入れる
- 判定は plan_changes という関数に分け、見本の delta 応答(search_delta.json)で結果を確かめてから本番のドライブで動かす
"""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())実行結果(2026-09-12)
fetch: ['在宅勤務規程.docx', '営業部定例_0910.docx']
drop: ['01FFF']msgraph-sdk で DRIVE_ID のドライブを delta で読み、前回保存した {id: cTag} と比べて、本文が変わった .docx・.pptx・.pdf だけをダウンロードし、削除された id を一覧にする Python を書いて。判定は plan_changes(items, known) という関数に分け、deltaLink は次回のために保存すること。
保存した deltaLink が使えなくなると 410 Gone が返り、全件の列挙からやり直しになる(扱い方は sharepoint-python の差分クエリの項)。Files.Read.All は読めるすべてのファイルに及ぶので、対象のサイトを限りたいときは Sites.Selected を検討する。手元に写した本文は元の権限の管理から外れる。保存先と読める人を決め、元のファイルが削除されたら写しとチャンクも使わない。
出典の該当箇所
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.
02PDF の本文を pypdf・pdfplumber・PyMuPDF で抜き出し比べる確認済
同じ PDF を 3 つのライブラリで読み、ページ数・文字数・先頭行を並べる。文字がほとんど取れないページは、画像だけの(スキャンした)ページの疑いとして書き出す。ライブラリはライセンスの条件も比べて選ぶ。
- pypdf は PdfReader(path).pages の各ページで extract_text() を呼ぶ。Python だけで書かれている
- pdfplumber は pdfplumber.open(path) の pages で extract_text() を呼ぶ。文字の位置や表も扱える
- PyMuPDF は pymupdf.open(path) の各ページで get_text() を呼ぶ。MuPDF を使い、pdfplumber より速い
- 文字数を 3 つで比べ、差の大きいページは目で確かめる。文字がほとんど無いページは OCR に回す
- 採用する前に、各ライブラリのライセンス(利用条件の欄)を社内の規程と照らし合わせる
"""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")実行結果(2026-09-12)
pypdf pages=2 chars=256 first line=在宅勤務規程
pdfplumber pages=2 chars=256 first line=在宅勤務規程
pymupdf pages=2 chars=256 first line=在宅勤務規程フォルダー内の PDF を pypdf、pdfplumber、PyMuPDF でそれぞれ読み、ファイルとページごとに文字数を CSV に書く Python を書いて。文字数が 20 字未満のページは scanned 列に印を付けること。見本の PDF は書き換えないこと。
スキャンした PDF には文字の層が無く、どのライブラリでも本文はほとんど取れない(OCR が要る)。段組みや表は、読む順が崩れたり語の間に空白が入ったりする。PyMuPDF は AGPL と商用ライセンスの二本立てで、AGPL の条件を満たせない使い方では Artifex の商用ライセンスが要る。抜き出した本文の一部を、元の PDF と並べて人が確かめる。
出典の該当箇所
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.
03見出しごと・一定の文字数ごとに区切り、境目を重ねる確認済
Word の文書は見出しの段落で区切り、見出しの階層をチャンクに付けて残す。見出しの無い PDF の本文は一定の文字数で切り、前後を少し重ねて、境目で切れた文がどちらかのチャンクに残るようにする。
- python-docx で段落を順に読み、style.name が Heading で始まる段落を区切りにする。名前の末尾の数字で階層を決め、「勤務 > 在宅勤務」の形で残す
- 見出しと見出しの間の段落をつないで 1 つのチャンクにし、ファイル名と見出しを出典として付ける
- PDF はページごとに本文を取り、行をつないでから一定の文字数で切る。切る幅から重なりの分を引いた幅ずつ進める
- チャンクは埋め込みモデルが読める長さに収める。multilingual-e5-small は 512 トークンを超える部分を切り捨てる
- 結果を out_chunks.jsonl に 1 行 1 チャンクで書き、索引を作る処理の入力にする
"""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])実行結果(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日までに申請python-docx で Word 文書を見出し(Heading 1〜3)ごとのチャンクに分け、各チャンクに見出しの階層とファイル名を付けて JSON Lines に書く Python を書いて。見出しの無い本文は 400 字ごと、80 字重ねて切ること。
日本語版の Word ではスタイルが[見出し 1]と表示されるが、style.name は英語名の Heading 1 を返す。太字にしただけの見出しは、この方法では区切れない。文字数で切る方法は文の途中で切れるので、句点(。)の位置で切る調整も試す。チャンクの大きさと重なりは、評価(search-python/e2e)の結果を見て決める。
出典の該当箇所
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
04日本語は文字 n-gram で TF-IDF と BM25 の基準線を作る確認済
日本語は語の間に空白が無いので、TfidfVectorizer は analyzer="char_wb" で文字の並びを特徴にし、BM25 には 2 文字ずつずらした語の列を渡す。語が一致する質問には強く、言い換えた質問には弱い。この差を、後の方法と比べる基準にする。
- TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 3)) で 2〜3 文字の並びを数える。既定の analyzer="word" は空白と記号で語を切るので、日本語の文は長い 1 語になってしまう
- TF-IDF の行は既定で長さ 1 に正規化されるので、質問のベクトルとの内積をそのまま類似度に使える
- rank_bm25 は前処理をしない。文書も質問も同じ関数で語の列にしてから BM25Okapi に渡す
- get_scores(質問の語の列) で全文書の点数を出し、上位を並べる
- 形態素解析で語に分ける方法に替えても、文書と質問に同じ処理をかける点は同じ
"""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))実行結果(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)scikit-learn の TfidfVectorizer(analyzer="char_wb"、ngram_range=(2, 3))と rank_bm25 の BM25Okapi(2 文字ずつの語の列)で、JSON Lines の文書を検索する Python を書いて。質問ごとに両方の上位 3 件を ID と点数つきで並べること。
文字の並びで比べるので、言い換え(「届け出」と「申請」、「家で仕事」と「テレワーク」)は拾えない。この見本でも「家で仕事をする日の届け出」は在宅勤務規程を 1 位にできなかった(出力を参照)。BM25 の点数は質問ごとに尺度が違うので、質問をまたいで点数の大小を比べない。
出典の該当箇所
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
05多言語の文埋め込みと FAISS で、意味の近い文書を探す確認済
sentence-transformers で文書と質問を 384 次元のベクトルにし、FAISS の索引で内積の大きい順に探す。ベクトルを長さ 1 にそろえると、内積はコサイン類似度と同じになる。語が一致しない言い換えの質問でも、意味の近い文書が上に来る。
- モデルは intfloat/multilingual-e5-small。モデルカードでは 12 層、埋め込みの次元は 384、ライセンスは MIT
- e5 は入力の頭に「passage: 」(文書)か「query: 」(質問)を付ける決まりがある。日本語の文にも付ける
- encode(..., normalize_embeddings=True) で長さ 1 のベクトルにし、float32 の配列にする
- faiss.IndexFlatIP(次元) に add し、index.search(質問のベクトル, 3) で上位を取る。Flat の索引は全件と総当たりで比べる
- faiss.write_index で索引を保存し、ベクトルの番号と文書 ID の対応も一緒に残す
"""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"]))実行結果(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:名刺の発注sentence-transformers の intfloat/multilingual-e5-small で JSON Lines の文書を埋め込み(文書は passage: 、質問は query: を頭に付ける)、faiss の IndexFlatIP で上位 3 件を探す Python を書いて。ベクトルは normalize_embeddings=True で正規化し、索引をファイルに保存すること。
初回だけ、モデルのファイルを Hugging Face から取得する(この e5-small で約 470 MB)。外部からの取得に許可が要る職場では先に確かめる。取得後は HF_HUB_OFFLINE=1 で手元のファイルだけを使う。文書は外に送らず、手元の CPU で計算する。512 トークンを超える部分は切り捨てられるので、長い文書はチャンクにしてから埋め込む。型番や社内コードのように文字が一致すべき検索は、埋め込みだけでは外すことがある。
出典の該当箇所
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
06BM25 と埋め込みの順位を RRF で 1 つにするハイブリッド検索確認済
語の一致(BM25)と意味の近さ(埋め込み)の 2 つの順位表を、Reciprocal Rank Fusion(RRF)で 1 つにまとめる。点数ではなく順位だけを使うので、尺度の違う 2 つの点数をそろえる必要が無い。
- 同じ質問で BM25 と埋め込みの検索を行い、それぞれ上位の一定件数(コードの depth)の順位表を作る
- 各順位表で文書に 1/(順位 + k) の点を与え、文書ごとに合計して並べ直す。k は定数で、Azure AI Search の説明では 60 のような小さな値がよいとされる
- 片方を重く見たいときは、順位表ごとに重み(コードの weights)を掛ける。Azure AI Search のベクトルの重み付けと同じ考え方
- 型番のように語の一致が効く質問と、言い換えの質問の両方で、各方法の上位を並べて見る
- 合わせた結果が単独の方法より良いかは、評価用の質問集(search-python/e2e)で確かめてから採用する
"""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])))実行結果(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 D01BM25 の順位表と埋め込みの順位表を Reciprocal Rank Fusion で合わせる関数 rrf(rankings, k=60, weights=None) を Python で書いて。順位は 1 から数え、1/(rank + k) に重みを掛けて文書ごとに合計し、点数の高い順の文書 ID を返すこと。
RRF は、片方の方法だけが上位に出した文書も上に混ぜる。この見本の評価では、言い換えの質問で BM25 が外すため、重みなしの RRF は埋め込みだけより成績が下がった(search-python/e2e の出力)。ハイブリッドにすれば必ず良くなるわけではない。重み、BM25 の候補の数、再順位付けの有無を、評価を見ながら決める。
出典の該当箇所
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.
07上位の候補だけをクロスエンコーダーで並べ替える確認済
速い検索で候補を少数に絞り、質問と候補の組をクロスエンコーダーで 1 組ずつ採点して並べ替える。組ごとに計算するので遅いが、順位の精度は上がりやすい。日本語向けの cl-nagoya/ruri-v3-reranker-310m を使う。
- 1 段目は BM25・埋め込み・ハイブリッドのどれかで上位の候補を取る(コードの DEPTH 件)
- CrossEncoder(モデル名) を読み込み、rank(質問, 候補の本文の一覧, top_k=3) で並べ替える。結果は候補の中での番号(corpus_id)と点数
- corpus_id を 1 段目の候補の文書 ID に戻して表示する
- 候補を増やすと取りこぼしは減るが、計算は組の数だけ増える。速さと成績の釣り合いを評価で確かめる
- モデルは ModernBERT-Ja を元にした日本語の再順位付けモデルで、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))実行結果(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)BM25 の上位 10 件を、sentence-transformers の CrossEncoder(cl-nagoya/ruri-v3-reranker-310m)の rank() で並べ替える Python を書いて。並べ替え前と後の上位 3 件を、文書 ID と点数つきで並べて表示すること。
再順位付けは候補の中でしか並べ替えない。1 段目の候補に正解が無ければ直らない(出力の 2 問目は BM25 の候補に正解が無く、どの点数も低い)。モデルのファイルは約 1.2 GB で、CPU では候補 10 件でも数秒かかる。そのため e2e では RERANK=1 のときだけ動かす。
出典の該当箇所
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
08Copilot の Retrieval API で、権限の範囲の抜粋を受け取る要確認
POST /copilot/retrieval に自然文の質問を送ると、SharePoint・OneDrive・Copilot コネクタから関連する本文の抜粋が返る。文書を別の索引に写す必要が無く、結果は呼び出した利用者が読める内容に絞られる。
- 本文に queryString(1 文の自然文)と dataSource(sharePoint、oneDriveBusiness、externalItem のどれか)を入れる。maximumNumberOfResults は 1 から 25 まで
- 範囲を絞るときは filterExpression に KQL を書く(例: path:"サイトの URL")。KQL の書き方を誤ると、絞り込まずに実行される
- 手元の msgraph-sdk には専用の呼び出しが見当たらないため、SDK の request_adapter で POST を送る。サインインは SDK と同じ資格情報を使う
- 応答の retrievalHits から webUrl・extracts・resourceMetadata・sensitivityLabel を取り出す。抜粋の並びに順位の意味は無いので、全部を LLM に渡す
- 使い分け: Microsoft 365 の中の文書を利用者の権限どおりに探すならこの API、手元の文書を自分で評価し並べ替えたいならローカルの索引を使う
"""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())実行結果(2026-09-12)
4 在宅勤務規程 社内限
path:"https://example.sharepoint.com/sites/HR/"msgraph-sdk の request_adapter で POST https://graph.microsoft.com/v1.0/copilot/retrieval を呼び、dataSource=sharePoint で抜粋を取る Python を書いて。本文を作る build_body と、応答を題・URL・ラベル・抜粋の行に直す to_rows を関数に分けること。権限は Files.Read.All と Sites.Read.All の委任。
委任のアクセス許可だけで使え、アプリケーションのアクセス許可には対応しない。SharePoint と OneDrive には Files.Read.All と Sites.Read.All の両方が要る。利用者 1 人あたり 1 時間に 200 回までの制限がある。POST だが検索の要求を送るだけで、Microsoft 365 のデータは変えない。抜粋は根拠の候補なので、使う前に元の文書を開いて確かめる。
出典の該当箇所
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
09Azure AI Search にキーワードとベクトルを 1 回で問い合わせる確認済
azure-search-documents の SearchClient.search に search_text と vector_queries を一緒に渡すと、全文検索とベクトル検索が並行して走り、RRF で 1 つの結果になる。認証は Microsoft Entra ID(DefaultAzureCredential)にし、キーをコードに書かない。
- 索引を作る側で、チャンクの本文・題・URL の項目と、ベクトルの項目(ここでは contentVector)を用意しておく
- ベクトルの項目に vectorizer を設定しておけば、VectorizableTextQuery(text=質問, ...) で質問の文をサービス側でベクトルにできる。無ければ手元で埋め込んだ配列を VectorizedQuery で渡す
- SearchClient(エンドポイント, 索引名, DefaultAzureCredential()) を作る。呼び出す利用者かサービス プリンシパルに Search Index Data Reader のロールを割り当てる
- search(search_text=..., vector_queries=[...], select=[...], top=5) の結果を回し、@search.score と項目を表示する
- 使い分け: Microsoft 365 の文書を利用者の権限どおりに探すだけなら Retrieval API(別項)。Azure AI Search は索引の中身と読める人を自分で設計して運用する
"""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()実行結果(2026-09-12)
contentVector 50 5 telework policyazure-search-documents の SearchClient と DefaultAzureCredential で、search_text と VectorizableTextQuery(fields="contentVector")を同時に渡すハイブリッド検索の Python を書いて。エンドポイントと索引名は環境変数から読み、キーは使わないこと。
Azure AI Search の索引は文書の写しを持つ別の索引なので、元の SharePoint の権限は索引の側で改めて設計する。ベクトルの項目の次元は、索引に入れた埋め込みモデルの次元と合わせる。VectorizableTextQuery は vectorizer の無い索引では使えない。料金は Azure のサービスの料金体系に従う。
出典の該当箇所
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.
10通しの例:言い換えた質問集で、検索方法ごとに recall と MRR を測る確認済
架空の社内文書(規程・議事録・FAQ など)と、正解の文書を付けた言い換えの質問集を作る。TF-IDF・BM25・埋め込み+FAISS・ハイブリッドを同じ質問で走らせ、recall@k と MRR を並べる。どの方法がどの質問で外すかも一覧にする。
- research/samples_search.py で文書と質問集を作る。質問は、正解の文書とわざと違う言い回しにする
- 質問集は {質問 ID: 正解の文書 ID の集合} の形にする。sentence-transformers の InformationRetrievalEvaluator の relevant_docs と同じ形
- 各方法の上位の一覧から、recall@k(正解のうち上位 k 件に入った割合)と MRR(最初の正解の順位の逆数の平均)を自前のコードで計算する。上位 10 件より下は見つからなかった扱いにする
- 質問ごとに、最初の正解の順位を方法別に並べる。外した質問を人が読み、原因(言い換え・略語・文書の書き方)を探す
- RERANK=1 を付けて動かすと、ハイブリッドの上位をクロスエンコーダーで並べ替えた結果も加わる(CPU では時間がかかる)
- 結果を out_search_eval.csv に書き、チャンクの切り方・モデル・重みを変えたときの比較に使う
"""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"])実行結果(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 | お客様への返事が遅いという苦情への対応JSON Lines の文書と、{質問, 正解の文書 ID の一覧} の質問集を読み、TF-IDF(char_wb)、BM25(2 文字ずつ)、e5 の埋め込み+faiss、両者の RRF の 4 通りで検索し、recall@1/3/5 と MRR@10 を表にする Python を書いて。質問ごとの最初の正解の順位も方法別に表示すること。
この見本(手元の CPU、2026-09-12)では MRR@10 が TF-IDF 0.578、BM25 0.544、埋め込み 0.944、ハイブリッド 0.711 だった。RERANK=1 ではハイブリッド+再順位付けが 0.938(約 94 秒)。質問が少ないので、差は目安にとどめる。自社の文書と、実際に使われる言い回しの質問で測り直す。正解の付け方がぶれると数値もぶれるので、正解は複数の人で確かめる。
出典の該当箇所
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.