2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Livebook から SearXNG を呼び出し、ウェブ検索付きのAIチャットを実装する

Last updated at Posted at 2025-03-12

はじめに

以前、 Open WebUI と SearXNG でウェブ検索付きのAIチャットを動かしました

本記事では Livebook を使用し、簡易的なウェブ検索付き AI チャットを実装します

実装したノートブックはこちら

事前準備

本記事では日本語に特化したテキスト埋込モデル Ruri を使用します

以下の記事を参考に、 tmp 配下に変換した Ruri の tokenizer.json を用意してください

また、テキスト埋め込みとチャットには Ollama を利用するため、 Ollama をインストールしておいてください

コンテナ起動

以下の内容で docker-compose.yml を作成します

---

services:
  livebook_with_searxng:
    image: ghcr.io/livebook-dev/livebook:0.15.3
    container_name: livebook_with_searxng
    ports:
      - '8080:8080'
      - '8081:8081'
    volumes:
      - ./tmp:/tmp

  searxng:
    container_name: searxng_host
    image: searxng/searxng:latest
    ports:
      - "8888:8080"
    volumes:
      - ./searxng:/etc/searxng:rw
    environment:
      - SEARXNG_HOSTNAME=localhost:8080/
    restart: unless-stopped
    cap_drop:
      - ALL
    cap_add:
      - CHOWN
      - SETGID
      - SETUID
      - DAC_OVERRIDE
    logging:
      driver: "json-file"
      options:
        max-size: "1m"
        max-file: "1"

以下のコマンドでコンテナを起動します

docker compose up

しばらくして初回起動が終わると、以下の URL でブラウザから SearXNG にアクセスできます

http://localhost:8888

スクリーンショット 2025-03-01 17.16.01.png

SearXNG の設定変更

searxng/settings.yml に SearXNG の設定ファイルが作成されています

検索結果を JSON 形式で返せるように formats の値として json を追加します

...
  formats:
    - html
+   - json
...

変更を保存したら Ctrl + c で一度コンテナを停止し、再度コンテナを起動します

docker compose up

この状態で別ターミナルから以下のコマンドを実行すると、 JSON 形式の検索結果が取得できます

curl 'http://localhost:8888/search?lang=ja&q=やせうま&format=json'

セットアップ

Livebook で新しいノートブックを作成し、セットアップセルで以下のコードを実行します

Mix.install([
  {:req, "~> 0.5"},
  {:ollama, "~> 0.8"},
  {:chunx, github: "preciz/chunx"},
  {:hnswlib, "~> 0.1"},
  {:kino, "~> 0.15"}
])

SearXNG の検索結果で得られるウェブサイトの内容は大きすぎるため、 Chunx を使って分割し、質問の内容に近いものだけを使うようにします

検索

検索用の関数を用意します

コンテナ間の通信であるため、ホスト名はサービス名、ポート番号はコンテナ内の番号になっています

search = fn query ->
  "http://searxng:8080/search"
  |> Req.get!(params: [q: query, format: "json"])
  |> Map.get(:body)
end

大分銘菓「やせうま」を検索してみましょう

results =
  "やせうま"
  |> search.()
  |> Map.get("results")

実行結果

[
  %{
    "category" => "general",
    "content" => "練った小麦粉を平たくの...地ののばし方にはコツがあり、熟練者ほど薄く長くのばすことができるという。 「やせうま」の発祥の地は古野(大分県由布市)とされている。平安時代、信仰心の厚い乳母の八...",
    "engine" => "brave",
    "engines" => ["duckduckgo", "brave"],
    "parsed_url" => ["https", "www.maff.go.jp",
     "/j/keikaku/syokubunka/k_ryouri/search_menu/menu/yaseuma_oita.html", "", "", ""],
    "positions" => [1, 2],
    "publishedDate" => nil,
    "score" => 3.0,
    "template" => "default.html",
    "thumbnail" => "",
    "title" => "やせうま 大分県 | うちの郷土料理:農林水産省",
    "url" => "https://www.maff.go.jp/j/keikaku/syokubunka/k_ryouri/search_menu/menu/yaseuma_oita.html"
  },
  %{
    "category" => "general",
    "content" => "やせうまは、弘法大師(空海)の命日である「お大師さま」で振る舞われたり、お盆や七夕に供えものにされた [1] 。 「お大師さま」の3月21日には、普段は道ばたや辻にある弘法大師の像を床の間に移して祀り、訪ねてくる近所の住民にやせうまを振る舞った。 。この行事の時には、やせうま ...",
    "engine" => "brave",
    "engines" => ["duckduckgo", "brave"],
    "parsed_url" => ["https", "ja.wikipedia.org", "/wiki/やせうま", "", "", ""],
    "positions" => [2, 1],
    "publishedDate" => "2024-10-10T00:00:00",
    "score" => 3.0,
    "template" => "default.html",
    "thumbnail" => "",
    "title" => "やせうま - Wikipedia",
    "url" => "https://ja.wikipedia.org/wiki/やせうま"
  },
  %{
    "category" => "general",
    "content" => "やせうま本舗では通販でもお求めいただけます。 「豊後銘菓やせうま」は大分県の郷土料理をアレンジした、きな粉を使った和菓子です。 明治神宮・靖国神社への献上をはじめ、全国菓子大博覧会等での数多くの受賞歴があります。",
    "engine" => "brave",
    "engines" => ["duckduckgo", "brave"],
    "parsed_url" => ["https", "yaseuma.com", "/", "", "", ""],
    "positions" => [4, 5],
    "publishedDate" => "2023-11-19T00:00:00",
    "score" => 0.9,
    "template" => "default.html",
    "thumbnail" => "",
    "title" => "やせうま本舗 | 明治神宮献上の大分銘菓やせうまの通販",
    "url" => "https://yaseuma.com/"
  },
  ...
]

やせうまに関するウェブサイトがスコアの降順で得られました

次のコードでウェブサイトのコンテンツをドキュメントとして取得します

  • URL が https から始まっている(http ではない)ウェブサイトのうち、上位10件を抜き出す
  • ウェブサイトのコンテンツをダウンロードする
  • コンテンツから HTML タグや JavaScript 、スタイルシートなどを削除する
  • UTF-8 以外の文字コード(EUC-JP や SJIS)のドキュメントは今回扱わないため、 String.valid? で対象外とする
  • 上位5件だけを取り出す
documents =
  results
  |> Enum.filter(fn result ->
    String.starts_with?(result["url"], "https")
  end)
  |> Enum.slice(0..9)
  |> Enum.map(fn result ->
    content =
      result["url"]
      |> URI.encode()
      |> Req.get!()
      |> Map.get(:body)
      |> String.replace(~r/\n/, "🦛")
      |> String.replace(~r/<script.*?\/script>/, "")
      |> String.replace(~r/<style.*?\/style>/, "")
      |> String.replace(~r/[\s]+/, "")
      |> String.replace("&nbsp;", " ")
      |> String.replace(~r/<br.*?>/, "\n")
      |> String.replace(~r/<.*?>/, "\t")
      |> String.replace("🦛", "\n")
      |> String.replace(~r/[\r\n\t]+/, "\n")
      |> String.trim()

    %{
      url: result["url"],
      title: result["title"],
      content: content
    }
  end)
  |> Enum.filter(fn result ->
    String.valid?(result.content)
  end)
  |> Enum.slice(0..4)

実行結果

[
  %{
    title: "やせうま 大分県 | うちの郷土料理:農林水産省",
    url: "https://www.maff.go.jp/j/keikaku/syokubunka/k_ryouri/search_menu/menu/yaseuma_oita.html",
    content: "やせうま大分県|うちの郷土料理:農林水産省\nこのページの本文へ移動\nEnglish\n..."
  },
  %{
    title: "やせうま - Wikipedia",
    url: "https://ja.wikipedia.org/wiki/やせうま",
    content: "やせうま-Wikipedia\nコンテンツにスキップ\nメインメニュー\n..."
  },
  %{
    title: "やせうま本舗 | 明治神宮献上の大分銘菓やせうまの通販",
    url: "https://yaseuma.com/",
    content: "やせうま本舗|明治神宮献上の大分銘菓やせうまの通販\n100\n明治神宮献上銘菓..."
  },
  %{
    title: "やせうま本舗│ 田口菓子舗",
    url: "https://www.rakuten.ne.jp/gold/onsenken-oita/yaseuma_sm.html",
    content: "\uFEFF\nやせうま本舗│田口菓子舗\n税抜き8,000円以上購入で送料無料!\n郷土料理「やせうま」..."
  },
  %{
    title: "やせうま | 全国学校栄養士協議会",
    url: "https://www.zengakuei.or.jp/kyodosyoku/pref/oita_03.html",
    content: "やせうま|全国学校栄養士協議会\nホーム\n協議会紹介\n概要・沿革\n会長ご挨拶\n概要\n沿革..."
  }
]

チャンキング

Chunx でセマンティックチャンキングを実行します

セマンティックチャンキング時にテキスト埋め込みも実行しているため、そのまま利用します

{:ok, tokenizer} = Tokenizers.Tokenizer.from_file("/tmp/ruri_base/tokenizer.json")
client = Ollama.init(base_url: "http://host.docker.internal:11434/api", receive_timeout: 300_000)
Ollama.pull_model(client, name: "kun432/cl-nagoya-ruri-base")
embedding_fn = fn texts ->
  texts
  |> Enum.map(fn text ->
    client
    |> Ollama.embed(
      model: "kun432/cl-nagoya-ruri-base",
      input: "文章: #{text}"
    )
    |> elem(1)
    |> Map.get("embeddings")
    |> hd()
    |> Nx.tensor()
  end)
end
chunks =
  documents
  |> Enum.map(fn document ->
    {:ok, doc_chunks} =
      document.content
      |> Chunx.Chunker.Semantic.chunk(
        tokenizer,
        embedding_fn,
        delimiters: ["。", ".", "!", "?", "\n"]
      )

    doc_chunks
    |> Enum.map(fn chunk ->
      chunk.sentences
      |> Enum.filter(fn sentence -> String.length(sentence.text) > 2 end)
      |> Enum.map(fn sentence ->
        document
        |> Map.put(:chunk, chunk.text)
        |> Map.put(:sentence, sentence.text)
        |> Map.put(:embedding, sentence.embedding)
        |> Map.delete(:content)
      end)
    end)
    |> Enum.concat()
  end)
  |> Enum.concat()

実行結果

[
  %{
    title: "やせうま 大分県 | うちの郷土料理:農林水産省",
    chunk: "やせうま大分県|うちの郷土料理:農林水産省\nこのページの本文へ移動\nEnglish\nこどもページ\nサイトマップ\n文字サイズ\n標準\n大きく\n逆引き事典から探す\n組織別から探す\n閉じる\n大臣官房\n食料・農業・農村基本法\n食料・農業・農村基本計画\n",
    url: "https://www.maff.go.jp/j/keikaku/syokubunka/k_ryouri/search_menu/menu/yaseuma_oita.html",
    sentence: "やせうま大分県|うちの郷土料理:農林水産省\nこのページの本文へ移動\nEnglish\nこどもページ\nサイトマップ\n文字サイズ\n標準\n大きく\n逆引き事典から探す\n組織別から探す\n閉じる\n大臣官房\n食料・農業・農村基本法\n",
    embedding: #Nx.Tensor<
      f32[768]
      [0.013750884681940079, -0.004406194668263197, 0.07910104095935822, ...]
    >
  },
  %{
    title: "やせうま 大分県 | うちの郷土料理:農林水産省",
    chunk: "やせうま大分県|うちの郷土料理:農林水産省\nこのページの本文へ移動\nEnglish\nこどもページ\nサイトマップ\n文字サイズ\n標準\n大きく\n逆引き事典から探す\n組織別から探す\n閉じる\n大臣官房\n食料・農業・農村基本法\n食料・農業・農村基本計画\n",
    url: "https://www.maff.go.jp/j/keikaku/syokubunka/k_ryouri/search_menu/menu/yaseuma_oita.html",
    sentence: "食料・農業・農村基本計画\n",
    embedding: #Nx.Tensor<
      f32[768]
      [0.01888609491288662, -0.004494194872677326, 0.07754290103912354, ...]
    >
  },
  %{
    title: "やせうま 大分県 | うちの郷土料理:農林水産省",
    chunk: "食料安定供給・農林水産業基盤強化本部\nTPP(国内対策)\n",
    url: "https://www.maff.go.jp/j/keikaku/syokubunka/k_ryouri/search_menu/menu/yaseuma_oita.html",
    sentence: "食料安定供給・農林水産業基盤強化本部\nTPP(国内対策)\n",
    embedding: #Nx.Tensor<
      f32[768]
      [0.009003493003547192, -0.00902487337589264, 0.07743033766746521, ...]
    >
  },
  ...
]

インデックス作成

チャンキングしたテキスト毎の埋め込みベクトルをインデックスに登録します

``elixir
{:ok, index} = HNSWLib.Index.new(:cosine, 768, 1_000_000)

chunks
|> Enum.each(fn chunk ->
HNSWLib.Index.add_items(index, chunk.embedding)
end)


インデックスを使って、「やせうまの材料は何ですか」というテキストに近い文章を検索してみましょう

```elixir
query = "やせうまの材料は何ですか"

embeddings =
  client
  |> Ollama.embed(
    model: "kun432/cl-nagoya-ruri-base",
    input: "クエリ: #{query}"
  )
  |> elem(1)
  |> Map.get("embeddings")
  |> hd()
  |> Nx.tensor()

{:ok, labels, dist} = HNSWLib.Index.knn_query(index, embeddings, k: 10)

実行結果

{:ok,
 #Nx.Tensor<
   u64[1][10]
   [
     [365, 303, 302, 304, 358, 211, 357, 366, 231, 283]
   ]
 >,
 #Nx.Tensor<
   f32[1][10]
   [
     [0.12406063079833984, 0.12798833847045898, 0.12906521558761597, 0.13681596517562866, 0.13692384958267212, 0.13862568140029907, 0.14217764139175415, 0.14507663249969482, 0.1468788981437683, 0.1480928659439087]
   ]
 >}

上位4件のテキスト(インデックス番号)が取得できました

それぞれのテキストを結合してコンテキスト情報とします

context =
  labels
  |> Nx.to_flat_list()
  |> Enum.map(fn index ->
    chunks
    |> Enum.at(index)
    |> Map.get(:chunk)
  end)
  |> Enum.join("\n\n")

Kino.Markdown.new(context)

実行結果

スクリーンショット 2025-03-11 19.19.26.png

上位に材料の情報が入ってくれました

AIチャット

チャットには Gemma 2 を使用します

Ollama.pull_model(client, name: "hf.co/alfredplpl/gemma-2-2b-jpn-it-gguf")
Ollama.preload(client, model: "hf.co/alfredplpl/gemma-2-2b-jpn-it-gguf")

ウェブ検索結果から得られたコンテキスト情報を元にして質問に答えます

{:ok, %{"response" => response}} =
  Ollama.completion(
    client,
    model: "hf.co/alfredplpl/gemma-2-2b-jpn-it-gguf",
    prompt: """
    質問に一般的な情報ではなく、コンテキスト情報のみに基づいて回答してください
  
    ## コンテキスト情報
  
    #{context}
  
    ## 質問
  
    #{query}
    """
  )

Kino.Markdown.new(response)

実行結果

スクリーンショット 2025-03-11 19.24.05.png

正しく答えることができました

まとめ

SearXNG 、 Ollama 、 Chunx の組み合わせでウェブ検索付 AI チャットが実装できました

色々組み合わせれば何でも実装できそうですね

2
1
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
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?