モジュール 04 · 第 3 回

ベクトル検索をゼロから書く

numpy で数十行のベクトル検索を書きます。チャンクをベクトルの行列に変え、検索時に類似度を計算して上位を取り出す。そして 20 問で評価すると、正しく見つけられたのは半分だけ。問題がどこにあるのかを見ていきます。

  • 約 45 分
  • 難易度:中級
  • 検証:2026-09-14 bge-small-zh-v1.5、multilingual-e5-small

コードと実行結果は実際に動かしたときのまま載せているため、コメントと出力は中国語です。

モジュール 01 第 5 課で埋め込みを扱いました。意味の近い文章は、ベクトルも近くなります。前の課で httpx のドキュメントを 196 個のチャンクに分割しました。この二つを組み合わせれば、セマンティック検索エンジンになります。あらかじめ各チャンクのベクトルを計算しておき、ユーザーが質問したら質問のベクトルを計算して、最も似ているチャンクをいくつか見つけるのです。

多くのチュートリアルは、いきなりベクトルデータベースをインストールさせます。この課ではまだ使わず、numpy で自分で書きます。全部で数十行です。書き終えれば、ベクトル検索の核心が 1 回の行列の掛け算にすぎないことがわかります。それから 20 問で評価して、本当に使えるのかを見ます。

インデックスを作る

import numpy as np
from sentence_transformers import SentenceTransformer


class VectorIndex:
    def __init__(self, model_name):
        self.model = SentenceTransformer(model_name)
        # e5 系列要求给查询和文档分别加上前缀,这是它训练时的约定
        self.q_prefix, self.d_prefix = ("query: ", "passage: ") if "e5" in model_name else ("", "")
        self.chunks = []  # [(文件名, 文本), ...]
        self.matrix = None  # 每一行是一个块的向量

    def build(self, chunks):
        self.chunks = chunks
        texts = [self.d_prefix + text for _, text in chunks]
        self.matrix = self.model.encode(texts, normalize_embeddings=True, batch_size=32)

build はすべてのチャンクを一度に埋め込みモデルに渡し、行列を受け取ります。196 個のチャンクがそれぞれ 512 次元のベクトルになるので、196 行 512 列の行列です。normalize_embeddings=True は各ベクトルの長さを 1 にそろえるので、後で類似度を計算するときは内積だけで済みます。

各チャンクには、どのファイルから来たかも記録しておきます。後で検索が正しかったかを判定するのに使い、第 5 課では答えがどこから来たかをユーザーに伝えるのにも使います。

検索する

    def search(self, query, k=5):
        q = self.model.encode([self.q_prefix + query], normalize_embeddings=True)[0]
        scores = self.matrix @ q  # 向量都归一化过了,点积就是余弦相似度
        top = np.argsort(-scores)[:k]
        return [(float(scores[i]), *self.chunks[i]) for i in top]

self.matrix @ q は行列とベクトルの掛け算です。196 行のそれぞれが質問のベクトルと内積を取り、質問とすべてのチャンクの類似度を一度に計算します。np.argsort(-scores) で類似度の高い順に並べ、上位 k 個を取ります。

これで完全なベクトル検索です。試してみましょう。

BAAI/bge-small-zh-v1.5:196 个块,向量 (196, 512),建索引用了 15.7 秒

示例:「怎么关闭 SSL 证书校验?」最相似的块来自 advanced/ssl.md,相似度 0.628
### Enabling and disabling verification

By default httpx will verify HTTPS connections, and raise an error for invalid SSL cases...

中国語の質問で、英語のドキュメントの証明書検証についての節が見つかりました。インデックスの作成には 15.7 秒かかり、その大部分はモデルを初めて読み込むことと 196 個のベクトルの計算に使われています。検索そのものにはほとんど時間がかかりません。

良し悪しはどうやってわかるか

一つの質問で正しく見つかっても、何の証明にもなりません。体系的に評価するには、正解のある問題のセットが必要です。

中国語の質問を 20 問用意して code/04-rag/eval_qa.jsonl に置きました。各問題には、答えがどのファイルにあるべきかと、キーワードを一つ付けています。取り出したチャンクがそのファイルから来ていて、かつそのキーワードを含んでいれば、正しく見つけたとみなします。

{"question": "怎么把超时完全关掉,让请求一直等下去?", "file": "advanced/timeouts.md", "keyword": "timeout=None"}
{"question": "服务器要求 Digest 认证怎么办?", "file": "advanced/authentication.md", "keyword": "DigestAuth"}
{"question": "有些域名不想走代理,环境变量怎么设置?", "file": "environment_variables.md", "keyword": "NO_PROXY"}
……

「チャンクの番号」ではなく「ファイル + キーワード」で判定するのは、分割方法を変えるとチャンクの番号がすべて変わってしまうのに対し、ファイルとキーワードは変わらないからです。これなら分割方法を変えても、同じ問題セットをそのまま評価に使えます。キーワードはどれも、対応するファイルに確かに出てくることをスクリプトで確かめてあります。

そして、いくつかの指標を集計します。

  • 1 位のヒット率:1 位のチャンクが正しいものの割合。
  • 上位 3 件、上位 5 件のヒット率:上位の中に正しいものがある割合。RAG はふつう上位の数チャンクをまとめてモデルに渡すので、こちらの指標のほうが重要です。
  • MRR(平均逆順位):正しいチャンクが 1 位なら 1 点、2 位なら 1/2、3 位なら 1/3、見つからなければ 0 点とし、全問で平均します。「見つかったかどうか」と「どれだけ上位にあるか」を総合的に表します。
def evaluate(search, questions, k=5):
    """返回第 1 名命中率、前 3 名命中率、前 5 名命中率、MRR,以及没找到的题。"""
    ranks, misses = [], []
    for qa in questions:
        results = search(qa["question"], k)
        rank = next((i + 1 for i, r in enumerate(results) if is_hit(r, qa)), None)
        ranks.append(rank)
        if rank is None:
            misses.append((qa, results[0]))
    n = len(questions)
    hit = lambda top: sum(1 for r in ranks if r and r <= top) / n
    mrr = sum(1 / r for r in ranks if r) / n
    return hit(1), hit(3), hit(5), mrr, misses

evaluate が受け取るのは検索関数で、特定のインデックスではありません。次の課のキーワード検索やハイブリッド検索も、これで評価でき、結果をそのまま比べられます。

結果:半分だけ

20 道题:第 1 名命中 20%,前 3 名命中 40%,前 5 名命中 50%,MRR 0.303
  没找到:怎么知道一个响应实际用的是 HTTP/1.1 还是 HTTP/2?(应在 http2.md)→ 第 1 名是 advanced/clients.md:!!! hint
  没找到:服务器要求 Digest 认证怎么办?(应在 advanced/authentication.md)→ 第 1 名是 advanced/ssl.md:### Enabling and disabling verification
  没找到:怎么让请求走 HTTP 代理?(应在 advanced/proxies.md)→ 第 1 名是 advanced/clients.md:!!! hint
  没找到:下载很大的文件时,怎么一块一块地读,而不是一次读进内存?(应在 quickstart.md)→ 第 1 名是 advanced/clients.md:## Multipart file encoding
  没找到:响应是 404 或 500 时,怎么让它直接抛异常?(应在 quickstart.md)→ 第 1 名是 advanced/timeouts.md:HTTPX is careful to enforce timeouts eve
  没找到:异步发请求应该用哪个类?(应在 async.md)→ 第 1 名是 async.md:# Async Support
  没找到:httpx 和 requests 在处理重定向上有什么不一样?(应在 compatibility.md)→ 第 1 名是 advanced/clients.md:!!! hint
  没找到:写测试时,怎么不真的发网络请求,而是返回一个假的响应?(应在 advanced/transports.md)→ 第 1 名是 advanced/clients.md:!!! hint
  没找到:想在每个请求发出之前和收到响应之后都执行一段代码,比如打日志,怎么做?(应在 advanced/event-hooks.md)→ 第 1 名是 advanced/timeouts.md:HTTPX is careful to enforce timeouts eve
  没找到:有些域名不想走代理,环境变量怎么设置?(应在 environment_variables.md)→ 第 1 名是 advanced/clients.md:!!! hint

20 問のうち、上位 5 件に正しいチャンクが見つかったのは半分だけです。これを RAG に組み込めば、半分の質問でモデルは無関係な資料を受け取ることになります。

別の埋め込みモデルを試してみましょう。intfloat/multilingual-e5-small は多言語向けに特別に学習された埋め込みモデルです。

python code/04-rag/vector_search.py intfloat/multilingual-e5-small
20 道题:第 1 名命中 30%,前 3 名命中 50%,前 5 名命中 65%,MRR 0.418

少しよくなり、上位 5 件のヒット率は 50% から 65% に上がりましたが、まだ満足できる水準ではありません。

問題はどこにあるか

言語をまたいでいる。質問は中国語で、ドキュメントは英語です。bge-small-zh-v1.5 は主に中国語向けに学習されていて、英語を理解する力は限られています。multilingual-e5-small は多言語向けに学習されているので、少しよくなります。しかし、中国語の質問と英語のドキュメントを同じベクトル空間に写すのは、同じ言語どうしよりもともと難しいのです。

「万能チャンク」。見つからなかった問題をよく見ると、1 位に同じチャンクが何度も出てきます。advanced/clients.md の中の !!! hint です。その全文は次のとおりです。

!!! hint
    If you are coming from Requests, `httpx.Client()` is what you can use instead of `requests.Session()`.

わずか 115 文字で、httpx、requests、Client といった語が一度に出てきて、「httpx をどう使うか」ということそのものを話しています。埋め込みモデルから見ると、これは「httpx でどうやって……」というほぼどんな質問とも少し似ています。しかも短いので、この「漠然とした関連性」を薄める他の内容がなく、多くの質問で 1 位になってしまうのです。e5 を使うと、この役回りは logging.md の冒頭に代わり、これも httpx について漠然と語る一節です。

この現象はよくあることです。とても短く、概括的なチャンクは、ベクトル検索で上位を占めがちです。分割のときに短すぎるチャンクを結合してしまうか、次の課の方法で補うことができます。

意味は合っているが、正確でない。「サーバーが Digest 認証を要求したらどうする」の 1 位は、SSL 証明書の検証についてのチャンクでした。埋め込みモデルから見ると、「認証」と「証明書の検証」はどちらも「セキュリティ・本人確認」の類に属し、意味はかなり近いのです。しかしユーザーが聞いているのは Digest という具体的な認証方式で、この語そのものが鍵です。モジュール 01 第 5 課でも同じ問題を見ました。埋め込みは httpx と requests を区別できませんでした。ベクトルは大意をとらえるのは得意ですが、具体的な名前に的を合わせるのは苦手です。

評価のルール自体にも限界がある。「非同期でリクエストを送るにはどのクラスを使うべきか」の 1 位は実は async.md ですが、そのチャンクはこのページの冒頭で、たまたま AsyncClient という語が出てこなかったため、ルール上は見つからなかったと判定されました。これを正しく見つけたとみなすべきかは議論の余地があります。評価のルールを厳しくするほど点数は下がりますが、信頼性は上がります。

第 6 課で評価をもっと体系的に扱います。次の課ではまず、正確なマッチと言語をまたぐ問題に取り組みます。

ベクトルデータベースが必要になるのはいつか

私たちの 196 個のベクトルは numpy の配列一つに収まり、メモリは 1MB にも満たず、毎回の検索は 1 回の行列の掛け算で一瞬で終わります。

ベクトルが数十万、数百万個になるか、次のような機能が必要になったとき、初めて専用のベクトルデータベース(Chroma、Qdrant、Milvus、あるいは PostgreSQL の pgvector 拡張など)を使う価値が出てきます。

  • データ量が多い。数百万個のベクトルを一つずつ比べるのは遅すぎます。データベースは近似最近傍(ANN)アルゴリズムを使い、正確さを少しだけ犠牲にして、ずっと速い速度を得ます。
  • 永続化と差分更新が必要。ドキュメントがどんどん追加、変更、削除されるなら、毎回行列全体を作り直すのは現実的ではありません。
  • 条件で絞り込む必要がある。たとえば「2.0 版のドキュメントだけを検索する」「このユーザーが閲覧権限を持つドキュメントだけを検索する」などです。

数千から数万個のチャンクの規模なら numpy で十分です。行列を np.save でファイルに保存しておき、次回はそのまま読み込めば、ベクトルを計算し直す時間を省けます。まず最も簡単な方法を使い、上のような問題に本当に出会ってから切り替えましょう。

練習問題

  1. vector_search.py で、作った行列を np.save でファイルに保存し、次の起動時にファイルがあればそのまま読み込むようにして、2 回の起動時間を比べてください。
  2. 分割関数を修正して、200 文字未満のチャンクを捨てるか結合するようにし、評価を実行し直してください。万能チャンクの問題は軽くなりましたか。上位 5 件のヒット率はどれだけ変わりましたか。
  3. eval_qa.jsonl に自分で考えた問題を 5 問追加してください。どの問題も、ドキュメントで答えの場所とキーワードを確認してから加えます。

確認テスト

1. ベクトルをすべて正規化すると、内積でコサイン類似度が計算できるのはなぜですか?

コサイン類似度は二つのベクトルの内積を、それぞれの長さの積で割ったものです。正規化すると各ベクトルの長さは 1 になり、分母が 1 なので、内積がそのままコサイン類似度になります。こうすれば検索全体が 1 回の行列の掛け算で済みます。

2. 検索の効果を評価するとき、チャンクの番号ではなく「ファイル + キーワード」でヒットを判定するのはなぜですか?

チャンクの番号は分割方法によって決まり、分割方法を変えると番号がすべて変わります。ファイル名とキーワードは分割方法と関係ないので、同じ評価問題で異なる分割方法や検索方法を比べられます。

3. 「万能チャンク」とは何ですか?それがベクトル検索で上位に来やすいのはなぜですか?

とても短く、内容が概括的なチャンクのことです。たとえば「requests.Session() の代わりに httpx.Client() を使う」という一行のヒントなどです。多くの質問と少しずつ関連しているうえに、その関連性を薄める他の内容がないので、さまざまな質問で上位に来てしまい、本当に関係のあるチャンクを押しのけてしまいます。

質問と議論

このレッスンでつまずいたところは、ここで質問してください。他の人の質問に答えるのも歓迎です。

質問で 3 ポイント、回答で 6 ポイント。審査を通過すると公開されます。

議論を読み込んでいます…