| @ブログ

カテゴリー検索

ふと思い立ってカテゴリーで検索できるようにしたら便利だろうと改修してみた。 Tantiny には facet_query というものがあって、カテゴリー名などは検索インデックスの型を facet にしておくと、カテゴリーだけを検索対象にできる。実装してみたところ便利。カテゴリー内の記事一覧ページを表示する機能はあるが、本文やコメントなども読み込むため遅い。タイトルだけ一覧でがっと欲しいときにカテゴリー検索は便利。

同様にタグでも検索できるようにしてみた。こちらは term_query を使っている。本文の検索は検索キーワードを形態素解析してトークナイズされた文字列で検索しているが、タグ名での検索は exact match をするようにしている。なので過不足あればヒットしない。完全一致で検索したいときに便利。

ただ、これまで記事にタグを設定してこなかったのでタグ未設定の記事が多い。ということで ChatGPT に記事の内容を読ませてタグを自動生成してもらうようにした。ちょっとへんちくりんなタグを設定することもあるが、文脈を読んでタグを設定してくれる。本文中にキーワードはないけど記事の内容に合致するタグを選んできたりもする。賢い。

ちなみに今回 ChatGPT のモデルを gpt-4o から gpt-5-mini に変更した。 GPT 5 系に移行するためには Chat API から Responses API への移行が必要だった。最初それがわからず難儀したが、 Responses API の方がパラメーターがシンプルで使いやすい。例えば JSON のフォーマットを指定したいとき、 Chat API だと JSON Schema で定義を書いて渡す必要があったが、 Response API であればプロンプト中に「こういうフォーマットでくれ」と書けばそのフォーマットで返してくれる。すごい。

require 'openai'

class EntryTagGenerator
  MODEL = 'gpt-5-mini'
  PROMPT = <<~EOF
以下のブログ記事に関して、次の作業をしてください。

1. この文章の内容を反映したタグを抽出してください。タグの数は最大で 5 個までとします。タグは極力一語で構成されるようにし、略語は避けてください。また「減量ダイエット」のような意味が重複する二つの単語で構成されるタグは付与しないでください。固有名詞の場合はその限りではありません。

レスポンスは JSON フォーマットで、以下のような形式にしてください。

{
  "tags": ["tag1", "tag2"...]
}

# ブログ記事本文

EOF

  def initialize(content)
    @content = content
    @client = OpenAI::Client.new(access_token: ENV['OPENAI_API_KEY'])
  end

  def generate
    request = @client.responses.create(
      parameters: {
        model: MODEL,
        input: input,
        reasoning: { effort: 'minimal' }
      }
    )
    JSON.parse(request.dig("output", 1, "content", 0, "text").strip)
  rescue => e
    puts "タグ生成エラー: #{e.message}"
    nil
  end

  def input
    %(#{PROMPT}#{@content})
  end
end

| @技術/プログラミング

Tantiny 、ろくにドキュメントを読んでいなかったので知らなかったのだけど、リアルタイムで検索インデックスの更新ができるようだった。これまで一時間に一度バッチ処理を動かして、更新された記事があればすべての記事を読み直してインデックスを更新するような富豪的なことをやっていた。アホだった。

| @ブログ

よく検索されているキーワード

検索フォームの下に「よく検索されているキーワード」を追加した。実はこれ完全に自己満足で Google Analytics のサイト内検索で調べる限りだと検索機能はほとんど使われていない。一方で自分はめちゃくちゃ使っている。なので自分自身の検索も含むすべての検索ログをアクセスログから抽出して集計し、検索回数が多い順に並べて表示した。

検索フォームにはインクリメンタルサーチ機能があって、検索ワード入力途中にも HTTP アクセスがあるので検索ログには不完全なキーワードも残る。それらが表示されないように 1 文字だけのキーワードや、実際に Tantiny に投げて返ってくる結果が少ないキーワードは除外している。

自己満足ではあるのだが、このサイトをもっとも閲覧していてもっとも検索している自分が便利になればそれで良い。

| @ブログ

Mac の定番ランチャー、 Alfred のような検索 UI が好きだ。こういう UI で検索できるソフトウェアやウェブサイトがあると気持ちいい。自分のブログにモーダルウィンドウの検索を追加したときも Alfred みたいにしたいと思いながら作った。

見た目は Alfred っぽくできたが、キーボードで操作できない点が気になっていた。いちいち画面右上の🔍マークを押さないと検索 UI を呼び出せないし、検索しようと思って気が変わったときにはカーソルを動かしてモーダルウィンドウの外の領域をクリックしないとモーダルを閉じられない。これが地味にストレスだった。

見た目だけでなく使い勝手も Alfred のようにしようと思ってガチャガチャやってみた。 Access Key を割り当てて Alt + Control + s で検索窓を開き、おもむろに検索文字列を入力すると結果が出てきて、矢印キー上下で候補を移動でき、 Return で選択(その記事に遷移)できる。検索をやめたいと思えば Esc でモーダルウィンドウを閉じることができる。トラックパッドやマウスに持ち替えることなく、キーボードだけで検索できるようになった。めっちゃ便利。

| @WWW

三苫海岸

ソーシャルメディアやニュースサイトに毎日新しいコンテンツが次々に投稿されるので、インターネット上の総情報量は増えていっているはずだが、 20 年前と比べてアクセスできる情報の種類は減っているのではないかと感じる。いま何か情報を得ようとしたときに Google は以前ほど便利ではなくなってきている。 Google がキュレーションした情報にしかアクセスできないからだ。誰にもフィルタリングされていない生の情報にアクセスしようとしたら Twitter 検索の方がよっぼどよいと感じるくらいだ。

昨年末、 40L の登山用バックパックをニュージーランドのショップから購入した。日本でも売っていた商品だが、国内の正規取扱店では売り切れてて個人輸入で購入するしかなかった。商品名で Google 検索しても日本語のページしかヒットしないし、在庫ありとして表示される楽天や Amazon のページには怪しい業者が定価の何倍もの価格でふっかけて販売しているケースがほとんどで、 Google から直接海外のショップのページに辿り着くことができない。メーカーのウェブサイトから各国の取り扱い店をたどってようやく販売しているページを見つけてメールで問い合わせて購入することができた。

15 年くらい前、日本から patagonia.com にアクセスすると patagonia.jp にリダイレクトされて、アメリカで 10000 円くらいで売られているものが日本では 1.5 倍の 15000 円くらいになってて日本人はぼったくられている、というようなことを書いている記事がバズってた(ちなみにいまも patagonia.com を開こうとすると patagonia.jp にリダイレクトされる1)。当時は日本人が価格差に気がつけないようにしているのは邪悪だということで攻撃されていたのはパタゴニアだけだったが、現在では Google が似たようなことをやっていて、インターネット全体でパタゴニアと同じようなことが起こっている。日本人(日本の IP アドレスから日本語設定のブラウザーを利用している人)が海外のショップから直接物を買おうと商品名で Google 検索しても、海外のサイトはほとんどヒットしない。日本人は日本語のウェブページしか見させてもらえない。

日本語のページであっても、すべてが検索結果に表示されているわけではない。何か商品について調べようと Google 検索しても結果に出てくる店は決まっていて、 20 件程度表示されたあとにそれ以降の情報を探すことができない。楽天や Amazon 内の情報のほか、 BASE や STORES 、カラーミー、 Shopify などといった割と利用者が多いカートを採用しているページは Google ショッピングの一覧に表示されるが、そうではない自前の CMS で構築されているような地方のショップのサイトなんかは結果に出てこない。

昨シーズン、ARC'TERYX の Motus AR Hoody というパーカーを買ってとても使い勝手が良かったので、今シーズンも色違いを買おうと探してみたら主要なサイトではすでに売り切れていた。一昨年も GRiPS のサイト(カラーミーで構築されている)に掲載されたタイミングでは買い逃していてネットの海をさまよって何とか辿り着いた地方のショップのサイト(メジャーなカートシステムではない独自システムのサイト)で購入することができた。まさかそんなことあるまいと思いながら今シーズンもそのサイトを訪れて探してみたところ、何とよそでは売り切れて定価の 2 倍とか 3 倍の値段で売られている Motus AR Hoody が定価で販売され在庫が残っていた。こういう例は一度や二度ではなく、何度か経験した。

インターネットに情報が増えすぎて、 Google としても検索結果に表示するページは絞るしかないのだと思う。すると Google にとってクローリングしやすく、サイトの更新を追っかけやすいサイトばかりが検索結果に表示されるようになる。サイトのレスポンスが遅かったり、構造がいまいちイケてないサイトは検索結果に出てきづらくなる。その結果、同じようなページばかりが検索結果に表示され、一部のサイトにだけトラフィックが集中して独自サイトにはアクセスが集まりづらくなってきている可能性がある(だから自分が買ったショップのページでは Motus AR Hoody のような人気商品が売れ残っていた)。

インターネットは情報の非対称性を下げて、より取引を効率化するものだと信じてきてが、どうやらそうではないようだ。一部のページにだけアテンションが集中し、むしろ情報の非対称性が高まっている。あっという間に定価で売られていた商品が売り切れてモノの値段がつり上げられ、一方でアテンションを集められないサイトでは売れ残ってセールになっている。経済学のセオリー通りなら、市場の原理が働いてギリギリに近い競争均衡価格で商品は取引されるはずだが、実際には逆の現象(正規販売店による定価販売と一部の転売業者による価格つり上げ販売)が起こっている。

EC サイトだけでなく、ブログや個人のウェブサイトでも同じような問題が起こっているのではないかと感じる。 Google のコアアルゴリズムアップデートで自分のブログも随分 Google 検索からの流入が減った。

2015 年からの月ごとの検索流入の推移

実際には存在するのに、 Google から不遇されて存在しないことになってしまっているウェブサイトがインターネット上にはきっとたくさんあるだろう。

昔のインターネットのような、検索結果をたどればたどるほど新しい情報と出会えていた頃が懐かしい


  1. 2023-01-18 訂正: patagonia.com から patagonia.jp へのリダイレクトは2023年1月18日時点では機能していなかった 

| @ブログ

グローバルナビゲーション(右上の白い領域)内の検索ボタンを押したら Alfred 風のモーダル検索フォームが開いて、そこにキーワードを入力するとインクリメンタルサーチが実行されて逐次検索結果の記事が表示されるようにした。

これまでだと検索すると Archives ページの絞り込み検索に飛ばすだけだったが、 Archives ページに遷移せずに検索できるようになった。また Archives ページだと時系列順でしか検索結果が表示されないが、インクリメンタルサーチではマッチ度順に関連度の高いものを表示するようにしている。ただし表示するのは上位 10 件だけにして、それ以上は Archives ページで時系列順の検索に飛ばしている。

昔ながらのブログの検索 UI には不満がある。ページネーションで何ページも辿って検索結果を見ていくのは大変だし、大抵並び順が時系列順で自分が最も用事がありそうな記事に辿り着くのに時間がかかる。自分のブログの検索はタイトルのみ表示されればよくて本文のプレビューは不要だし(著者だからタイトルを見ただけでどんな記事なのか大体わかる)、何ページもページネーションせずに一覧でガッと検索結果を見たい。それに結果は時系列順ではなく関連度が高い順に並んでいて欲しい。キーワードを一部だけかすってるような最近の記事が最も関連度が高い記事を差し置いて最上位に表示されるのはいまいちだ。

今回作った Alfred 風インクリメンタルサーチではこれらの問題が解消されていて非常に満足。自分にとって自分のブログが世の中の情報の中で一番参照頻度が高いし、そのブログで効率的に情報を取り出せるのは大切なことだと思う。

| @技術/プログラミング

Rust 製の全文検索システム Tantivy を Ruby から使える Tantiny を導入したことを書いた。

結構手軽に使えるのだがやはり日本語のトークナイズ(形態素解析)ができないのでいまいちなところがあった。 Tantivy には lindera-tantivy というものがあって、 Lindera は kuromoji のポートなので、これを使うと日本語や中国語、韓国語の形態素解析ができる。 Tantiny に導入できないか試してみたが、自分の Rust 力では到底無理だった。

ちなみに関連記事の表示でも日本語の形態素解析は行っている。

MeCab に neologd/mecab-ipadic-neologd を組み合わせてナウな日本語に対応させつつ形態素解析している。

この仕組みを作ってトークナイズは Ruby で自前で行い、 Tantiny および Tantivy にはトークナイズ済みの配列を食わせるだけにした( Tantiny はトークナイズ済みのテキストを受け付けることもできる)。トークナイズを自前で行うことで辞書ファイルで拾いきれないような固有名詞もカバーできる。例えば 山と道 なんかは MeCab と mecab-ipadic-neologd にトークナイズさせると に分割されてしまう。自前のトークナイザーで単語として認識させていている。おかげで「山と道」をちゃんと検索できるようになっている

なお、自前のトークナイザーはこんなコードになっている。

class Tokenizer
  attr_reader :text

  class << self
    def run(text)
      self.new(text).tokenize
    end
  end

  def initialize(text)
    @text = text
  end

  def cleansed_text
    @cleansed_ ||= text.
      gsub(/<.+?>/, '').
      gsub(/!?\[(.+)?\].+?\)/, '\1').
      gsub(%r{(?:```|<code>)(.+?)(?:```|</code>)}m, '\1')
  end

  def words_to_ignore
    @words_to_ignore ||= %w[
      これ こと とき よう そう やつ とこ ところ 用 もの はず みたい たち いま 後 確か 中 気 方
      頃 上 先 点 前 一 内 lt gt ここ なか どこ まま わけ ため 的 それ あと
    ]
  end

  def preserved_words
    @preserved_words ||= %w[
      山と道 ハイキング 縦走 散歩 プログラミング はてブ 鐘撞山 散財 はてなブックマーク はてな
    ]
  end

  def nm
    require 'natto'
    @nm ||= Natto::MeCab.new
  end

  def words
    @words ||= []
  end

  def tokenize
    preserved_words.each do |word|
      words << word if cleansed_text.match?(word)
    end

    nm.parse(cleansed_text) do |n|
      next unless n.feature.match?(/名詞/)
      next if n.feature.match?(/(サ変接続|数)/)
      next if n.surface.match?(/\A([a-z][0-9]|\p{hiragana}|\p{katakana})\Z/i)
      next if words_to_ignore.include?(n.surface)
      words << n.surface
    end

    words
  end
end

preserved_words が手製の辞書だ。 はてなはてブ も辞書登録しておかないと MeCab だとバラバラに分割されてしまって検索できなかった。

難点としては記事更新後に自動でインデックスの更新が行われず、 cron によるバッチ処理でインデックス更新を行っている[1]。なので検索インデックスにデータが反映されるまでにタイムラグがある。 Tantiny でやれれば記事作成・更新時のコールバックとして処理できるのでリアルタイムに変更を検索インデックスに反映させることができるが、個人の日記なのでタイムラグありでも大きな問題にはならない。

本当は Tantiny で lindera-tantivy を使えるようにして Pull Request がカッチョイイのだが、とりあえずは自分は目的が達成できたので満足してしまった。 5 年くらい前から Rust 勉強したいと思っているが、いつまでも経っても Rust を書けるようにはならない。

[1]: mecab-ipadic-neologd を VPS 上でインストールできず(めっちゃメモリを使う)、手元の Mac で Docker コンテナ化して Docker Hub 経由でコンテナイメージを Pull して VPS 上で Docker 経由で動かしている(その辺について書いてる記事: ブログのコンテナ化を試みたけどやめた