はじめに
以前、 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 にアクセスできます
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(" ", " ")
|> 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)
実行結果
上位に材料の情報が入ってくれました
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)
実行結果
正しく答えることができました
まとめ
SearXNG 、 Ollama 、 Chunx の組み合わせでウェブ検索付 AI チャットが実装できました
色々組み合わせれば何でも実装できそうですね