「HtmlRAG」を試す
以前に読んだ論文
GitHubレポジトリがあるのに気づいてなかった
HtmlRAG: RAGシステムでの検索結果のモデリングにはプレーンテキストよりもHTMLが優れている
私たちは、RAGシステムにおける外部知識のフォーマットとして、プレーンテキストではなくHTMLを使用するHtmlRAGを提案します。HTMLによってもたらされる長いコンテキストに対応するために、**「無損失HTMLクリーニング」と「2段階ブロックツリーベースHTML剪定」**を提案しています。
- 無損失HTMLクリーニング:
このクリーニングプロセスでは、完全に無関係な内容を削除し、冗長な構造を圧縮することで、元のHTMLのすべての意味情報を保持します。無損失HTMLクリーニングによる圧縮HTMLは、長いコンテキストを持つLLM(大規模言語モデル)を活用し、生成前に情報を一切失いたくないRAGシステムに適しています。- 2段階ブロックツリーベースHTML剪定:
ブロックツリーベースのHTML剪定は2つのステップで構成され、いずれもブロックツリー構造に基づいて実行されます。
最初の剪定ステップでは、埋め込みモデルを用いてブロックのスコアを計算します。このステップは無損失HTMLクリーニングの結果を処理します。
次の剪定ステップでは、パス生成モデルを用います。このステップは最初の剪定ステップの結果を処理します。HtmlRAGはこれらの手法を活用し、RAGシステムにおいてHTML形式の外部知識を効果的に活用することを可能にします。
RAGで使うということもあるんだけど、上記のHTMLクリーニングのための手法がツールとして用意されてるようなので、これを試してみたい。
Colaboratoryで。
パッケージインストール
!pip install htmlrag
!pip freeze | grep -i htmlrag
htmlrag==0.0.4
サンプル通りに進めてみる。まずHTMLクリーニング。
from htmlrag import clean_html
html = """
<html>
<head>
<title>When was the bellagio in las vegas built?</title>
</head>
<body>
<p class="class0">The Bellagio is a luxury hotel and casino located on the Las Vegas Strip in Paradise, Nevada. It was built in 1998.</p>
</body>
<div>
<div>
<p>Some other text</p>
<p>Some other text</p>
</div>
</div>
<p class="class1"></p>
<!-- Some comment -->
<script type="text/javascript">
document.write("Hello World!");
</script>
</html>
"""
simplified_html = clean_html(html)
print(simplified_html)
以下のようにscriptタグやclass属性などが削除されている。
<html>
<title>When was the bellagio in las vegas built?</title>
<p>The Bellagio is a luxury hotel and casino located on the Las Vegas Strip in Paradise, Nevada. It was built in 1998.</p>
<div>
<p>Some other text</p>
<p>Some other text</p>
</div>
</html>
このクリーニング済みのHTMLからブロックツリーを構築する。
from htmlrag import build_block_tree
block_tree, simplified_html = build_block_tree(simplified_html, max_node_words=10)
for idx, block in enumerate(block_tree):
print("インデックス: ", idx)
print("ブロックのコンテンツ: ", block[0])
print("ブロックのパス: ", block[1])
print("末端ノードか?: ", block[2])
print("")
以下のようにHTMLの文書構造に従ってツリーが作成されているのがわかる。
インデックス: 0
ブロックのコンテンツ: <title>When was the bellagio in las vegas built?</title>
ブロックのパス: ['html', 'title']
末端ノードか?: True
インデックス: 1
ブロックのコンテンツ: <div>
<p>Some other text</p>
<p>Some other text</p>
</div>
ブロックのパス: ['html', 'div']
末端ノードか?: True
インデックス: 2
ブロックのコンテンツ: <p>The Bellagio is a luxury hotel and casino located on the Las Vegas Strip in Paradise, Nevada. It was built in 1998.</p>
ブロックのパス: ['html', 'p']
末端ノードか?: True
これをベクトル化して「剪定」できるようにする
from htmlrag import EmbedHTMLPruner
embed_model="BAAI/bge-large-en"
query_instruction_for_retrieval = "Instruct: Given a web search query, retrieve relevant passages that answer the query\nQuery: "
embed_html_pruner = EmbedHTMLPruner(
embed_model=embed_model,
local_inference=True,
query_instruction_for_retrieval=query_instruction_for_retrieval
)
query_instruction_for_retrieval = query_instruction_for_retrieval, endpoint=tei_endpoint)
question = "When was the bellagio in las vegas built?"
block_rankings=embed_html_pruner.calculate_block_rankings(question, simplified_html, block_tree)
print(block_rankings)
関連性の高いノードから順にランキングが返ってくる模様。今回の例でいうと、タイトルブロックはまさにクエリそのままだし、次に、回答となる文章が含まれているブロックがランキングされている。
[0, 2, 1]
サンプルコードではBM25を使った例も記載されていた。
では「剪定」されたHTMLを出力する。
from transformers import AutoTokenizer
chat_tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-3.1-70B-Instruct")
max_context_window = 60
pruned_html = embed_html_pruner.prune_HTML(simplified_html, block_tree, block_rankings, chat_tokenizer, max_context_window)
print(pruned_html)
以下のように関連する情報だけが含まれたHTMLが生成される。
<html>
<title>When was the bellagio in las vegas built?</title>
<p>The Bellagio is a luxury hotel and casino located on the Las Vegas Strip in Paradise, Nevada. It was built in 1998.</p>
</html>
さらにLLMを使ってより細かく剪定する。ここはどうやらそれ用のモデルが用意されているようである。
from htmlrag import GenHTMLPruner
import torch
ckpt_path = "zstanjj/HTML-Pruner-Llama-1B"
if torch.cuda.is_available():
device="cuda"
else:
device="cpu"
gen_embed_pruner = GenHTMLPruner(gen_model=ckpt_path, max_node_words=5, device=device)
block_rankings = gen_embed_pruner.calculate_block_rankings(question, pruned_html)
print(block_rankings)
んー、ここはサンプルコードとは異なる結果となった。
[0, 1]
各ブロックの内容を見てみる。(ここは上の方でブロックツリー構築した際とは順番が前後しているような気がするが・・・)
block_tree, pruned_html=build_block_tree(pruned_html, max_node_words=10)
for idx, block in enumerate(block_tree):
print("インデックス: ", idx)
print("ブロックのコンテンツ: ", block[0])
print("ブロックのパス: ", block[1])
print("末端ノードか?: ", block[2])
print("")
ブロック自体は適切に構築されているように思える。
インデックス: 0
ブロックのコンテンツ: <title>When was the bellagio in las vegas built?</title>
ブロックのパス: ['html', 'title']
末端ノードか?: True
インデックス: 1
ブロックのコンテンツ: <p>The Bellagio is a luxury hotel and casino located on the Las Vegas Strip in Paradise, Nevada. It was built in 1998.</p>
ブロックのパス: ['html', 'p']
末端ノードか?: True
「剪定」されたHTMLを出力する。
max_context_window = 32
pruned_html = gen_embed_pruner.prune_HTML(pruned_html, block_tree, block_rankings, chat_tokenizer, max_context_window)
print(pruned_html)
<title>When was the bellagio in las vegas built?</title>
LLMを使ったところがうまく行ってないのだけども、流れとしてはわかった。
READMEに書いてある図を日本語化した。
refered from https://github.com/plageon/HtmlRAG and translated into Japanese by kun432
日本語で使う場合は以下の課題がある。
-
build_block_tree
は日本語を想定していない。 -
GenHTMLPruner
で指定するモデルには、HTML剪定タスクに特化した専用モデルが必要と思われる。
build_block_tree
は、DOMツリーを再帰的に分割しているようだが、1ノードの大きさを単語数で制限、つまりスペース区切りになっている。日本語のようにスペースで単語を区切らない言語の場合には正しくカウントできない。
日本語でやってみる。
from htmlrag import clean_html
html = """
<html>
<head>
<title>ラスベガスのベラージオが建てられたのはいつですか?</title>
</head>
<body>
<p class="class0">ベラージオは、ネバダ州パラダイスのラスベガス・ストリップに位置する高級ホテル&カジノ。1998年に建設されました。</p>
</body>
<div>
<div>
<p>他のテキスト1</p>
<p>他のテキスト1</p>
</div>
</div>
<p class="class1"></p>
<!-- Some comment -->
<script type="text/javascript">
document.write("Hello World!");
</script>
</html>
"""
simplified_html = clean_html(html)
print(simplified_html)
<html>
<title>ラスベガスのベラージオが建てられたのはいつですか?</title>
<p>ベラージオは、ネバダ州パラダイスのラスベガス・ストリップに位置する高級ホテル&カジノ。1998年に建設されました。</p>
<div>
<p>他のテキスト1</p>
<p>他のテキスト1</p>
</div>
</html>
from htmlrag import build_block_tree
block_tree, simplified_html = build_block_tree(simplified_html, max_node_words=10)
for idx, block in enumerate(block_tree):
print("インデックス: ", idx)
print("ブロックのコンテンツ: ", block[0])
print("ブロックのパス: ", block[1])
print("末端ノードか?: ", block[2])
print("")
以下のように1つのノードとなってしまう。
インデックス: 0
ブロックのコンテンツ: <html>
<title>ラスベガスのベラージオが建てられたのはいつですか?</title>
<p>ベラージオは、ネバダ州パラダイスのラスベガス・ストリップに位置する高級ホテル&カジノ。1998年に建設されました。</p>
<div>
<p>他のテキスト1</p>
<p>他のテキスト1</p>
</div>
</html>
ブロックのパス: ['html']
末端ノードか?: True
ここをSudachiを使って分割するようにしてみた。
from sudachipy import tokenizer
from sudachipy import dictionary
import bs4
from typing import List, Tuple, Dict, Optional, Any, cast
from collections import defaultdict
def count_japanese_words(text):
tokenizer_obj = dictionary.Dictionary().create()
mode = tokenizer.Tokenizer.SplitMode.C
words = tokenizer_obj.tokenize(text, mode)
return len(words)
def custom_build_block_tree_ja(html: str, max_node_words: int=512) -> Tuple[List[Tuple[bs4.element.Tag, List[str], bool]], str]:
soup = bs4.BeautifulSoup(html, 'html.parser')
word_count = count_japanese_words(soup.get_text())
print(word_count)
if word_count > max_node_words:
possible_trees = [(soup, [])]
target_trees = [] # [(tag, path, is_leaf)]
# split the entire dom tee into subtrees, until the length of the subtree is less than max_node_words words
# find all possible trees
while True:
if len(possible_trees) == 0:
break
tree = possible_trees.pop(0)
tag_children = defaultdict(int)
bare_word_count = 0
# count child tags
for child in tree[0].contents:
if isinstance(child, bs4.element.Tag):
tag_children[child.name] += 1
_tag_children = {k: 0 for k in tag_children.keys()}
# check if the tree can be split
for child in tree[0].contents:
if isinstance(child, bs4.element.Tag):
# change child tag with duplicate names
if tag_children[child.name] > 1:
new_name = f"{child.name}{_tag_children[child.name]}"
new_tree = (child, tree[1] + [new_name])
_tag_children[child.name] += 1
child.name = new_name
else:
new_tree = (child, tree[1] + [child.name])
word_count = count_japanese_words(child.get_text())
# add node with more than max_node_words words, and recursion depth is less than 64
if word_count > max_node_words and len(new_tree[1]) < 64:
possible_trees.append(new_tree)
else:
target_trees.append((new_tree[0], new_tree[1], True))
else:
bare_word_count += count_japanese_words(str(child))
# add leaf node
if len(tag_children) == 0:
target_trees.append((tree[0], tree[1], True))
# add node with more than max_node_words bare words
elif bare_word_count > max_node_words:
target_trees.append((tree[0], tree[1], False))
else:
soup_children = [c for c in soup.contents if isinstance(c, bs4.element.Tag)]
if len(soup_children) == 1:
target_trees = [(soup_children[0], [soup_children[0].name], True)]
else:
# add an html tag to wrap all children
new_soup = bs4.BeautifulSoup("", 'html.parser')
new_tag = new_soup.new_tag("html")
new_soup.append(new_tag)
for child in soup_children:
new_tag.append(child)
target_trees = [(new_tag, ["html"], True)]
html=str(soup)
return target_trees, html
試してみる。
from htmlrag import build_block_tree
block_tree, simplified_html = custom_build_block_tree_ja(simplified_html, max_node_words=10)
for idx, block in enumerate(block_tree):
print("インデックス: ", idx)
print("ブロックのコンテンツ: ", block[0])
print("ブロックのパス: ", block[1])
print("末端ノードか?: ", block[2])
print("")
インデックス: 0
ブロックのコンテンツ: <title>ラスベガスのベラージオが建てられたのはいつですか?</title>
ブロックのパス: ['html', 'title']
末端ノードか?: True
インデックス: 1
ブロックのコンテンツ: <p>ベラージオは、ネバダ州パラダイスのラスベガス・ストリップに位置する高級ホテル&カジノ。1998年に建設されました。</p>
ブロックのパス: ['html', 'p']
末端ノードか?: True
インデックス: 2
ブロックのコンテンツ: <p0>他のテキスト1</p0>
ブロックのパス: ['html', 'div', 'p0']
末端ノードか?: True
インデックス: 3
ブロックのコンテンツ: <p1>他のテキスト1</p1>
ブロックのパス: ['html', 'div', 'p1']
末端ノードか?: True
日本語の場合、わざわざ形態素解析しなくても、文字数とかのほうが使いやすいかもね。
import bs4
from typing import List, Tuple
from collections import defaultdict
def custom_build_block_tree_char_ja(html: str, max_node_chars: int=512) -> Tuple[List[Tuple[bs4.element.Tag, List[str], bool]], str]:
soup = bs4.BeautifulSoup(html, 'html.parser')
char_count = len(soup.get_text())
if char_count > max_node_chars:
possible_trees = [(soup, [])]
target_trees = [] # [(tag, path, is_leaf)]
while possible_trees:
tree = possible_trees.pop(0)
tag_children = defaultdict(int)
bare_char_count = 0
for child in tree[0].contents:
if isinstance(child, bs4.element.Tag):
tag_children[child.name] += 1
_tag_children = {k: 0 for k in tag_children.keys()}
for child in tree[0].contents:
if isinstance(child, bs4.element.Tag):
if tag_children[child.name] > 1:
new_name = f"{child.name}{_tag_children[child.name]}"
new_tree = (child, tree[1] + [new_name])
_tag_children[child.name] += 1
child.name = new_name
else:
new_tree = (child, tree[1] + [child.name])
char_count = len(child.get_text())
if char_count > max_node_chars and len(new_tree[1]) < 64:
possible_trees.append(new_tree)
else:
target_trees.append((new_tree[0], new_tree[1], True))
else:
bare_char_count += len(str(child))
if len(tag_children) == 0:
target_trees.append((tree[0], tree[1], True))
elif bare_char_count > max_node_chars:
target_trees.append((tree[0], tree[1], False))
else:
soup_children = [c for c in soup.contents if isinstance(c, bs4.element.Tag)]
if len(soup_children) == 1:
target_trees = [(soup_children[0], [soup_children[0].name], True)]
else:
new_soup = bs4.BeautifulSoup("", 'html.parser')
new_tag = new_soup.new_tag("html")
new_soup.append(new_tag)
for child in soup_children:
new_tag.append(child)
target_trees = [(new_tag, ["html"], True)]
html = str(soup)
return target_trees, html
block_tree, simplified_html = custom_build_block_tree_char_ja(simplified_html, max_node_chars=20)
for idx, block in enumerate(block_tree):
print("インデックス: ", idx)
print("ブロックのコンテンツ: ", block[0])
print("ブロックのパス: ", block[1])
print("末端ノードか?: ", block[2])
print("")
インデックス: 0
ブロックのコンテンツ: <div>
<p0>他のテキスト1</p0>
<p1>他のテキスト1</p1>
</div>
ブロックのパス: ['html', 'div']
末端ノードか?: True
インデックス: 1
ブロックのコンテンツ: <title>ラスベガスのベラージオが建てられたのはいつですか?</title>
ブロックのパス: ['html', 'title']
末端ノードか?: True
インデックス: 2
ブロックのコンテンツ: <p>ベラージオは、ネバダ州パラダイスのラスベガス・ストリップに位置する高級ホテル&カジノ。1998年に建設されました。</p>
ブロックのパス: ['html', 'p']
末端ノードか?: True
GenHTMLPrunner
で使用されているモデルは、Llama-3.2ベースのFTモデルのようで、
- Llamaだと日本語にはあまり期待できなさそう
- ファインチューニングが必要
というところになるのだが、スクリプトもデータセットも公開されているので、まあできなくはないかなぁ・・・
ただ、日本語でのチューニングまで必要なのか?はちょっとわからない。
あとGenHTMLPrunner
の引数にも単語数の指定があるので、ここはまた修正が必要になりそう。
そのへんを踏まえると、個人的にはEmbeddingベースの剪定だけでも十分じゃないかなという気がする。
上で生成したブロックツリーを使ってやってみる。EmbeddingはBAAI/bge-m3
に変えた。
embed_model="BAAI/bge-m3"
embed_html_pruner = EmbedHTMLPruner(
embed_model=embed_model,
local_inference=True,
)
question = "ラスベガスのベラージオが建てられたのはいつ?"
block_rankings=embed_html_pruner.calculate_block_rankings(question, simplified_html, block_tree)
print(block_rankings)
ベクトル検索はOK
[1, 2, 0]
剪定済みHTMLを作成。トークナイザーはllm-jp/llm-jp-3-13b-instruct
のものを拝借。
chat_tokenizer = AutoTokenizer.from_pretrained("llm-jp/llm-jp-3-13b-instruct")
max_context_window = 60
pruned_html = embed_html_pruner.prune_HTML(simplified_html, block_tree, block_rankings, chat_tokenizer, max_context_window)
print(pruned_html)
<html>
<title>ラスベガスのベラージオが建てられたのはいつですか?</title>
<p>ベラージオは、ネバダ州パラダイスのラスベガス・ストリップに位置する高級ホテル&カジノ。1998年に建設されました。</p>
</html>
少なくともこの例では悪くなさそう。もうちょっと色々調べてみないと判断はできないけども。
まとめ
以下の記事でも書いたけど、
HTML無駄な情報が多すぎて無理じゃん、と最初思ったのだけど、なるほど、CSSとかはクリーニングするのね。それならば納得。ClaudeなんかだとXMLに最適化されていることもあるし、理解してくれそう。
とはいえ、全部プレーンテキスト化しちゃうとやっぱりコンテキストが欠落しちゃうので、HTMLで文書構造だけうまく残せればコンテキストはある程度保持できそうで、少し興味深い。
というところでコンテキストの欠落を押さえれそうな感はある。
ただし、同じく前の記事の中で書いた通り、動的なコンテンツや複雑なコンポーネント構造の場合にどこまでクリーニング・剪定できるか?というところはありそう。スクレイピングでよくある課題は同じように課題になりそうで、イタチごっこになりそうな感もなきにしもあらず、かなぁ。