2018年8月28日

ElasticsearchでWikipediaの全文検索にチャレンジ (2)

前置き


前回の記事からの続きです。
Wikipediaのファイルをスクリプトから処理可能なサイズに分割し、Elasticsearchのセットアップ作業を終えている前提で話を進めます。

ファイル整形

前回は、230万件のWikipediaの記事を1000件単位に分割してファイルに納めるということをしました。 まず、先頭ファイルと最終ファイルにそれぞれXMLの開始/終了がくっついているので、これを切り出します。
$WorkDir = "C:\Wikipedia"
$OutputDir = Join-Path $WorkDir "output"

# 書き込み順でソート。
$FileList = Get-ChildItem $OutputDir | Sort-Object -Property "LastWriteTime"
$FirstFile = $FileList | Select-Object -First 1
$LastFile = $FileList | Select-Object -Last 1

# 編集対象のファイルがこちらです
$FirstFile.FullName
$LastFile.FullName

# 失敗した時のためにWorkDirへバックアップ
Copy-Item $FirstFile.FullName $WorkDir
Copy-Item $LastFile.FullName $WorkDir

# ■ヘッダー編集
$LineCounter = 0
$Keyword = '<page>' # 記事の開始タグ
$Header = New-Object System.Collections.ArrayList
[System.Collections.ArrayList]$FirstFileData = Get-Content $FirstFile.FullName

# ヘッダ開始位置の確認
ForEach ($Line in $FirstFileData) {
 if ( $Line.Contains($Keyword) ) {break}
 $Header.Add($Line) >$Null
 $LineCounter = $LineCounter +1
}
$LineCounter
$Header

# ヘッダーを別ファイルへ保存
$HeaderFile = Join-Path $WorkDir "xmlHeader.txt"
$Header  -join "`n" | Out-File -FilePath $HeaderFile -Encoding utf8

# Header分離
$FirstFileData[0] #分離前確認
$FirstFileData[$LineCounter] #分離前確認

ForEach ($i in @(1..$LineCounter) ) {
 $FirstFileData.RemoveAt(0)
}

$FirstFileData[0] #分離後確認

# 上書き保存
$FirstFileData -join "`n" | Out-File -FilePath $FirstFile.FullName -Encoding utf8

# ■フッター編集
# 配列ArrayListとしてGet-Contentを実行
[System.Collections.ArrayList]$LastFileData = Get-Content $LastFile.FullName
$LastLine = @($LastFileData).length -1
$LastFileData[$LastLine] # これが最終タグ

# フッターを別ファイルへ保存
$FooterFile = Join-Path $WorkDir "xmlFooter.txt"
$LastFileData[$LastLine] | Out-File $FooterFile -Encoding UTF8

# 最終行を削除
$LastFileData.RemoveAt($LastLine)
@($LastFileData).length

# 上書き保存
$LastFileData -join "`n" | Out-File -FilePath $LastFile.FullName -Encoding utf8

これくらい開発するよりエディタでやったほうが絶対に早いんだけれども、乗り掛かった舟なのでコードにしました。

XMLオブジェクト生成


では、生成したファイルからXMLファイルを合成し、XMLオブジェクトへ変換しましょう。
$WorkDir = "C:\Wikipedia"
$OutputDir = Join-Path $WorkDir "output"
$HeaderFile = Join-Path $WorkDir "xmlHeader.txt"
$FooterFile = Join-Path $WorkDir "xmlFooter.txt"

$HeaderText = Get-Content $HeaderFile -encoding UTF8
$FooterText = Get-Content $FooterFile -encoding UTF8
$PageText = Get-Content $(Join-Path $OutputDir File-0.txt)

# XML生成
[xml]$XMLData = $HeaderText + $PageText + $FooterText

# 生成結果確認
$XMLData
$XMLData.mediawiki
$XMLData.mediawiki.page[0]
$XMLData.mediawiki.page[0].revision
$XMLData.mediawiki.page[0].revision.text

最終的にこれを0~2300のファイルに実行するイメージです。Pageは[0]~[999]です。
※未検証ですが、オフラインだとスキーマにアクセスできないのでXMLの生成に失敗すると思われます。
 その場合にはスキーマをローカルにダウンロードしてヘッダをローカル参照に書き換えてください。

投入データ選定


まず、Elasticsearchへの取り込み対象を絞りましょう。
  • page/id
  • page/revision/timestamp
  • page/title
  • page/revision/text."#text"
ぱっと見では、これだけ取っておけば全文検索には役に立ちそうな気がします。
では試してみましょう。

$Columns = "id", "timestamp", "title", "text"
$Table = New-Object System.Collections.ArrayList # 新規配列

ForEach ($page in $XMLData.mediawiki.page) {
 $Row = New-Object PSObject | Select-Object $Columns
 $Row.id = $page.id
 $Row.timestamp = $page.revision.timestamp
 $Row.title = $page.title
 $Row.text = $page.revision.text."#text"
 $Table.Add($Row) > $Null
}

$Table

それっぽい出力ができました。これを取り込めばよさそうです。
Wiki記法が気になりますが、恐らくSTOPWORDというElasticsearchの機能でなんとかなるんじゃないかと楽観視しているので、一旦編集処理はしません。
もし必要なら$Row.textを文字列置換とかでいい感じに編集するようにしてみてください。

設定ファイル生成


先日の記事でもやりましたが、まずはアップロードするファイルをElasticsearchに納めるうえで、Mappingの定義ファイルを作成します。
まず、先ほど作成した変数$Tableから5件Elasticsearchに登録します。

# ■Elasticsearch 登録先情報
$Hostname = "localhost"
$PortNumber = 9200
$IndexName = "wikipedia"
$TypeName = "page"
$BaseURL = "http://${Hostname}:${PortNumber}"
$APIURL = "/${IndexName}/${TypeName}/_bulk"
$RequestURL = $BaseURL + $APIURL
$ReqestType = "POST"
$BulkCommand = @{ "index" = @{ "_index" = $IndexName ; "_type" = $TypeName } } | ConvertTo-Json -Compress

# ■Bulk実行用のJsonデータ生成
$BulkData = New-Object System.Collections.ArrayList # 新規配列
ForEach ($Counter in @(0..4) ) {
 $JsonData = $Table[$Counter] | ConvertTo-Json -Compress
    $BulkData.Add($BulkCommand) > $Null
    $BulkData.Add($JsonData) > $Null
}

Write-Output $BulkData

# ■ Bulk実行
$JsonData = $( $BulkData -join "`n" ) + "`n"
$PostParam = [System.Text.Encoding]::UTF8.GetBytes($JsonData) # 日本語が文字化けするのでUTF8を強制
$Result = Invoke-RestMethod -Uri $RequestURL -Body $PostParam -ContentType 'application/json' -Method $ReqestType
$Result

# ■投入結果確認
$APIURL = "/${IndexName}/${TypeName}/_search"
$RequestURL = $BaseURL + $APIURL
$Result = Invoke-RestMethod -Uri $RequestURL
ConvertTo-JSON $Result -Depth 10

# ■現在のMAPを取得
$MapFile = Join-Path $WorkDir "map.json"
$APIURL = "/${IndexName}/${TypeName}/_mapping"
$RequestURL = $BaseURL + $APIURL
$Result = Invoke-RestMethod -Uri $RequestURL

ConvertTo-JSON $Result."${IndexName}" -Depth 10 | Out-File -Encoding Default -FilePath $MapFile

では、このMAPファイルを編集していきましょう。データ型は以下のページを参考にします。
https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-types.html
更にKuromojiを利用するための設定として以下のページの内容も組み込みます。 https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-kuromoji-tokenizer.html

{
    "mappings": {
        "page": {
            "properties": {
                "id": {
                    "type": "integer"
                },
                "text": {
                    "type": "text",
                    "fielddata": true,
                    "store": true
                },
                "timestamp": {
                    "type": "date"
                },
                "title": {
                    "type": "keyword"
                }
            }
        }
    },
    "settings": {
        "index": {
            "analysis": {
                "tokenizer": {
                    "kuromoji_user_dict": {
                        "type": "kuromoji_tokenizer",
                        "mode": "search"
                    }
                },
                "analyzer": {
                    "wikipedia_analyzer": {
                        "type": "custom",
                        "tokenizer": "kuromoji_user_dict"
                    }
                }
            }
        }
    }
}

textフィールドの謎指定「fielddata」「store」は何かうまく動かなかったので以下を参考に指定してみた的なおまじないです。時間がある時に何者でどんな効果が期待できるのか調べたいです。よくわかってないので一旦ぶん投げておきます。
https://qiita.com/tsgkdt/items/9ac7c8ffd74e2baee79d

では既存Indexを削除してこの定義を取り込んでみましょう。

# ■定義更新
# ○既存Indexの削除
Function Delete-Index () {
    $APIURL = "/${IndexName}"
    $RequestURL = $BaseURL + $APIURL
    $ReqestType = "DELETE"
    Invoke-RestMethod -Uri $RequestURL -Method $ReqestType
    Write-Output $("`t Delete-Index() : " + $(Get-Date) )
}

Delete-Index

# ○MAP定義登録
Function Create-TypeItem ( [string]$JsonFilePath ) {
    $APIURL = "/${IndexName}"
    $TargetURL = $BaseURL + $APIURL
    $ReqestType = "PUT"
    $JsonData = Get-Content $JsonFilePath
    # 文字化けするのでUTF8を強制
    $PostParam = [System.Text.Encoding]::UTF8.GetBytes($JsonData)
    Invoke-RestMethod -Uri $TargetURL -Body $PostParam -ContentType 'application/json' -Method $ReqestType
    Write-Output $("`t Create-TypeItem( ${JsonFilePath} ) : " + $(Get-Date) )
}

$JsonFilePath = Join-Path $WorkDir "map.json"
Create-TypeItem -JsonFilePath $JsonFilePath

取り込みエラーが出なかったら即座に確認です。
$JsonData = '{"analyzer": "wikipedia_analyzer", "text": "吾輩は猫である。"}'
$ReqestType = "POST"
$APIURL = "/${IndexName}/_analyze"
$RequestURL = $BaseURL + $APIURL
$PostParam = [System.Text.Encoding]::UTF8.GetBytes($JsonData) # 日本語が文字化けするのでUTF8を強制
$Result = Invoke-RestMethod -Uri $RequestURL -Body $PostParam -ContentType 'application/json' -Method $ReqestType
ConvertTo-JSON $Result -Depth 10

N-Gramではなく、Kuromojiが適用されていそうです。なお、確認方法は以下のページを参考にしました。
http://pppurple.hatenablog.com/entry/2017/05/28/141143

つまりここでKuromojiを指定しなければ、N-Gramされてしまうのですね。単語間にスペースのついてない言語って英語圏の人たちは頭を抱えたくなるのでしょうか。
$JsonData = '{"text": "monster analyzed you"}'
$ReqestType = "POST"
$APIURL = "/${IndexName}/_analyze"
$RequestURL = $BaseURL + $APIURL
$PostParam = [System.Text.Encoding]::UTF8.GetBytes($JsonData) # 日本語が文字化けするのでUTF8を強制
$Result = Invoke-RestMethod -Uri $RequestURL -Body $PostParam -ContentType 'application/json' -Method $ReqestType
ConvertTo-JSON $Result -Depth 10

$JsonData = '{"text": "吾輩は猫である。"}'
$ReqestType = "POST"
$APIURL = "/${IndexName}/_analyze"
$RequestURL = $BaseURL + $APIURL
$PostParam = [System.Text.Encoding]::UTF8.GetBytes($JsonData) # 日本語が文字化けするのでUTF8を強制
$Result = Invoke-RestMethod -Uri $RequestURL -Body $PostParam -ContentType 'application/json' -Method $ReqestType
ConvertTo-JSON $Result -Depth 10

あとはデータを投入する処理で完了という感じですが、長くなったので次回に持ち越しです。いいところで続くって続きが楽しみになりませんか。

0 件のコメント:

コメントを投稿

TIPS:VSCodeで日本語化がうまくいかないとき

前置き Visual Studio Codeで拡張機能「 Japanese Language Pack for Visual Studio Code 」を入れたら日本語になりますよね。 でも、「 Remote Development 」で色々な環境を日本語化してると、偶に...