トピックキーワードの生成
まず、はてなブックマークでのインデックス構造について説明します。以下は、はてなブックマークの記事データに対するElasticsearchのインデックスの一部を取り出したものですが、記事のタイトルや本文、時間情報などがインデックスされています。
{ "mapping": { "entry": { "_id": { "path": "id" }, "properties": { "id": { "type": "integer" }, "url": { "type": "string" }, "title": { "type": "string" }, "content": { "type": "string" }, "created": { "type": "date", "format": "date_time_no_millis" } } } } }
トピックキーワード生成ではSignificant Terms Aggregationを2層にして利用します。タイトルと本文のそれぞれに対して適用しますが、本稿では、記事のタイトルに対してSignificant Terms Aggregationを適用する例を使って説明します。以下の例では2015年1月13日から2015年1月20日までの1週間に話題となったトピックキーワードを生成しています。
{ "query": { "filtered": { "filter": { "bool": { "must": [ { "range": { "created": { "to": "2015-01-20T00:00:00+09:00", "from": "2015-01-13T00:00:00+09:00" } } } ] } } } }, "aggs": { "terms": { "significant_terms": { "field": "title", "size": 5, "jlh": {} }, "aggs": { "terms": { "significant_terms": { "field": "title", "size": 5, "jlh": {} } } } } } }
{ "aggregations": { "terms": { "doc_count": 13757, "buckets": [ { "key": "シャルリ", "doc_count": 55, "score": 2.50210212027157, "bg_count": 400, "terms": { "doc_count": 55, "buckets": [ { "key": "シャルリ", "doc_count": 55, "score": 129926.04000000001, "bg_count": 400 }, { "key": "エブド", "doc_count": 39, "score": 99737.61800012618, "bg_count": 262 }, { "key": "赦す", "doc_count": 3, "score": 5522.228146399055, "bg_count": 28 }, { "key": "ムハンマド", "doc_count": 7, "score": 4729.318328535611, "bg_count": 178 }, { "key": "風刺", "doc_count": 16, "score": 4172.5661787444915, "bg_count": 1054 } ] } }, { "key": "エブド", "doc_count": 39, "score": 2.277874539496356, "bg_count": 221, "terms": { "doc_count": 39, "buckets": [ { "key": "エブド", "doc_count": 39, "score": 183722.4660633484, "bg_count": 221 }, { "key": "シャルリ", "doc_count": 38, "score": 115756.92828528726, "bg_count": 333 }, { "key": "赦す", "doc_count": 3, "score": 8580.414623837702, "bg_count": 28 }, { "key": "ムハンマド", "doc_count": 4, "score": 4314.2186626289185, "bg_count": 99 }, { "key": "風刺", "doc_count": 10, "score": 3132.9434242977027, "bg_count": 852 } ] } }, { "key": "スイスフラン", "doc_count": 57, "score": 1.8023504538916841, "bg_count": 596, "terms": { "doc_count": 57, "buckets": [ { "key": "スイスフラン", "doc_count": 57, "score": 89385.22483221476, "bg_count": 596 }, { "key": "急騰", "doc_count": 15, "score": 1992.9018004791897, "bg_count": 1851 }, { "key": "上限", "doc_count": 8, "score": 1391.6562517093605, "bg_count": 754 }, { "key": "損失", "doc_count": 8, "score": 1118.6385812659432, "bg_count": 938 }, { "key": "暴騰", "doc_count": 5, "score": 766.1320808990831, "bg_count": 535 } ] } }, { "key": "ニュース", "doc_count": 1966, "score": 1.6056558085044332, "bg_count": 732518, "terms": { "doc_count": 1966, "buckets": [ { "key": "ニュース", "doc_count": 1966, "score": 84.61743192658747, "bg_count": 732518 }, { "key": "yahoo", "doc_count": 583, "score": 26.437202086858722, "bg_count": 206296 }, { "key": "風刺", "doc_count": 44, "score": 24.771369040835854, "bg_count": 1267 }, { "key": "ようじ", "doc_count": 28, "score": 20.977907336532066, "bg_count": 606 }, { "key": "nhk", "doc_count": 271, "score": 20.15849908084594, "bg_count": 58713 } ] } }, { "key": "ようじ", "doc_count": 48, "score": 1.511414342391317, "bg_count": 504, "terms": { "doc_count": 48, "buckets": [ { "key": "ようじ", "doc_count": 48, "score": 80424.74801587302, "bg_count": 504 }, { "key": "つま", "doc_count": 34, "score": 26207.617735627864, "bg_count": 776 }, { "key": "混入", "doc_count": 22, "score": 4192.095123673341, "bg_count": 2031 }, { "key": "スナック菓子", "doc_count": 4, "score": 2759.6237064270153, "bg_count": 102 }, { "key": "手配", "doc_count": 8, "score": 2128.3030350766644, "bg_count": 529 } ] } } ] } } }
以下の5つのトピックキーワード集合が生成されています。
- シャルリ、エブド、赦す、ムハンマド、風刺
- エブド、シャルリ、赦す、ムハンマド、風刺
- スイスフラン、急騰、上限、損失、暴騰
- ニュース、yahoo、風刺、ようじ、nhk
- ようじ、つま、混入、スナック菓子、手配
ただし、ここには以下の2つの問題があります。
重複トピックキーワード集合
1つ目のトピックキーワードと2つめのトピックキーワードは同じ話題を表しています。この問題については、トピックキーワードの生成時には対処せずに、トピック生成処理の最後のフェーズでトピックをマージすることで解決します。
トピックキーワードの誤生成
ニュースやyahoo、nhkなど、トピックを表さないキーワードが生成されています。これは記事タイトルにサイト名が含まれていることが要因で、記事タイトルによく出現する単語はスコアが高くなるため、誤ってトピックキーワードとして生成されています。これを解決するために、ニュースなどのよく出現する単語をストップワードとして用意します。ただし、人手で用意するのは管理等も含めコストが高いため、期間を指定せずにSignificant Terms Aggreagationを利用することで自動生成します。
記事タイトルによく出現する単語はトピックキーワードではないという仮定のもと、これらをストップワードとして利用します。
ストップワード、および、一定のスコア以下のキーワードを除外し、残ったものをトピックキーワードとして取得します。
トピックキーワードに関連する記事の取得
生成したトピックキーワードに関連する記事を取得します。ただし、単にキーワードのいずれかを含む記事を取得したのでは、関係ない記事もトピックに含まれてしまいます。そこで、Significant Terms Aggregationで取得したトピックキーワードのスコアを活用します。記事スコアとして、記事に含まれるトピックキーワードのスコアの割合を算出し、スコアが一定以上の記事をトピックの記事とみなして取得します。これはスコアが高いキーワードが含まれていれば、トピックに関する記事である可能性が高いといえるからです。
記事スコア = 記事に含まれるトピックキーワードのスコアの合計 / トピックキーワードのスコアの合計
この条件を満たす記事の取得方法について説明します。Elasticsearchに対して単純にトピックキーワードを使って検索すると、TF-IDFやBM-25といった手法でスコアリングされた結果が返ってくるため、トピックキーワードのスコアを考慮できません。
そこで、ElasticsearchのFunction Score Queryを利用します。
Function Score Queryを用いることで、トピックキーワードのスコアを利用した独自のスコアリングが可能となり、一定のスコア以上の記事を取得できます。以下は、トピックキーワード集合「シャルリ、エブド、赦す、ムハンマド、風刺」に関連する記事を取得する例です。script_score
のparams
にキーワードとそのスコアを指定し、script
でキーワードのスコアの和を記事のスコアとなるようにしています。そして、min_score
に記事スコアの閾値(この例ではキーワードスコアの和の8割となる195270)を指定することで、トピックキーワードに関連する記事のみを取得します。
{ "query": { "function_score": { "score_mode": "sum", "boost_mode": "replace", "functions": [ { "script_score": { "params": { "field": "title", "words": ["シャルリ","エブド","赦す","ムハンマド","風刺"], "scores": [129926.04,99737.62,5522.23,4729.32,4172.57] }, "lang": "groovy", "script": "def score=0;for(i in 0..4){ if (_index[field][words[i]].tf() > 0){ score += scores[i]} }; score;" } } ], "query": { "bool": { "should": [ { "match": { "title": "シャルリ" } }, { "match": { "title": "エブド" } }, { "match": { "title": "赦す" } }, { "match": { "title": "ムハンマド" } }, { "match": { "title": "風刺" } } ], "minimum_should_match": 1 } } } }, "min_score": 195270, }
トピックのマージ
ここまでに説明した手法によって話題のトピックが作成されます。ただし、同じ内容のトピックが複数作成されるという問題がまだ解決できていません。そのため、重複トピックを判定して、トピックをマージします。
重複トピック判定にはトピックキーワードのスコア重複率、および、トピックの記事の重複率を利用します。いずれかの重複率が一定以上の場合に同じトピックであると判定し、トピックをマージします。トピックのマージはキーワード、記事ともに、単純に和集合をとります。