📚

自然言語処理のためのWikipediaテキストデータ抽出

2023/09/23に公開

ドキュメントのベクトル検索の実験をしようと思ったので、Wikipediaのデータを使いたくなりました。この記事ではWikipediaのアーカイブをダウンロードし、次の手順について確認し、整理すします。

  1. articles.xml.bz2からXMLを抽出
  2. XMLを下処理済みのtextに変換
  3. sqliteのDBへ格納
  4. categorylinks.sqlを基にカテゴリ別のデータを取得

途中でプレーンテキストへ変換しているので、txtやjsonで保存したい方は適宜読み替えてください。

wikipediaデータベースダウンロード

https://dumps.wikimedia.org/jawiki/latest/

日本語のwikipediaのデータを持ってくる。
jawiki-latest-pages-articles.xml.bz2 : 記事の全文のXML
jawiki-latest-categorylinks.sql.gz : 記事の属性(カテゴリや注意など)
それぞれ約4GBと6GBほどある。

jawiki-latest-categorylinks.sql.gz

articlesからXMLを抽出

https://github.com/attardi/wikiextractor

これをpipからインストールすることでCLIから一瞬でテキストが得られる。便利な世の中だ...
ただし全文の展開にはそこそこ時間がかかる。

pip install wikiextractor
wikiextractor jawiki-latest-pages-articles.xml.bz2

これによりカレントへ次のXMLファイルが格納されたディレクトリが作られる。

directory
extracted/
├── AA/
│   ├── wiki_00
│   ~
│   └── wiki_99
├── AB/
~
└── BL/ 
extracted/AA/wiki_00.xml
<doc id="5" url="https:..." title="アンパサンド">
アンパサンド
...
</doc>
<doc id="10" url="https:..." title="言語">
言語
...
</doc>
...
</doc>

このwiki_nnがXMLの本体であり、次の形式のXMLが入っている。
ここで、python上でxml.etree.ElementTreeを用いてこれを扱うためには、root付きの完全なXMLへ変換しなければならない点に注意。
抽出されたXMLは要素docの列になっているため、ElementTreeを使うには全ファイルの上下にrootとなる要素を挿入。

次のstream editorコマンドなどを全XMLへmapすると準備が整う。
要素名xmlは任意の名前で大丈夫。

sed -i -e 'li<xml>' -e '$a</xml> extracted/*/wiki_*' 
# `-i` : ファイル直接編集
# `-e` : スクリプト追加 
# `li~~~` : ファイル行頭へ~~~を挿入
# `$a~~~` : ファイル末端へ~~~を挿入
extracted/AA/wiki_00.xml(編集後)
<xml>
  <doc id="5" url="https:..." title="アンパサンド">
  アンパサンド
  ...
  </doc>
  <doc id="10" url="https:..." title="言語">
  言語
  ...
  </doc>
  ...
  </doc>
</xml>

XMLからデータを抽出

pythonでXMLをパースし、記事のID・タイトル・本文テキストの3つを抽出して、軽くクリーンする。また、軽量化も兼ねて200文字以下の記事は消した。

data_formatter.py
import xml.etree.ElementTree as et
import glob
import unicodedata
import re

# xmlの全pathをキャッチ
pathes = glob.glob('dataset_all/*/wiki*')
pathes.sort()

for p in pathes:

    # <xml>をrootとするxmlの木を作成し、直下の要素<doc>をキャッチ
    tree = et.parse(p)
    root = tree.getroot()
    doces = root.findall('doc')

    for doc in doces:
    
        # 200文字以下の記事は無視
        if len(doc.text) < 200:
            continue
        
        # UNICODE標準化や改行削除などでクリーニング
        text = unicodedata.normalize('NFKC', doc.text)
        text = text.replace('\n', '')
        text = re.sub(r'[“”]', '', text)
        text = re.sub(r'https?:\/\/.*?[\r\n ]', '', text)

        # ↓ここらへんでSQLまわりの処理を書く

このコードにより、<doc>の全情報をpython側で扱えるようになる。
この変数textはプレーンなテキストであるため、これをそのままダンプすれば素のtxt形式のデータセットが作成できる。txtやjsonで作業したい方はここでお別れです。

データをsqliteへ移行

テキスト内では多くの約物が使われているため、構造を保持しながらtxtで扱うには少し難儀しそうに感じたので、SQLでデータベース化して格納する方針にした。
サーバ通信を経由せずアプリケーションとして扱えるsqliteを使うため、DBを作成。

data_formatter.py
# ~~~
import sqlite3

# sqliteでDBを作成して操作のためのカーソルを作成
conn = sqlite3.connect('jawiki_article.db')
cur = conn.cursor()
# テーブルを作成(文字列でsqliteのQueryを直書きする)
cur.execute(
    'CREATE TABLE article( \
    id INTEGER PRIMARY KEY, \
    title TEXT, \
    text TEXT )'
    )
conn.commit()

pathes = glob.glob('dataset_all/*/wiki*')
pathes.sort()

for p in pathes:
    tree = et.parse(p)
    root = tree.getroot()
    doces = root.findall('doc')

    for doc in doces:
        if len(doc.text) < 200:
            continue
        text = unicodedata.normalize('NFKC', doc.text)
        text = text.replace('\n', '')
        text = re.sub(r'[“”]', '', text)
        text = re.sub(r'https?:\/\/.*?[\r\n ]', '', text)

        # テーブルへwikiのページIDと情報を挿入
	# このとき、テキストを直接展開するとQueryへ影響する可能性があるため
	# ?を用いてデータを流し込む
        cur.execute(
            'INSERT INTO article(id, title, text) values(?, ?, ?)',
            (doc.attrib['id'], doc.attrib['title'], text)
            )
    # 1ファイルごとに変更をコミット
    conn.commit()

# カーソルとDBへの接続を切断
cur.close()
conn.close()

これによりデータベースjawiki_article.dbが作成され、100万タイトルの内容が格納される。
標準的な使用用途であればここでおしまいであるが、4GBもある文字列をNNへ乗せるのは苦行な気がするため、次のセクション移行ではカテゴリ情報から記事を内容ベースでクリーニングし、実験で扱いやすくしていく。

カテゴリ情報を付加

jawiki-latest-categorylinks.sql.gzには各記事のカテゴリ情報が格納されている。まずこれらをsqliteへ変換し、テーブル内のcl_tocl_fromcl_typeを使って記事の属性を抽出できるようにしたい。

https://github.com/dumblob/mysql2sqlite

mysql2sqliteを利用してsqlダンプファイル2つをsqliteのDBへ変換。

gzip -d jawiki-latest-categorylinks.sql.gz
./mysql2sqlite jawiki-latest-categorylinks.sql | sqlite3 jawiki_categorylinks.db

https://sqlitebrowser.org

大きなDBはVSCode上で開けないかもしれません 閲覧はDB Browser for SQLiteが便利。
また、軽量化のためDB Browser for SQLite上で次のSQLを実行し、不必要なcolumnを削除した。また、適宜不必要なcl_toのカテゴリ名を削除し、心ばかりの軽量化を行った。

sqlite_delete_columns
ALTER TABLE categorylinks RENAME TO tmp;
CREATE TABLE categorylinks(cl_from INTEGER, cl_to TEXT, cl_type TEXT, cl_sortkey TEXT);
INSERT INTO categorylinks(cl_from, cl_to, cl_type, cl_sortkey) SELECT cl_from, cl_to, cl_type, cl_sortkey FROM tmp;
DROP TABLE tmp

COMMIT;

ここまでの流れをまとめると、最小限のデータとして次のDBが作れる。
jawiki_article.db : ページID・タイトル・本文
jawiki_categorylinks.db : ページ・属するページ・ページかカテゴリか・タイトル

jawiki_article.db
jawiki_categorylinks.db

jawiki_categorylinks.dbから、お互いが参照しないページIDのレコードを捨てる。
具体的には、テーブルcategorylinksの参照元IDcl_fromのうち、テーブルarticleのページIDidに存在しない値のレコードを破棄する。
また、column名のリネーム、容量の最適化など、雑多な処理をまとめて書いてしまう。

categorylinks.db
-- articleの`id`として参照元が存在しないcategorylinksのレコードを削除
ATTACH DATABASE './jawiki_article.db' AS article_db;
CREATE TABLE article AS SELECT * FROM article_db.article;
DELETE FROM categorylinks WHERE NOR EXISTS (
    SELECT 1 FROM article WHERE article.id = categorylinks.cl_from
    );
    
-- categorylinksのページIDに対応するタイトルのふりがな`cl_sortkey`を
-- articleの`title`に直す
ATTACH DATABASE '.\jawiki_article.db' AS article_db;
UPDATE categorylinks
    SET cl_sortkey = (
	    SELECT article_db.article.title FROM article_db.article WHERE article_db.article.id = categorylinks.cl_from 
	) WHERE EXISTS (
	    SELECT 1 FROM article_db.article WHERE article_db.article.id = categorylinks.cl_from
	);

-- categorylinksの列名を整形
ALTER TABLE categorylinks RENAME TO tmp;
CREATE TABLE categorylinks (page_id INTEGER, page_title TEXT, category TEXT);
INSERT INTO categorylinks (page_id, page_title, category) SELECT cl_from, cl_sortkey, cl_to FROM tmp;
-- categorylinks.dbの容量とindexの最適化
DROP TABLE tmp;
VACUUM;
REINDEX;

-- articleの列名を整形
ALTER TABLE article RENAME TO tmp;
CREATE TABLE article (page_id INTEGER PRIMARY KEY, page_title TEXT, content TEXT);
INSERT INTO article (page_id, page_title, content) SELECT id, title, "text" FROM tmp;
-- article.dbの容量とindexの最適化
DROP TABLE tmp;
VACUUM;
REINDEX;

COMMIT;

このjawiki_assemble.dbに問い合わせることで、カテゴリテーブルのcategoryで検索して得られたpage_idと等しい記事テーブルのpage_idを求め、テキストデータcontentを引っ張ってくることができる。

以上の方法で、Wikipediaからテキストデータを持ってくることができるようになった。やったね。

成果と課題

SQL完全に理解した。

ベクトル検索の実験を仮定しているため、100万件のテキストとそのサブセット化を推論しないと行けないというのはこの速度では無理な気がしてきた。が、最適化と整形を頑張ったところ、現実的な速度で動くようになった。
アセンブルしたDBを使ってランダムサンプリングしたデータを生成すれば軽いクラスタ分析程度ならできそうな感じがする。

次回

https://zenn.dev/inaturam/articles/fbd67646acc266

Discussion