Closed4

「HtmlRAG」を試す

kun432kun432

以前に読んだ論文

https://zenn.dev/kun432/scraps/f82e44db9e411f

GitHubレポジトリがあるのに気づいてなかった

https://github.com/plageon/HtmlRAG?tab=readme-ov-file

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クリーニングのための手法がツールとして用意されてるようなので、これを試してみたい。

kun432kun432

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

kun432kun432

日本語で使う場合は以下の課題がある。

  • build_block_treeは日本語を想定していない。
  • GenHTMLPrunerで指定するモデルには、HTML剪定タスクに特化した専用モデルが必要と思われる。

build_block_treeは、DOMツリーを再帰的に分割しているようだが、1ノードの大きさを単語数で制限、つまりスペース区切りになっている。日本語のようにスペースで単語を区切らない言語の場合には正しくカウントできない。

https://github.com/plageon/HtmlRAG/blob/42552d81c73f62aa2f88c1aa3b9c897ca71f82d9/toolkit/htmlrag/html_utils.py#L168-L228

日本語でやってみる。

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だと日本語にはあまり期待できなさそう
  • ファインチューニングが必要

というところになるのだが、スクリプトもデータセットも公開されているので、まあできなくはないかなぁ・・・

https://github.com/plageon/HtmlRAG?tab=readme-ov-file#-training

https://huggingface.co/datasets/zstanjj/HtmlRAG-train

ただ、日本語でのチューニングまで必要なのか?はちょっとわからない。

あと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>

少なくともこの例では悪くなさそう。もうちょっと色々調べてみないと判断はできないけども。

kun432kun432

まとめ

以下の記事でも書いたけど、

https://zenn.dev/kun432/scraps/f82e44db9e411f

HTML無駄な情報が多すぎて無理じゃん、と最初思ったのだけど、なるほど、CSSとかはクリーニングするのね。それならば納得。ClaudeなんかだとXMLに最適化されていることもあるし、理解してくれそう。

とはいえ、全部プレーンテキスト化しちゃうとやっぱりコンテキストが欠落しちゃうので、HTMLで文書構造だけうまく残せればコンテキストはある程度保持できそうで、少し興味深い。

というところでコンテキストの欠落を押さえれそうな感はある。

ただし、同じく前の記事の中で書いた通り、動的なコンテンツや複雑なコンポーネント構造の場合にどこまでクリーニング・剪定できるか?というところはありそう。スクレイピングでよくある課題は同じように課題になりそうで、イタチごっこになりそうな感もなきにしもあらず、かなぁ。

このスクラップは1日前にクローズされました