はてだBlog(仮称)

私的なブログど真ん中のつもりでしたが、気づけばWebサイト系のアプリケーション開発周りで感じたこと寄りの自分メモなどをつれづれ述べています。2020年6月現在、Elasticsearch、pandas、CMSなどに関する話題が多めです。...ですが、だんだんとより私的なプログラムのスニペット置き場になりつつあります。ブログで述べている内容は所属組織で販売している製品などに関するものではなく、また所属する組織の見解を代表するものではありません。

ほとんど何もしないディレクトリ同期風 Pythonプログラムの例

はじめに

例えば、自分の中ではあるあるなのですが、静的HTML*1のサイトのデータについてある要素を一括書き換えする...みたいなニーズがあります。

CMSやなんらかの仕掛けによりあっさり完了ということも多いでしょうが、一方、そうでないこともやはり少なくない気がします。

仮にCMS管理されていても、フィールド管理されているデータならまだしも、フリーテキストの中に埋もれているような要素だとすると結構やっかいです。

また、書き換えたい内容が、htmlの形にしたもので初めて条件が決まる場合もあり、この場合は出力内容のhtmlのテキストで物事を見る必要があるため、もっと厄介でしょう。

いずれにせよ、最終的には機械的な置換で対応できるとしても、取りうる手法の中でもっとも妥当な方法を見出す前に、いくつかデータ調査を行う必要もあるでしょう。

スゴ腕の人は最短距離で進めることもできるのでしょうし、本格アプリ・機能として実現するならそれなりのフレームワークや準備が可能です。

一方、私のようなそうではないヒトは、まずはその際に許される環境やその時期に自分の手に馴染んでいる言語などで、安心して実験できるアドホックで、かつナイーブゆえに取り回しやすい、スキャフォールドがあると便利だと感じています。

コピーして書き直して...

大層なことを言いましたが、前項の目的にしたがった一例として、あるディレクトリを再帰的に巡回して、ひとまずメモリに読み込んで、ターゲットディレクトリに階層をそのまま吐き出すPythonプログラムの例です。

import glob
import os
import sys
import codecs

# コピー先のディレクトリと既存でそのディレクトリが存在しても上書きするかのチェック
# コマンド起動時引数で制御した方がよさそうだが今回はそうはしていない
FORCE = True
EXPDIR = 'foo'

# syncディレクトリの拡張子がhtmlのファイルを一括で読み込む
#  ※ファイル数がある程度限られること、ツール扱いであることを前提として次の処理とした。
file_names = [i for i in glob.glob('**',recursive=True) if i.startswith('sync/') and i.endswith('.html') ]
files = []
for fn in file_names:
    print(fn,file=sys.stderr)
    with codecs.open(fn, 'r', 'utf-8') as f:
        files.append({'fn':fn, 'data': f.read()})

# 出力ディレクトリを生成して、そのディレクトリに移動
try:
    if FORCE: 
        os.makedirs(EXPDIR,exist_ok=True)
    else:
        os.makedirs(EXPDIR)
except FileExistError:
    print('出力用ディレクトリが存在します。', file=sys.stderr)
    sys.exit()

try:
    os.chdir(EXPDIR)
except:
    print('出力用ディレクトリに移動できませんでした。', file=sys.stderr)
    sys.exit()
    

for f in files:
    fn = f['fn']
    fdata = f['data']
    """
    ここでfdataに対して、リンクの変換などを実験する。
    """
    os.makedirs(os.path.dirname(fn), exist_ok=True)
    outputdata = fdata 
    with codecs.open(fn, 'w', 'utf-8') as f:
        f.write(outputdata)

まとめ

もともとこのブログの対象読者ターゲットは私自身なのですが、今回はいつも以上かも。

この類のスニペットは厳格に管理するものでもないので、こんな感じでリマインダにしておくのが都合が良いのです。

追伸

Pythonに限らないが、複数階層のディレクトリ作成は標準のライブラリの中でもベーシックな関数では作成できなくて、応用型の関数に位置付けられる気がする。

一方、ツールの類ではディレクトリ名を与えるとサクッとディレクトリを掘って欲しいことも多い*2のだけど、Pythonの場合は、makedirsがそれにあたるので、上記のサンプルプログラムでもそれを使っている。

docs.python.org

なお、自分は慣れていないので使っていないが、pathlibのPath.mkdirは、複数階層に対応してそう。

docs.python.org

参考

ここまで読んでくれた方なら興味があるかもしれない他記事↓

itdepends.hateblo.jp

itdepends.hateblo.jp

itdepends.hateblo.jp

*1:静的HTMLともかぎりませんが、静的HTMLで管理した方が良いというようなサイトの方がしばしば発生しやすい気がします。

*2:つまりUnix互換の世界の mkdir -p foo/bar

静的HTMLサイトの画像が規約にしたがっているかチェック(Python / os.path.commonpath) と昔話

はじめに

Pythonの次のライブラリ/便利メソッドの簡単な紹介です。

紹介といいながらも、上記を個人的な昔話にからめながら、スニペット事例をペタペタとはってみたというところになります。

昔話

CMSの移行時などにコンテンツデータを整備したいといったケースではあるあるではないかと思うのですが、現行のコンテンツのHTML(特に一点もののページ)において、共有画像がただしく共有ルールにしたがって共有されているかということをチェックしたいことがあります。

また、逆の話として、本来はあるページでのみ利用するべき個別画像が必要以上に他のページから直リンクされており、掲載期限との兼ね合いの運用をいたずらに難しくしているというものがないかチェックしたいというのもあるでしょう。

この辺の話が難しいのは、大半はマークアップ規約やCMS製品の仕組みでうまく取り計らっているものの、いかんせん「コンテンツ」というのは主観によるところが大きく、せいぜい半構造化の範囲である程度ゆるやかな運用にせざるを得ないため、例外がつきものというところです。

具体的には、下の図のように(図の例は多少デフォルメ・単純化されていますが)、理想どおりになっているOKパターンとそうでない例外となってしまったNGパターンを調査するといったことが考えられます。

f:id:azotar:20200202233438p:plain

以前は、結構この手のチェックにいろいろバッドノウハウ含め手間を要していた用に思いますが、最近はそもそもこういうことが起きにくくなってきたこともありましょうが、BeautifulSoupやらのスクレイピング系のライブラリなどを活用すれば、ワンライナーに毛が生えたぐらいのスクリプトでさくっと調査できてしまうなぁと感じる次第です。

スニペット

ということで事例です。

(1)あるディレクトリ配下の全てのHTMLを読み込みimgタグのsrc属性を抜き出す

import glob
from bs4 import BeautifulSoup as sp
import bs4 
from urllib.parse import urljoin 
import sys

# ルート相対ディレクトリで走行したとする。配下の拡張子がhtmlのファイルを読み込みする。
# マシンスペックに見合わないようなファイル数だと破綻するので注意。最低限、iglobにするとかは必要かも。
files =  [{ 'fn': i, 'file':open(i).read()} for i in glob.glob('**',recursive=True) if i.endswith('.html')] # エラー処理は省略
chklist = []

for f in files:
    bsobj = sp(f['file'], 'html.parser')
    fname = '/' + f['fn'] # ルート相対ディレクトリで走行した扱いのため「/」を補う。
    imgsrc = [ [fname, urljoin(fname,i.get('src')) ] for i in bsobj.find_all('img') if i.get('src')] 
    chklist.extend(imgsrc) 

print('page,img')
print( '\n'.join([ i[0] + ',' + i[1] for i in sorted(chklist,key=lambda x:x[1]) ] ) )



(2)ファイル名→そのファイル名のimgタグのリンク先の関連テーブル風データを元に、共通ルールにしたがって画像管理されているかチェックする

前述の②-OK/NGパターンを判定しています。

本来は、(1)の続きですが、ここでは、コピペでも動作例が確認できるように、インプットデータはプログラム内のCSV風文字列にしています。

import pandas as pd
import io
import sys
import os

csv = """
page,img
/xxx/a/b/foo.html,/xxx/a/img_ng/ng.jpg
/xxx/a/b/bar.html,/xxx/a/img_ng/ng.jpg
/xxx/boo.html,/xxx/a/img_ng/ng.jpg
/xxx/a/b/foo.html,/xxx/a/img/i.jpg
/xxx/a/b/bar.html,/xxx/a/img/i.jpg
"""

# 実際はpandasを介すほどではないかもしれない。
# しかし、手軽なCSVの読み込みと、ループ処理を宣言的に実装できることと、今回はそこまでやっていないが他に何かやりたい時のshape変更の伸び代を期待して、pandasを利用。

df = pd.read_csv(io.StringIO(csv))

a = df.groupby('img')['page'].apply(list).reset_index()

def 期待通り共有されているか判定(s):
    IMGDIR_PREFIX = ('/img','/images/') # ここでは「imgで始まるディレクトリ」「imagesディレクトリ」は共通フォルダ用の画像フォルダの命名ルールであるとした。
    pages = s['page']
    imgpath = s['img']
    commonpath = os.path.commonpath(pages)
    commonpath = os.path.commonpath([imgpath,commonpath])
    path = imgpath.replace(commonpath, '')
    return [pages,imgpath,'共通パス:'+path,'判定' + str(path.startswith(IMGDIR_PREFIX)) ]

a.apply(期待通り共有されているか判定,axis=1).to_csv(sys.stdout)

↓実行結果

0,"[['/xxx/a/b/foo.html', '/xxx/a/b/bar.html'], '/xxx/a/img/i.jpg', '共通パス:/img/i.jpg', '判定True']"
1,"[['/xxx/a/b/foo.html', '/xxx/a/b/bar.html', '/xxx/boo.html'], '/xxx/a/img_ng/ng.jpg', '共通パス:/a/img_ng/ng.jpg', '判定False']"

おわりに(ひとりごと)

昔話をおさらいしたくて記事にしてしまった。

標準ライブラリを使って良いのであれば、もっとシンプルかつ堅牢にかけるかもですが、それはデキる人にまかせよう。

あと、記事を書きながら、この類は最近は不要かもと思ったけど、リソースをCDNに置いたりするから逆に必要かもとおも思ったりもした。

参考リンク

os.path.commonpath

commonpathの公式R。

docs.python.org

ちなみに、os.pathの上等版のpathlibにはぱっと見、commonpathに対応するものはない模様。

例↓

In [5]: os.path.commonpath(['/xxx/a/b/foo.html', '/xxx/a/b/bar.html', '/xxx/boo.html'])                                                                 
Out[5]: '/xxx'

os.path.commonprefix

...というものもある。名前のとおり。

https://docs.python.org/ja/3/library/os.path.html#os.path.commonprefix

目的外使用ながら、文字列のリストの最初の部分の共通項を抜き出すのにも使える。

しっかり調べたりする時間がが無い場合にはその用途で使っちゃうかも。

In [21]: os.path.commonprefix(['ああ/xxx/a/b/foo.html', 'ああ/xxx/a/b/bar.html', 'ああ/xxx/boo.html'])                                                  
Out[21]: 'ああ/xxx/'

Python os.urllib.parse.urljoinでHTML内の相対パスをルート相対パスにお手軽に変換 と 昔話

はじめに

Pythonの標準ライブラリの

  • os.urllib.parse.urljoin
  • os.path.join
  • os.path.normpath

スニペット事例紹介です。

特に、os.urllib.parse.urljoinについては、

「/a/b/c/foo.html」と「../../common/img/z.jpg」を引数に与えると、「/a/common/img/z.jpg」を直接戻してくれる

という、相対パスからのルート相対パス変換@静的HTMLの分析・パッチ/変換あるあるをダイレクトに実装してくれるので、アドホックに手軽にこのようなことをやりたい場合は非常にありがたいライブラリでした。

昔話(静的HTML内の相対パスリンクをルート相対パスに変換)

実際の紹介の前に昔話をひとつ。

よくある話ですが、Bシェルのシェルスクリプトしか使えない(perlも使えない)環境で、静的html内のリンクを相対パスからルート相対パスに変換したりということがしばしばありました。

やりたいことはこんなことです。↓

f:id:azotar:20200202133801p:plain

この変換ですが、シェル芸のエキスパートならまだしも、そうではない自分には結構手間な話でした。

というのも次の図のように、親ディレクトリを示す「ドット2つ(..)」のパスの変換も必要になるからです。

f:id:azotar:20200202140906p:plain

ひとつのアイディアとして、シェルスクリプトの中で実際に「『cd』して『pwd』」して得られた値を使ってしのいでいましたが、これはこれでいろいろ手間です。

まあ、Bシェルと言っても、bashは使えたので、バッククォート版ではなくて、「$と()」によるコマンド置換*1は利用できましたのでその分は気軽でしたが...

ディレクトリやファイル名のパス結合

前述の例ぐらいなら言語によっては標準ライブラリがあるんじゃないということで、いまさらながら公式Rをみたりやググってみたところ、「結合」という意味では次の関数が使えそうです。

os.path.join docs.python.org

os.urllib.parse.urljoin docs.python.org

なお、似た観点のライブラリとして、ある相対パスに関して、実行環境での実際の絶対パスを得るという、Path.resolve()という関数もあります。ここではリンクするに留めておきます。

Path.resolve https://docs.python.org/ja/3/library/pathlib.html#pathlib.Path.resolve

やってみる

やってみた方が早そうですので、説明無用で次のスクリプトを起動してみます。

from urllib.parse import urljoin

d = [
'/a/b/c/',
'/a/b/c/index.html',
'/a/b/c/foo.html',
'/a/b/c/bar',
'/a/b/c'
]

for i in ['d/e/f.jgp', '../../y/z.png']:
    print('-----')
    print(i)
    for j in d:
        print('--')
        j_i = j.ljust(20,' ') + i.ljust(20,' ') 
        print('ospath:',os.path.join(j, i).ljust(40,' '), j_i)
        print('osnorm:',os.path.normpath(os.path.join(j, i)).ljust(40,' '), j_i)
        print('urllib:',urljoin(j, i).ljust(40,' '), j_i)

↓ 実行結果

-----
d/e/f.jgp
--
ospath: /a/b/c/d/e/f.jgp                         /a/b/c/             d/e/f.jgp           
osnorm: /a/b/c/d/e/f.jgp                         /a/b/c/             d/e/f.jgp           
urllib: /a/b/c/d/e/f.jgp                         /a/b/c/             d/e/f.jgp           
--
ospath: /a/b/c/index.html/d/e/f.jgp              /a/b/c/index.html   d/e/f.jgp           
osnorm: /a/b/c/index.html/d/e/f.jgp              /a/b/c/index.html   d/e/f.jgp           
urllib: /a/b/c/d/e/f.jgp                         /a/b/c/index.html   d/e/f.jgp           
--
ospath: /a/b/c/foo.html/d/e/f.jgp                /a/b/c/foo.html     d/e/f.jgp           
osnorm: /a/b/c/foo.html/d/e/f.jgp                /a/b/c/foo.html     d/e/f.jgp           
urllib: /a/b/c/d/e/f.jgp                         /a/b/c/foo.html     d/e/f.jgp           
--
ospath: /a/b/c/bar/d/e/f.jgp                     /a/b/c/bar          d/e/f.jgp           
osnorm: /a/b/c/bar/d/e/f.jgp                     /a/b/c/bar          d/e/f.jgp           
urllib: /a/b/c/d/e/f.jgp                         /a/b/c/bar          d/e/f.jgp           
--
ospath: /a/b/c/d/e/f.jgp                         /a/b/c              d/e/f.jgp           
osnorm: /a/b/c/d/e/f.jgp                         /a/b/c              d/e/f.jgp           
urllib: /a/b/d/e/f.jgp                           /a/b/c              d/e/f.jgp           
-----
../../y/z.png
--
ospath: /a/b/c/../../y/z.png                     /a/b/c/             ../../y/z.png       
osnorm: /a/y/z.png                               /a/b/c/             ../../y/z.png       
urllib: /a/y/z.png                               /a/b/c/             ../../y/z.png       
--
ospath: /a/b/c/index.html/../../y/z.png          /a/b/c/index.html   ../../y/z.png       
osnorm: /a/b/y/z.png                             /a/b/c/index.html   ../../y/z.png       
urllib: /a/y/z.png                               /a/b/c/index.html   ../../y/z.png       
--
ospath: /a/b/c/foo.html/../../y/z.png            /a/b/c/foo.html     ../../y/z.png       
osnorm: /a/b/y/z.png                             /a/b/c/foo.html     ../../y/z.png       
urllib: /a/y/z.png                               /a/b/c/foo.html     ../../y/z.png       
--
ospath: /a/b/c/bar/../../y/z.png                 /a/b/c/bar          ../../y/z.png       
osnorm: /a/b/y/z.png                             /a/b/c/bar          ../../y/z.png       
urllib: /a/y/z.png                               /a/b/c/bar          ../../y/z.png       
--
ospath: /a/b/c/../../y/z.png                     /a/b/c              ../../y/z.png       
osnorm: /a/y/z.png                               /a/b/c              ../../y/z.png       
urllib: /y/z.png                                 /a/b/c              ../../y/z.png       

まとめ

上記の結果をみやすいように次の表にしました。

f:id:azotar:20200202141117p:plain

各関数の位置付け・生い立ちからすると当たり前ですが、html内の相対パスを変換するという用途については、やはりurljoinが都合が良さそうです。

ただし、os.path.joinとnormpathからすると、スラッシュ有無を起点にディレクトリかどうかを判断しているようですので、2,3,7,8あたりの結果にケチをつかられるのはある意味いいがかりかもしれません。

また、「/a/b/c」の例の動作を例にとって、os.path.joinの方がurljoinよりも用途にあっているという場合もしばしばあるかもしれません。

以上、「昔話」を成仏させるために筆をとってみました。

おまけ

ターゲットとなるファイルのルート相対パスと起点となるディレクトリ・ファイルが分かっていて、ターゲットとなるファイルの相対パスが欲しい場合もあります。

次の図のような今回の本題と反対の演算です。

f:id:azotar:20200202133900p:plain

こちらは、次のように、os.path.relpathを使うと、あっさり対応できそうです。

In [1]: import os                                                                                                                                       

In [2]: os.path.relpath('/a/y/z.png',start='/a/b/c/')                                                                                           
Out[2]: '../../y/z.png'

※ startに渡すディレクトリの形式には注意!

os.path.relpath

docs.python.org

その他参考リンク

記事本文ではos.pathを例に挙げましたが、pathlibにシフトしていくべきなのかもしれません。

docs.python.org

※ ページの最後に、os.pathと pathlibの同等のものの対応表があります。

https://google.github.io/styleguide/shell.xml

linuxjm.osdn.jp

BeutifulSoupでお手軽DOMツリーのテキスト出力

はじめに

Python、BeautifulSoupふと思い出し企画です。

  1. Pythonスクレイピングライブラリである、BeutifulSoup4 についてオレオレ切り口でちょっとだけふれています。
  2. BeautifulSoup4やScrapyというキーワードで言うと、スクレイピングやそもそものクローラーという話題になるのですが、ここではそれらの前段であるhtml/htmlファイル群に対する探索的データ解析の視点に寄せています。
  3. よって、どちらかといえばデータ抽出などの目的よりは、例えば、あるCMSから別のCMSにデータを移行したい場合にhtmlマークアップ構造とドキュメントの共通構造を切り出し新たなスキーマを見出すために、ざっくりhtmlの構造をオーバービューしたいといった場合をイメージしています。
  4. Elasticsearchなどの検索エンジンに検索対象のhtmlドキュメントのデータを抜き出してインポートするにあたり、htmlのどのブロックを抜き出しておけば、丸っと全体を抜き出すよりもより各ドキュメントの特徴を表すブロックにフォーカスできるよね、そのような記述がされているエリアはどこかな...といったことを見出すといったケースをイメージしています。

という大層なところからはやや飛躍しますが、ひらたく言うとBeautifulSoupでコマンドラインテキストベースのDOMツリーを出力する習作もかねたツールを作ってみたという記事です。

参考リンク

本題の前に、BeautifulSoup公式へのリファレンスです。

www.crummy.com

https://www.crummy.com/software/BeautifulSoup/bs4/doc/#kinds-of-objects

https://www.crummy.com/software/BeautifulSoup/bs4/doc/#contents-and-children

BeautifulSoupなどをクローラースクレイピング用途で使う場合とオーバービュー用途で使う場合の違い

この記事の立ち位置をもっとはっきりさせるために、私の感じているクローラースクレイピング用途とオーバービューの違いに軽くふれます。

前者はなんらかの方法でおおよそ欲しいデータがマークアップのどこにあるかわかっている状態で、そのブロックを効率的に抜き出すことが第一目的です。

よって、この視点でいうと、find_allやselect、あるノードの前後や親子のノードなどを取得するparent、childrenといった、セレクタ/セレクタ風のノードへのアクセスメソッドの使いこなしがメインになります。

一方、後者はそもそも、htmlの構造がどいう傾向になっているか・例外は多くないか、欲しい情報があるノードはどこか、それに対してどんなセレクタでアクセスすれば良いか/外れが少ないかといったことを見出すことが目的です。

と、偉そうに言いましたが、後者はページ内の全てのリンクを出力して分析したり*1、そもそもDOMのツリーを気軽にみてみたいということになります。

特にDOMのツリーを気軽に(しかも複数のhtmlを横断的に目grepで大まかに把握したい)という場合には、リッチなUIよりも、CUI寄りのIFが手頃で取り回しやすいのではという発想もあるかと思います。

具体的には、次の構造のhtmlファイルをインプットに、

f:id:azotar:20200130021120p:plain

次の図のような出力が得られれば、ある程度、キーボードから手を離さずに、データの俯瞰するようなアナライズができるんじゃないかというところがこの記事の出発点です(しゴールです)。

f:id:azotar:20200130021106p:plain

※上記のhtmlは公式リファレンスのhtmlファイルをブラウザの開発者ツールで見たものと、本記事で作成してみたツールで同じhtmlを元にDOMツリーを出力したものです。

本題

Tag, NavigableString, BeautifulSoup, and Comment.

DOMツリーのテキスト出力となると、ループと再帰処理になりそうです。

BeautifulSoupはもちろんそういうこともできる(できないはずはない)のですが、先述のとおり、スクレイピングのニーズの方が強いからから、宣言的に必要なノードにアクセスするfind_allなどの紹介記事が多いように思います。

そう、意外にもループ処理・再帰処理を行うための観点を解説したものがググっても見つけられないのです。

ググラビリティが低いのか私がググり下手なのかはわかりませんが、BeautifulSoupでhtmlドキュメントの上部の方からネスト構造を辿りながらノードを取得していくにはどうするんじゃいというところなのですが、結論としては、contentsメソッド(アクセサ?)を使えば良さそうです。

ただし、このcontentsメソッドですが、使いどころというか使えどころがぱっと見分からないので注意です。

いやちゃんと分かる人には分かるのかもしれませんが、私は慣れるまで時間がかかりました。

慣れてみれば、ああ確かにこんな動作になるのねというところですが、結局のところ、次の図のような構造というところが分かればある程度すっきりします。

Tag, NavigableString, BeautifulSoup, and Comment.

の4つのオブジェクトです。

f:id:azotar:20200130021401p:plain

なので、実際は「contents」を使って

f:id:azotar:20200130021440p:plain

のように、得られたリストを「型」の種別を判定しながら、最下層(行き止まり)は、NavigableStringになるだろうから、そこまで繰り返し掘っていくと良さそう!

DOMツリー出力サンプルプログラム

続いて、DOMツリーを出力するPython/BeautifulSopuのツールプログラムの例です。

bs4.element.Tagのcontentsを再帰的に掘っていく処理としています。

ポイントは、自作のdumpdomdict関数とdomdict関数です。

インプットのDOM(BeautifulSoupオブジェクト)を、domdict関数で、タグ名を軸にネストされたdictに変換します。

そして、得られたdict(ある構造になっていることが担保されている)を再帰的に、標準出力(実際はなぜか標準エラー出力にしておきましたが...)に出力しています。

from bs4 import BeautifulSoup as sp
import bs4 
import glob
import sys

STRING_CHOP_LENGTH = 30
def domdict(bsobj: bs4.element.Tag):
    """
    htmlのdomツリーを解析して、dictの配列のネスト構造のデータを返す
        ※ タグ名:[配下のタグなどの配列] ... のようなdictを形成する
    """
    global MAX_LENGTH
    if isinstance(bsobj, bs4.element.Tag): # タグの場合
        _id = bsobj.get('id')
        _class = bsobj.get('class')
        # 「h3」「code   class=docutils literal」のようなキーを形成する
        tag = bsobj.name + '   ' + ('class=' + ' '.join(_class) if _class else '') + '   ' + ('id=' + _id if _id else '')
        #  div:[{div:[{ul:[]}]}] のようなデータを形成する(domdictを再帰的にコールしている)
        return {tag: [j for j in [domdict(i) for i in bsobj.contents] if len(j) > 0]} 
    elif type(bsobj) is bs4.element.NavigableString: # テキストの場合
        return bsobj.string.replace('\n','')[0:STRING_CHOP_LENGTH]  # タグの階層構造を明らかにする目的のため、テキストなどは読み捨てる 
    else:         
        return ''  # コメントタグは読み捨てる 


def dumpdomdict(dd: dict,n=0,nmax=100,excltags=[]):  # dictの配列のネスト構造になっている
    """
    domdictで生成したdictをツリー風に標準エラーに出力する
    """
    global CNT
    _INDENT = '  '
    _PRFX = str(CNT).zfill(6) + ': ' + str(n).zfill(len(str(nmax))) + (_INDENT * n) #出力通番と階層数値(例「000503: 009」)
    if (n >= nmax): # 深さ限界であれば何もせずreturnする
        return None
    if isinstance(dd, str):  # 文字列だった場合はその値を出力してreturnする
        print( _PRFX + (_INDENT * 30) +  dd, file=sys.stderr)
        CNT = CNT + 1
        return None
    for k, v in dd.items(): # ここまで至る場合はdict型のはずなのでキーを取り出して出力する
        if k.split(' ')[0] not in excltags: # 出力許可(NGでない)タグの場合、タグ名を出力する
            print( _PRFX + k, file=sys.stderr) 
            CNT = CNT + 1
            for i in v: # ここまで至る場合はvはlistのはずなので、順に処理する
                dumpdomdict(i, n + 1, nmax=nmax, excltags=excltags)
                

# 基本の分析対象(リンク等)
files =  [ open(i).read() for i in glob.glob('cr/*.html') ] #乱暴な方法。件数が少ない場合にしか使うべきではないがここでは簡略化のためこのとおり。
tags = 'a img script iframe'.split(' ')
attrs = 'href src src src'.split(' ')

chklist = []
chklist_jsdump = []
textdump = []

for f in files:
    CNT = 1

    bsobj = sp(f, 'html.parser')
    print(bsobj.title.text,file=sys.stderr)

    # 直下に記載のJavaScriptのテキスト取得
    chklist_jsdump.extend([i.text.replace('\n', '改行') for i in bsobj.find_all('script')])
    
    # 基本の分析対象の取得
    for t,at in zip(tags,attrs):
        print(t,at,file=sys.stderr)
        tmp = [ i.get(at) for i in bsobj.find_all(t) if i.get(at)] 
        chklist.extend(tmp)
        
    # テキストのみ取得
    textdump.append(bsobj.get_text())
    
    # dom構造を出力
    excltags = ['script','noscript','span'] # 出力しない/掘り下げないタグを設定
    dumpdomdict(domdict(bsobj),nmax=5,excltags=excltags)   # nmaxで掘り下げる最大の階層数を指定


# 基本の分析対象を全ファイルまとめてレポート

#print('\n'.join(sorted(set(chklist))))
#print('\n'.join(sorted(set(chklist_jsdump))),file=sys.stderr)

カレントディレクトリの「cr」ディレクトリに拡張子htmlのファイルをいくつか突っ込み、上記プログラムを起動すると、DOMツリーが出力されます。

言い訳など

カタい/安全なロジックを突き詰めていないわりに、思ったよりステップ数を要していますが、後述の補足のようなパラメータで挙動を変えるようにしたためで、よりシンプルなDOMツリーを出力するだけならもっとタイプ数を少なくできると思います。

このほか、複数ファイル(crディレクトリに配置されている全てのhtmlファイルについてループ)を取り扱ったりしているのと、実際はコメントアウトしているので何も出力されないリンク分析用のロジックもなぜか加えているので本題が紛れて紛らわしいかもしれませんのでご容赦ください。

ただし、これらもfind_allなどスクレイピング寄りの視点でよく使われるメソッドのシンプルな事例解説も兼ねているので、一応残しておいたというところです。

補足事項

以下、上記のプログラムの補足です。

DOMをdictにする

上記のプログラムでは、インプットのDOM(BeautifulSoupオブジェクト)を、domdict関数で、タグ名を軸にネストされたdictに変換していますが、下記のようなdictです。

<html>
<body>
<div>
  FOOOOOOOOO
</div>
<p>
  BARRRRRR
  <span>
    BAZZZZZZ
  </span>
</p>
</body>
</html>

どいうhtmlマークアップ(DOM)なら

{
  "document": [
    {
      "html": [
        {
          "body": [
            {
              "div": [
                "FOOOOOOOOO"
              ]
            },
            {
              "p": [
                "BARRRRRR",
                {
                  "span": [
                    "BAZZZZZZ"
                  ]
                }
              ]
            }
          ]
        }
      ]
    }
  ]
}

というdictに変換する。これはある程度ルールが定まったdictであれば再帰処理で解析(今回はprint)しやすいと考えたためです。

見栄えの加工とそのオプションなど

DOMツリーを出力する際にargvなど起動パラメータまでにはしていませんが、プログラム内の定数などで、出力するDOMの深さなどを調整できるようにしています。

これにより、単にDOMツリーを出力するだけ/かつ使い捨てで良いならもっとシンプルにできるところがその分だけ複雑になっています。

具体的に調整できることおよびその他ロジックが少々膨れる要因となっているものとしては次のとおりです。

  1. STRING_CHOP_LENGTH の値を変えると、タグで囲まれたテキストの最初の数文字のみ出力するようにする。0に設定した場合はテキストは出力せず、タグの構造のみ出力する。
  2. idとclassはCSSとのインタフェースになる傾向のある属性なので大まかに把握しておきたい → これらを出力するための編集をしている。
  3. DOMツリーの出力の深さを「dumpdomdictのnmaxパラメータ」で制御できる。この例では5階層までとしている。
  4. excltagsに出力対象外とするタグ名を設定できる。
  5. glob.globであるディレクトリ配下の拡張子htmlの全てについて処理対象にするようにしている。ここではトップディレクトリを「cr」ディレクトリにしているが、「**.html」などとして最下層のディレクトリのファイルまで処理対象にすることも可能。

さいごに

今回はこれでおしまいです。

*1:実際のところこの用途のみであれば、find_allが望ましいですが、ここではスタンスの違いとして例にあげています。

Elasticsearch のFunction score queryで得られたスコアに後付けで細工する

はじめに

何度目かのElasticsearchのオレオレスコアリング論まとめです。

この記事をまとめるきっかけとして、Elasticsearch ver7系におけるScript score queryなるもので、今までできなかった(?)BM25などから得られた関連度に細工ができるようで、これは知識をアップデートせねばと思ったものの、結局6.x系までの話に終始しています

オレオレ主張はともかく、記事の後半で、スコアの「加点」を制御するFunction scoreの中で、painless scriptにより、検索クエリから直接得られるQuery score(≒ Relevance score 関連度)に細工するサンプル例を示していますので、もしそれらのキーワードでこの記事を訪れた方は後半部分をご参照ください。

前提(scoreの制御)

先述のとおり、ver7系で導入された(らしい)Script score queryでは、もっときめ細やかに制御ができるようですが、それはさておき、scoreの制御はおおよそこんな感じです。 (図の丸付き数字のあたりが、横から差し金して最終評価のスコアを調整できる箇所です。)

今回は、図の丸付き数字4のfunction_scoreの部分で、Query scoreとして得られた値を「_score」という変数で取得できるので、これを使ってどのような遊びができるか確認してみます。

f:id:azotar:20200128123710p:plain

関連リンク

脱線しますが、Elasticsearchの公式リファレンスの最新版は、なかなか日本のGoogleではヒットしません。

本題に入る前に、本記事で関連するファンクションについての公式へのリンクをまとめてはっておきます。

www.elastic.co

www.elastic.co

および

この記事では使っていませんが、Script score ↓

Script score query | Elasticsearch Reference [7.5] | Elastic

および ちょっと似た話として、

itdepends.hateblo.jp

Function score queryの中でscore値に細工する

モチベーション・背景

検索クエリで得られる関連度〜Query scoreについては、Elasticsearchのデフォルトでは、BM25が用いられています。

そのBM25(BM25を分かった気になるかもしれない邪道な解説(?) - はてだBlog(仮称))についてはIDFという項がありますが、こいつが対数:logを用いた関数になっているため、小数を含む数字になる傾向があるとともに、Elasticsearchの分散検索の仕様に伴い、検索しにくシャードによって多少値の傾向が異なるということが発生してしまいます。

これ自体はBM25やElasticsearchがもちろんそういうコンセプトなので正しいのですが、目的によってはいくつかやっかいなことがあります。

よく言われるやっかいごと(並び順が安定しないように見える,etc.)についてはこの記事の本題ではないため、キーワードとして、Search after、Scroll API、DFS Query Then Fetch... といったものをあげておくにとどめます。

この記事で問題にする別のやっかいごとですが、シャードごとのちょっとした偏りにともなって、実際は大して本来のスコアに差がない、競合するような検索語・ドキュメントにおいて、スコア差が発生してしまうということです。

そのような、本来はどんぐりの背比べのリストの検索結果の場合は、サービス提供側でオススメかつ次善のソート条件を足しこんで並べてやることで良い感じにしてやることができる...ハズなのですが、実際は上記のとおり、小数で値が(僅差ながらも)ばらけてしまうため、思ったように機能しません。

考え方

前述のような、それ自体は正しいものの、用途や要件によってはちょいと都合がよろしくないスコアリング傾向に対してですが、

「近似的な演算ではなく厳格な条件で計算すると同じ値になるようなもののハズなので、値をまるめてやる(正規化・標準化する)ことで、値をならしてやり、誤差をある種塗りつぶしてやること」

を考えます。

ミソは、ある程度うまい正規化を定めてやれば、本来の特徴に応じて差が出るべきものには正規化してもその差が現れるし、そうでなければそれほどの特徴の差異だったと考えることです。

と、偉そうに言いましたが、ここで言う正規化については過度に複雑にするのもかえって分かりづらいので、「得られたスコアの上一桁を有効数字とする」という方法で試してみます。

f:id:azotar:20200128191510p:plain

実例

インポートデータの例

少々乱暴ですが、kibanaのDevToolsで貼り付けてください。 データ内容はあまり意味がありません。

POST /myidx2/_doc/                                                            
{    "A": "東京都中央区"                         ,"C":    200                 }
POST /myidx2/_doc/                                                            
{    "A": "東京都港区中央町"                           ,"C":    110                 }
POST /myidx2/_doc/                                                            
{    "A": "東京都荒川区西日暮里中央"                           ,"C":    100                 }
POST /myidx2/_doc/                                                            
{    "A": "長崎県中央市"                         ,"C":    100                 }
POST /myidx2/_doc/                                                            
{    "A": "鹿児島中央駅(鹿児島県鹿児島市)"                           ,"C":    1000                    }
POST /myidx2/_doc/                                                            
{    "A": "中央駅(神奈川県横浜市)"                           ,"C":    1100                    }
POST /myidx2/_doc/                                                            
{    "A": "中央スポーツセンター(秋田県秋田市)"                         ,"C":    100 ,"D":    "SPORTS"         }
POST /myidx2/_doc/                                                            
{    "A": "中央周辺エリア"                          ,"C":    101 ,"D":    ["OUTDOORS","ACTIVE"]           }
POST /myidx2/_doc/                                                            
{    "A": "千葉県千葉市XXX区YYY町" ,"B":    ["△△駅","中央駅"]                   ,"C":    100                 }

クエリ例1(得られたQuery score(「_score」)を10の累乗の数で割ったりかけたりする))

GET /myidx2/_search
{
  "explain": true,
  "query": {
    "function_score": {
      "query": {
        "match": {
          "A": {
            "query": "中央",
            "boost": 10000
          }
        }
      },
      "score_mode": "sum",
      "boost_mode":"replace",
      "functions": [
        {
          "script_score": {
            "script": {
              "source": """    
              int keta = String.valueOf((long)_score).length();
              double p = Math.pow(10,keta -1 );
              double q = Math.floor(_score/p) * p;
              return q ;
              """
            }
          }
          }
      ]
    }
  }
}

クエリ例2(文字列操作で対応)

こっちの方が無難かな...

GET /myidx2/_search
{
  "explain": true,
  "query": {
    "function_score": {
      "query": {
        "match": {
          "A": {
            "query": "中央",
            "boost": 10000
          }
        }
      },
      "score_mode": "sum",
      "boost_mode":"replace",
      "functions": [
        {
          "script_score": {
            "script": {
              "source": """
              int keta = String.valueOf((long)_score).length();
              String d = String.valueOf(_score).substring(0,1);
              return  Math.pow(10,keta -1 ) * Integer.parseInt(d);
              """
            }
          }
        }
      ]
    }
  }
}

クエリ例3(例2 に加えて「doc['XXX'].value」を使う)

GET /myidx2/_search
{
  "explain": true,
  "query": {
    "function_score": {
      "query": {
        "match": {
          "A": {
            "query": "中央",
            "boost": 10000
          }
        }
      },
      "score_mode": "sum",
      "boost_mode":"replace",
      "functions": [
        {
          "script_score": {
            "script": {
              "source": """
              int keta = String.valueOf((long)_score).length() ;
              String d = String.valueOf(_score).substring(0,1);
              return  Math.pow(10,keta -1 ) * Integer.parseInt(d) + doc['C'].value;
              """
            }
          }
        },
       {"weight": 250}
      ]
    }
  }
}

戻り値イメージ

前述のクエリ(例2)の戻り値のイメージです。

Query scoreとして得られた10470.967から、「0470.967」部分を除いて 「10000」に丸めた値が最終スコアとなっていますね。 (紛らわしいのですが、検索クエリの「boost」と結果的に同じ値になっています。別物ですのでご注意ください。→ 画像の2枚目が2レコード目ですが、こちらは9400.072が9400に正規化されている。)

f:id:azotar:20200128123907p:plain

f:id:azotar:20200128124159p:plain

まとめ

Function score queryでpainless scriptを使うと、Query scoreの結果の値を元に、後から調整ができます。

また、doc[フィールド名].valueでフィールドの値も取れることと、if文なども記載できるので、必要なら細やかな制御も可能です。

もちろん、Field values score など、Function score queryのより手軽なショートハンドで済むならそれでも良しですが、Function score queryに足を踏み入れるならいっそ、painless scriptを使ったこの記事の方式に慣れておくのも悪くないかと思います。

Elasticsearch script query によるフィールド間の関係による検索絞り込み

この記事の内容

本記事では、Elasticsearchのscript query について、クエリ例を列挙しています。

script queryの使い所(と筆者が思うところ)

Elasticsearchは(全文)検索エンジン寄りのソフトですので、入力語に対して、各ドキュメントがマッチするのかという検索ユースケース中心で物事をみることが多いと思います。

ただ、もちろん、検索のモデルに、特に絞り込みにおいては、「洋室より和室の多いホテル」のようにそのドキュメントのフィールド間の関係が特定の関係を満たすものに絞り込みたいというケースもあります。

このような場合に、script queryが力を発揮します。

f:id:azotar:20200127003739p:plain

なお、script queryは、filterコンテキストのpainless scriptが記載できるみたいなので、その範囲でいろいろできそうです。

script query

www.elastic.co

www.elastic.co

実例

それでは実例です。

以下は、Elasticssearch6.8 での確認です。 上位バージョンでもエッセンスは変わらない範囲に絞ったつもりですが、7系がメジャーになった昨今では、オプションなど変わっている可能性があるのでご注意願います。

また、kibanaのDev Toolsへの貼り付けイメージで記載しています。

◆インデックスの設定

PUT /sqtest

PUT /sqtest/_doc/_mapping
{
    "properties": {
      "C":   { "type": "keyword"  },
      "D":   { "type": "keyword"  }
    }
}

◆サンプルデータ登録

POST sqtest/_doc/
{ "A":100, "B":50, "C":"あああ", "D":"あああ"}

POST sqtest/_doc/
{ "A":100, "B":100, "C":"あああ", "D":"いいい"}

POST sqtest/_doc/
{ "A":100, "B":200, "C":"あああ", "D":""}

POST sqtest/_doc/
{ "A":100, "B":100, "C":"うううえ", "D":"えええ"}

POST sqtest/_doc/
{ "A":900, "B":900, "C":"1234567890", "D":""}

◆クエリ例

注: script queryは、filterコンテキストを基本とするので本来はfilterの中にリーフクエリDSL(この場合はscript)を記載するべきでしょうが、ここでは簡単のため、queryプロパティに直接ぶら下げています。

(1) Aが1より大きいもの

GET sqtest/_search
{
  "query": {
    "script": {
      "script": {
        "source": "doc['A'].value > 1",
        "lang": "painless"
      }
    }
  }
}

この使い方であれば、rangeクエリのシンプル版としても用いることができる。

www.elastic.co

要件によるが、性能やその他の条件ではなく、クエリの見栄えがビジネスルールやドメインをより適切に表しているかを重視して良いのであれば、rangeクエリよりもこちらの方がいい感じになることもある。 もちろん、そうでないこともある。

(2) AがBより大きいもの: ※複数フィールド間の条件

GET sqtest/_search
{
  "query": {
    "script": {
      "script": {
        "source": "doc['A'].value > doc['B'].value",
        "lang": "painless"
      }
    }
  }
}

例) 女性医師が男性医師より多い病院を検索(絞り込み)

【ひとりごと】クエリビルダー機能設計の観点でいうと、フィールド間の関係をElasticsearchのクエリの外に追い出ししやすくなるというメリットもあるかもしれない。

(3) AとBの文字列の内容が同じもの

GET sqtest/_search
{
  "query": {
    "script": {
      "script": {
        "source": "doc['C'].value == doc['D'].value",
        "lang": "painless"
      }
    }
  }
}


(4) Cの文字列の桁数が3より大きいもの

GET sqtest/_search
{
  "query": {
    "script": {
      "script": {
        "source": "doc['C'].value.length() > 3",
        "lang": "painless"
      }
    }
  }
}

(5) painless scriptなので、複合条件も記述できる

Cの文字列の長さが3より大きくかつ Aの値が100より大きい

または

Bは50以下

GET sqtest/_search
{
  "query": {
    "script": {
      "script": {
        "source": """
        ( doc['C'].value.length() > 3 &&  doc['A'].value > 100 ) ||
        ( doc['B'].value <= 50 )
        """,
        "lang": "painless"
      }
    }
  }
}

(6) filterコンテキストなのでpost_filterにもかける

条件はひとつ前のものと同じなので特に目新しいものではないが、post_filterでも記述できるということであれば、ユースケースによっては少しトリックを仕込めるかもしれないという期待はある。

GET sqtest/_search
{
  "query": {
    "match_all": {}
  },
  "post_filter": { 
    "script": {
      "script": {
        "source": """
        ( doc['C'].value.length() > 3 &&  doc['A'].value > 100 ) ||
        ( doc['B'].value <= 50 )
        """,
        "lang": "painless"
      }
    }
  }
}

(7) function_scoreでも使えた!

GET sqtest/_search?filter_path=**._score,**._source
{
  "query": {
    "function_score": {
      "query": {
        "match_all": {}
      },
      "functions": [
        {
          "filter":{
            "script": {
               "script": {
                 "source": "doc['C'].value.length() > 3" ,
                  "lang": "painless"
            }
          }},
          "weight" : 100
        }
      ],
    "score_mode":"sum",
    "boost_mode":"sum"
    }
  }
}

↓ 検索結果

{
  "hits" : {
    "hits" : [
      {
        "_score" : 101.0,
        "_source" : {
          "A" : 900,
          "B" : 900,
          "C" : "1234567890",
          "D" : ""
        }
      },
      {
        "_score" : 101.0,
        "_source" : {
          "A" : 100,
          "B" : 100,
          "C" : "うううえ",
          "D" : "えええ"
        }
      },
      {
        "_score" : 2.0,
        "_source" : {
          "A" : 100,
          "B" : 200,
          "C" : "あああ",
          "D" : ""
        }
      },
      {
        "_score" : 2.0,
        "_source" : {
          "A" : 100,
          "B" : 50,
          "C" : "あああ",
          "D" : "あああ"
        }
      },
      {
        "_score" : 2.0,
        "_source" : {
          "A" : 100,
          "B" : 100,
          "C" : "あああ",
          "D" : "いいい"
        }
      }
    ]
  }
}

続・Elasticsearchのひらがなでの検索時のトリックについて雑談(漢字ひらがな混在の場合について深掘り と  Multiplexer filterの練習もかねて)

はじめに

この記事は、次の記事の続きです。

itdepends.hateblo.jp

前の記事では、ひらがな(読み仮名)→漢字にフォーカスしましたが、ここでは、ひらがなの単語の複合語や漢字とひらがなの混合の複合語などででヒットさせるにはということで膨らませてみます。

... という体裁で、Elasticsearchのアナライザーのfilterである、「Multiplexerというやつを使ってみた」紹介記事にもなっています。

www.elastic.co

[補足] Multiplexerでのfilter処理イメージ

Multiplexerは次のようなことができます。

f:id:azotar:20200126145201p:plain

目次

この記事の「読み仮名」での「検索テーマ」

先の記事では、Elasticsearch((実際はElasticsearchに限らないかもなーという気はしますが。))での、ひらがな→漢字の検索はちょっとしたトリックの案をご紹介したという話でした。

ただ、語っておいていうのもなんですが、実は、先の記事で紹介した方法は、弱点...というのともちょっと違いますが、ひらがなの単語を複数合わせたようなワードになると、ヒトの目だとヒットしても良いかもと思うような例ででヒットしなくなるケースが増える傾向があります。

例えば、先の記事のやり方の場合、検索語が「マサクニ」であれば、「渡邊正邦」がヒットするようにできます。

一方、「ワタナベマサクニ」の場合は、「ワタナベマサクニ」で「ワタナベ」と「マサクニ」を探しに行くために、ヒットしません。

また、「渡邊マサ」のような漢字が混在する検索語のケースも苦手な傾向があります。

ではもっとオーソドックスに検索語を分かち書きをかけるようにした場合どうなるかというと、次の図のような扱いになるため、残念ながらなにやら当たりそうで当たらないということになります。

f:id:azotar:20200126145400p:plain

何か工夫が必要ですね。

泣かぬなら鳴かせてみせよう...の前に

この件は、読み仮名を引き出すには形態素解析による分かち書きが必要、ただし分かち書きを機能させると「ひらがな」中心のテキストは必ずしも都合の良い分かち書きがされるとも限らないというジレンマです。

本題に入る前にもう一度考えるべきことがあります。

ここで、本当にこのような読み仮名から漢字のワードを導き出すことが、サジェストやオートコンプリートといったあるあるも含め必要か、(場合によっては検索ノイズの温床になるリスクを犯してまで)注力する範囲でしょうか。

また、本当に必要という場合は、正式な辞書を用意するという正攻法や、この件を難しくしている「トークン分割」を再結合するような直接的な方法を考えた方が良さそうです。具体的には、自前のfilterを作成・カスタマイズすることで可能です。

以下の方がElasticsearchでのfilterのプラグインを自作して、ひらがな(例はローマ字)で素直に漢字をヒットさせる方法を紹介されてますので、お求めのものがこちらという人のためにリンクさせていただきます。

qiita.com

以降、漢字ひらがな混在、複数のひらがな単語の複合語などでぼちぼちヒットさせる、ただし、できるだけElasticsearchの標準の仕組みのみで対応するとして、百本ノック的にやり方を検討してみます。

本題

さて、この記事の本題に戻ります。

分かち書きされたトークンを結合させることができれば、万事うまくいきそうですが、ぱっと見、独自のフィルターを作成する以外にトークンの結合はできなさそうです。

まあ、トークン化を否定することになりますしね。

よって、結合できないなら、当たる範囲を広げてやるようなアナライズを行うことになるでしょう。

この際、多少の「当たりすぎ」やちょっと違和感のある検索結果になる場合があることはやむなしと考えざるをえません。

幸い、検索エンジンではスコアスコアリングの調整ができるので、このようなギャップは、もっともらしいものを上位にする並び順にするワークアラウンドとしましょう。

とした上で、多少乱れ打ちでヒットさせてやる作戦として、「アナライズ」について、次の図のようなものを考えます。

f:id:azotar:20200126145845p:plain

f:id:azotar:20200126145905p:plain

ポイントとしては、Multiplexerを使って、複合語を意識した元の漢字の単語と読み仮名の単語を複数同時に同じフィールドの転置インデックスの部屋に格納してやり、それのどれかとマッチすればOKという考え方です。

また、普通はやらないであろう1gramのフィルターを用いるとともに、より過激な分かち書きの1gramも活用します。

元のデータを様々に切り刻んでおいて、検索でマッチングさせる候補とする転置インデックスのエントリを幅広く作ってやります。

これにより次のような当たり方をすればマッチさせられるハズという寸法です。

f:id:azotar:20200126150238p:plain

◆実験

それでは実験です。

確認に使った、Elasticsearch のバージョンは6.8です。よって、kuromojiプラグインはこのバージョンと対になるバージョンですので、それの辞書での結果になります。

(1) setting/mapping

先のコンセプトに合わせたsetting/mappingの一例はこちら。

PUT anx2
{
    "settings": {
        "analysis": {
            "tokenizer": {
                "my_kuro_tk": {
                    "type": "kuromoji_tokenizer",
                    "mode": "search"
                },
                "my_eng_tk": {
                    "type": "edge_ngram",
                    "min_gram": 1,
                    "max_gram": 1
                },
                "my_1g_tk": {
                    "type": "ngram",
                    "min_gram": 1,
                    "max_gram": 1
                }
            },
            "analyzer": {
                "my_ja_anlz": {
                    "type": "custom",
                    "tokenizer": "my_kuro_tk"
                },
                "my_noop_anlz": {
                    "type": "custom",
                    "tokenizer": "keyword",
                    "filter":[
                      "hiragana_2_katakana"
                      ]
                },
                "my_rf_anlz": {
                    "type": "custom",
                    "tokenizer": "my_kuro_tk",
                    "filter":[
                      "kuromoji_readingform",
                      "hiragana_2_katakana"
                      ]
                },

                "my_1g_anlz": {
                    "type": "custom",
                    "tokenizer": "my_1g_tk"
                },
                "my_1gkana_anlz": {
                    "type": "custom",
                    "tokenizer": "my_1g_tk",
                    "filter": [
                        "hiragana_2_katakana"
                    ]
                },
                "my_rf_x_1g_anlz": {
                    "type": "custom",
                    "tokenizer": "my_kuro_tk",
                    "filter": [
                        "kuromoji_readingform",
                        "hiragana_2_katakana",
                        "1gram_filter"
                    ]
                },
                "mp1_anlz": {
                    "type": "custom",
                    "tokenizer": "my_kuro_tk",
                    "filter": [
                        "mp1"
                    ]
                },
                "mp2_anlz": {
                    "type": "custom",
                    "tokenizer": "my_kuro_tk",
                    "filter": [
                        "mp2"
                    ]
                }
            },
            "filter": {
                "hiragana_2_katakana": {
                    "type": "icu_transform",
                    "id": "Hiragana-Katakana"
                },
                "katakana_2_hiragana": {
                    "type": "icu_transform",
                    "id": "Katakana-Hiragana"
                },
                "e_ngram_filter": {
                    "type": "edge_ngram",
                    "min_gram": 1,
                    "max_gram": 10
                },
                "1gram_filter": {
                    "type": "ngram",
                    "min_gram": 1,
                    "max_gram": 1
                },
                "mp1":{
                  "type": "multiplexer",
                  "filters": ["e_ngram_filter", "kuromoji_readingform, e_ngram_filter", "kuromoji_readingform,katakana_2_hiragana,e_ngram_filter" ]
                 },
                 "mp2":{
                  "type": "multiplexer",
                  "filters": [ "1gram_filter","kuromoji_readingform, 1gram_filter",  "kuromoji_readingform, katakana_2_hiragana, 1gram_filter"  ],
                    "preserve_original":false
                 }
                
            }
        }
    },
    "mappings": {
        "_doc": {
            "properties": {
                "location": {
                    "type": "geo_point"
                }
            },
            "dynamic_templates": [
                {
                    "my_hybrid_style_for_string": {
                        "match_mapping_type": "string",
                        "mapping": {
                            "analyzer": "my_ja_anlz",
                            "fielddata": true,
                            "store": true,
                            "fields": {
                                "mp1": {
                                    "type": "text",
                                    "analyzer": "mp1_anlz"
                                },
                                "mp2": {
                                    "type": "text",
                                    "analyzer": "mp2_anlz"
                                }
                            }
                        }
                    }
                }
            ]
        }
    }
}

(2) 試験用データ

次のデータを流し込みます。

POST anx2/_doc/
{  "A":"渡邊"}

POST anx2/_doc/
{  "A":"渡邊ひろまさ"}

POST anx2/_doc/
{  "A":"渡邊まさくに"}

POST anx2/_doc/
{  "A":"渡邊くにひろ"}

POST anx2/_doc/
{  "A":"渡邊ひろし"}

POST anx2/_doc/
{  "A":"渡邊まさし"}

POST anx2/_doc/
{  "A":"渡邊広正"}

POST anx2/_doc/
{  "A":"渡邊正邦"}

POST anx2/_doc/
{  "A":"渡邊国広"}

POST anx2/_doc/
{  "A":"渡邊博史"}

POST anx2/_doc/
{  "A":"渡邊雅志"}

POST anx2/_doc/
{  "A":"渡哲夫"}


POST anx2/_doc/
{  "A":"世露死苦"}

POST anx2/_doc/
{  "A":"阿多羅奈伊代"}

検索クエリ

POST anx2/_search
{
  "query": {
    "dis_max": {
      "queries": [
        {
          "constant_score": {
            "filter": {
              "match": {
                "A.mp1": {
                  "query": "{{qs}}",
                  "operator": "and",
                  "analyzer": "my_ja_anlz"
                }
              }
            },
            "boost": 4
          }
        },
        {
          "constant_score": {
            "filter": {
              "match": {
                "A.mp1": {
                  "query": "{{qs}}",
                  "operator": "and",
                  "analyzer": "my_noop_anlz"
                }
              }
            },
            "boost": 3
          }
        },
        {
          "constant_score": {
            "filter": {
              "match": {
                "A.mp1": {
                  "query": "{{qs}}",
                  "operator": "and",
                  "analyzer": "my_rf_anlz"
                }
              }
            },
            "boost": 2
          }
        },
        {
          "constant_score": {
            "filter": {
              "match": {
                "A.mp2": {
                  "query": "{{qs}}",
                  "operator": "and",
                  "analyzer": "my_1g_anlz"
                }
              }
            },
            "boost": 1
          }
        },
        {
          "constant_score": {
            "filter": {
              "match": {
                "A.mp2": {
                  "query": "{{qs}}",
                  "operator": "and",
                  "analyzer": "my_rf_x_1g_anlz"
                }
              }
            },
            "boost": 0
          }
        }
      ]
    }
  }
}


(3) 検索結果

{
  "hits" : {
    "total" : 2,
    "max_score" : 4.0,
    "hits" : [
      {
        "_score" : 4.0,
        "_source" : {
          "A" : "渡邊まさくに"
        }
      },
      {
        "_score" : 1.0,
        "_source" : {
          "A" : "渡邊正邦"
        }
      }
    ]
  }
}

「ワタナベマサクニ」で「渡邊正邦」が当たるようになりました。

また、この場合は、「渡邊正邦」より「ワタナベマサクニ」に近いという考え方にした「渡邊まさくに」(別レコード)が上位にヒットしていますね。

試験用の全体の集合のデータに偏りもありますし、他の検索語との比較での当たり具合/あたってほしくなさ具合は紙面の都合で割愛していますが、「当たって欲しいな...」という例にあげたようなものはそれなりにあたるようになります。

あらためのて注意事項

紹介した例では、なんとか、「ワタナベクニマサ」でヒットできるようになりました。

ただし、光あるところには影があというとことで、トレードオフがあります。

方式からすると最初から折り込み済みではありますが、端的なものとしては、「またくさたにくわたべな」のような「渡邊邦正」のアナグラム風のワードであれば確実にヒットしてしまいます。

これは次の図のように「潜在的なOR(この記事での便宜上の造語)」が作用するためです。

f:id:azotar:20200126150136p:plain

アナグラム風の例だけでいえば、実際に使われる検索語の傾向からいうとそれほど問題にならないかもしれません。

ところが、今回の検索側アナライズの「my_rf_x_1g_anlz」では、禁断の検索時のfilterに1gramを用いるという禁断のテクニックを使っています。

この場合、検索側のアナライザーのfilterでの1gramの方の「潜在的OR(造語)」とインデックス側の「潜在的OR(造語)」の掛け合わせにより、ああこれも当たっちゃうのねという組み合わせがより発生しやすくなります。

例えば、次のようなケースでも、「渡邊正邦」がヒットしてしまいます。

  1. わんたん (分かち書きで、「わん/たん」 となり、分かち書きトークンごとの代表がわりに「わ」と「た」を含むためヒット)
  2. ワンダフル (分かち書きは発生せず「ワンダフル」となり、その後filterの1-Gramにより、「ワ」「ン」「ダ」「フ」「ル」のどれかの1文字だけでも含まれていればヒットする。)

<<潜在的OR(造語)のイメージ>>

f:id:azotar:20200126151610p:plain

これが示唆するのは、「my_rf_x_1g_anlz」と「mp2」の組み合わせでの検索を行うことにより、他の組み合わせ以上に、含まれていない文字を多く含むような検索語でヒットしてしまうケースが増えそうだということです。

また、今回は実際にそのようなうまい(?)例を見つけられていませんが、辞書により漢字から読み仮名の読み替えのされ方によっては、次のように声に出してみても1文字もかさなるものが無いような、初見では全く因果関係に納得しづらい、検索結果が含まれてしまう可能性もありそうです。

検索側の分かち書き〜期待されるものとは別の読み仮名があてがわれてしまった。例えば、長辺→ナガ/ナベ とアナライズされた*1

加えて、1gramフィルターで、「ナ・ガ/ナ・ベ」扱いとなった。

「ナ or ガ」および「ナ or べ」がどこかにあれば良いので「渡邊正邦」のインデックス側の1文字刻み「ワ・タ・ナ・ベ/マ・サ・ク・ニ」でもマッチする。

流れを追えば仕組みのとおりで不思議ではありませんが、「長辺」で「渡邊正邦」がヒットしたという大外だけみると何でそうなったかわからないということがおきます。

実際にはこのようなケースがあるので、特に検索時に1Gramフィルターを入れるような検索ニーズの場合をマジメに考慮する場合は、スコアリングによる門前払いや二段階選抜も必要かもしれません。

私の経験上、通常のユースケースでは、スコアリングによる二段階選抜(スコアの低いものを間引く)はしないことをお勧めしています*2

しかし、本件のような例に限っては、AND検索を主軸としつつ、途中からOR検索相当の検索結果も含まれるような検索モデルになりますので、Elasticsearchの検索後えられた結果のうち、スコアの高いものが一定数ヒットしている状況においては、取り決めたスコア閾値より低い検索結果はクライアントにああ戻さないといった、アプリ側での対応も視野に入れた方が良さそうです。

まとめ

Multiplexerを使って、「読み仮名」を意識した、複合語→複合語の検索方法の例をゆるゆる考察してみました。

本記事の例がMultiplexerの開発時にイメージされたの本来の用途かは、勉強不足のためわかりません。

しかし、日本語のような単語の切れ目がスペースでなく、読みが発音としてだけでなく通常の文章内に存在するような言語では本記事の用法が使えそうです。

本記事の他にも、複数の表記揺れを同時に扱うなどの場合に、Multiplexerは使えるかもしれません。

付録

ベンチマークにあげた検索語のアナライズ結果


POST anx2/_analyze?filter_path=*.token
{
    "analyzer":"my_ja_anlz",
    "text":"渡邊正邦"
}

POST anx2/_analyze?filter_path=*.token
{
    "analyzer":"my_ja_anlz",
    "text":"わたなべ"
}


# POST anx2/_analyze?filter_path=*.token
{
  "tokens" : [
    {
      "token" : "渡邊"
    },
    {
      "token" : "正邦"
    }
  ]
}


# POST anx2/_analyze?filter_path=*.token
{
  "tokens" : [
    {
      "token" : "わ"
    },
    {
      "token" : "た"
    },
    {
      "token" : "なべ"
    }
  ]
}



POST anx2/_analyze?filter_path=*.token
{
    "analyzer":"my_noop_anlz",
    "text":"渡邊マサ"
}






# POST anx2/_analyze?filter_path=*.token
{
  "tokens" : [
    {
      "token" : "渡邊マサ"
    }
  ]
}




POST anx2/_analyze?filter_path=*.token
{
    "analyzer":"my_rf_anlz",
    "text":"渡辺まさ"
}

POST anx2/_analyze?filter_path=*.token
{
    "analyzer":"my_rf_anlz",
    "text":"渡辺まさくに"
}



# POST anx2/_analyze?filter_path=*.token
{
  "tokens" : [
    {
      "token" : "ワタナベ"
    },
    {
      "token" : "マサ"
    }
  ]
}

# POST anx2/_analyze?filter_path=*.token
{
  "tokens" : [
    {
      "token" : "ワタナベ"
    },
    {
      "token" : "マサ"
    },
    {
      "token" : "クニ"
    }
  ]
}




POST anx2/_analyze?filter_path=*.token
{
    "analyzer":"my_1g_anlz",
    "text":"ワタナベマサクニ"
}

POST anx2/_analyze?filter_path=*.token
{
    "analyzer":"my_1g_anlz",
    "text":"わたなべマサクニ"
}

POST anx2/_analyze?filter_path=*.token
{
    "analyzer":"my_1g_anlz",
    "text":"渡なべまさくに"
}




# POST anx2/_analyze?filter_path=*.token
{
  "tokens" : [
    {
      "token" : "ワ"
    },
    {
      "token" : "タ"
    },
    {
      "token" : "ナ"
    },
    {
      "token" : "ベ"
    },
    {
      "token" : "マ"
    },
    {
      "token" : "サ"
    },
    {
      "token" : "ク"
    },
    {
      "token" : "ニ"
    }
  ]
}


# POST anx2/_analyze?filter_path=*.token
{
  "tokens" : [
    {
      "token" : "わ"
    },
    {
      "token" : "た"
    },
    {
      "token" : "な"
    },
    {
      "token" : "べ"
    },
    {
      "token" : "マ"
    },
    {
      "token" : "サ"
    },
    {
      "token" : "ク"
    },
    {
      "token" : "ニ"
    }
  ]
}


# POST anx2/_analyze?filter_path=*.token
{
  "tokens" : [
    {
      "token" : "渡"
    },
    {
      "token" : "な"
    },
    {
      "token" : "べ"
    },
    {
      "token" : "ま"
    },
    {
      "token" : "さ"
    },
    {
      "token" : "く"
    },
    {
      "token" : "に"
    }
  ]
}



POST anx2/_analyze?filter_path=*.token
{
    "analyzer":"my_rf_x_1g_anlz",
    "text":"渡なべ魔サくに"
}


# POST anx2/_analyze?filter_path=*.token
{
  "tokens" : [
    {
      "token" : "ワ"
    },
    {
      "token" : "タ"
    },
    {
      "token" : "リ"
    },
    {
      "token" : "ナ"
    },
    {
      "token" : "ベ"
    },
    {
      "token" : "マ"
    },
    {
      "token" : "サ"
    },
    {
      "token" : "ク"
    },
    {
      "token" : "ニ"
    }
  ]
}

*1:実際は「長辺」は「チョウヘン」になる傾向が高く、このようにはなりませんが、ここでは説明の例としてそのように分割されたとします。→ 実際は「チョウ/アタリ」でしたが、「ナガ/ナベ」にはひとまずなりませんが、いずれにせよひとまずこういう可能性があるということで。

*2:間引くのになぜそもそも検索させるのかという意味で...