0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AIエージェント用検索APIの重複URLをPythonで測る

0
Posted at

AIエージェント用検索APIの重複URLをPythonで測る

朝、テスト用に保存した二つの検索応答を見比べていたら、片方にだけ utm_source が付いていた。同じ Python ドキュメントを返しているのに、そのまま集合を取ると別URL扱いになる。こういう地味な差で「二つの検索APIは違う結果を返した」と思い込む。

8月18日に、AIエージェント向け検索APIを品質・コスト・速度で比較するベンチマークが報じられた1。ただ、エージェントに検索を任せる側が最初に見る数字は順位ではなく、結果がどれだけ同じ一次情報に寄っているかだと思う。上位10件のうち8件が同じURLなら、APIを二本用意しても調査の逃げ道は増えない。

ここでは、各APIの生JSONを保存してから、URLを正規化して重複を数える小さなスクリプトを作る。APIキーもSDKも要らない。比較の前に、今使っている検索結果を観察するための道具として使う。

URLをそのまま比べると重複を取り逃がす

検索APIの応答形式はまちまちだ。results の配列だったり、web.results の中だったりする。下のコードはJSONを再帰的にたどり、HTTP URLだけを拾う。

比較前に次をそろえる。

  • utm_*gclidfbclid を落とす
  • www. と末尾の / をそろえる
  • フラグメントを落とす
  • それ以外のクエリは残す

最後の点は意外と大事。検索条件を含むURLまで全部同じページ扱いにすると、今度は別物を混ぜてしまう。正規化は強くしすぎない方が、後で出力を読める。

#!/usr/bin/env python3
"""複数の検索 API 応答に含まれる URL の重複を集計する。"""

import argparse
import itertools
import json
from pathlib import Path
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit

def iter_urls(value):
    if isinstance(value, dict):
        for child in value.values():
            yield from iter_urls(child)
    elif isinstance(value, list):
        for child in value:
            yield from iter_urls(child)
    elif isinstance(value, str):
        parsed = urlsplit(value)
        if parsed.scheme in {"http", "https"} and parsed.netloc:
            yield value

def canonicalize(url):
    parsed = urlsplit(url)
    host = parsed.netloc.lower().removeprefix("www.")
    path = parsed.path.rstrip("/") or "/"
    query = [
        (key, value)
        for key, value in parse_qsl(parsed.query, keep_blank_values=True)
        if not (key.lower().startswith("utm_") or key.lower() in {"gclid", "fbclid"})
    ]
    return urlunsplit(("https", host, path, urlencode(sorted(query)), ""))

def read_input(spec):
    name, separator, filename = spec.partition("=")
    if not separator or not name or not filename:
        raise ValueError("--input は provider=response.json の形式で指定してください")
    with Path(filename).open(encoding="utf-8") as handle:
        document = json.load(handle)
    return name, {canonicalize(url) for url in iter_urls(document)}

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--input", action="append", required=True)
    args = parser.parse_args()

    try:
        results = dict(read_input(spec) for spec in args.input)
    except ValueError as error:
        parser.error(str(error))
    if len(results) != len(args.input):
        parser.error("provider 名は重複させないでください")

    provider_names = sorted(results)
    shared_by_all = sorted(set.intersection(*(results[name] for name in provider_names)))
    pairwise_overlap = []
    for left, right in itertools.combinations(provider_names, 2):
        shared = results[left] & results[right]
        union = results[left] | results[right]
        pairwise_overlap.append(
            {
                "providers": [left, right],
                "shared_urls": len(shared),
                "jaccard": round(len(shared) / len(union), 3) if union else 0.0,
            }
        )

    print(json.dumps({
        "providers": {name: {"unique_urls": len(results[name])} for name in provider_names},
        "shared_by_all": shared_by_all,
        "pairwise_overlap": pairwise_overlap,
    }, ensure_ascii=False, indent=2))

if __name__ == "__main__":
    main()

保存した応答を brave.jsontavily.json とした場合はこう実行する。

python audit_search_overlap.py \
  --input brave=brave.json \
  --input tavily=tavily.json

自分が動かしたテストでは、片方だけに付けた utm_source と、もう片方の #url-parsing が消え、Python公式ドキュメントが1件の共通URLになった。Python 3.14.6で構文確認も通している。

{
  "providers": {
    "brave": {"unique_urls": 2},
    "tavily": {"unique_urls": 2}
  },
  "shared_by_all": [
    "https://docs.python.org/3/library/urllib.parse.html"
  ],
  "pairwise_overlap": [
    {
      "providers": ["brave", "tavily"],
      "shared_urls": 1,
      "jaccard": 0.333
    }
  ]
}

ここだけ見れば、速度の違う二社が実際には同じ一件を返していないか確認できる。

数字の読み方と、集めすぎないための使い方

shared_by_all は全社で共通のURL、jaccard は二社の和集合に対する共通URLの比率だ。0.333なら、今回のように3URLのうち1URLが共通ということになる。

値が低ければ良い、で終わらせない。低い理由が低品質なページの混入なら、エージェントの根拠はむしろ弱くなる。上位URLを実際に開き、公式ドキュメント、一次発表、転載記事のどれが増えたかを見る。ここはスコアで自動判定しない方がいい。ドメイン単位に畳みたくなるけれど、公式Docsと公式Blogを分けて見たい案件もある。

自分は、まず同じ5クエリを各APIへ投げて応答をそのまま保存し、このスクリプトを回す運用にしている。重複が高いなら片方を冗長化用に置く判断もできる。低いなら、回答を合成する前に出典の優先順位を決める。検索APIの契約を増やす話より先に、そこを見ておくと設計がかなり静かになる。

たとえば一般Web検索と公式ドキュメント専用の検索を並べるなら、重複が少ないこと自体に意味がある。逆に二社とも同じニュース転載へ寄るなら、プロバイダを替えても調査の視野は広がらない。出力を一度眺めるだけで、クエリの書き方を直す場所も見えてくる。

おわりに

検索APIの評価は、平均レイテンシや正解率だけで決めにくい。エージェントでは検索結果が次のプロンプトに入り、その偏りが回答まで持ち越されるからだ。

まずは保存済みの応答でURLの重複を出す。共通の一次情報しか取れていないなら、検索先を増やすより、クエリやソース制約を変えた方が効く。比較表の数字を読む前に、自分のワークロードで出典の分散を一度測るのがおすすめ。

  1. New benchmark ranks search APIs for AI agents on quality, cost, and speed

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?