PowerCMSを使用した案件で「PowerCMS 5によるレコメンド機能 | PowerCMSブログ」を使う機会があり、Hyper Estraierを使用した検索や関連記事の表示に興味を持ちました。
PowerCMSブログで紹介されている方法では、タグ欄やカスタムフィールドの情報をHyper Estraierの文書ドラフトに書き出し(metadata属性を定義)、その情報を元に属性検索を実行して抽出を行います。この方法だと記事編集者が記事を編集する都度タグを設定するので、関連記事として抽出される内容をかなり制御できるのではないかと思われます。
ただ、タグを設定するのもなかなか大変ですし、文書を分析して自動的に関連記事が抽出できないか?ということを考えました。例えば「Pythonで関連記事を抽出する - hello_world.py」に紹介されているようなベクトルを使用する方法です。(この記事は理解しやすかったです。)そこでHyper Estraierのドキュメントを読み込んだのですが、ある文書に似た文書を探す、類似検索式もサポートされます。
との記述があり、類似記事(関連記事)が抽出できることが分かりました。仕組みは以下のように書かれていました。
類似度はベクトル空間モデルという考え方に基づいて算出されます。文書からキーワードを取り出してベクトルとして表現し、ベクトル同士のなす角の余弦を類似度とするものです。ちょっと難しい言い方になりましたが、要するに、語彙が似通った文書は類似度が高くなるということです。キーワードとしては、文書内の頻度にTF-IDF法で重みづけを行った結果が上位の語句が選択されます。
そこで、このブログを使用してHyper Estraierの類似検索を実験してみました。
Hyper Estraierの準備
Hyper Estraier(GNU Lesser General Public Licenseに基づいて配布されるフリーソフトウェア)をさくらのVPSにインストールします。過程は本記事では省略しますが、Hyper Estraierをビルドする前の設定で./configure --enable-mecab
とし、Hyper EstraierにMeCabを組み込みます。
インデックスの作成
Hyper Estraierは対象文書の情報を登録したインデックスを準備する必要があります。このブログはCraft CMSで運用しているため、全記事の文書ドラフトを1つのファイルに書き出した後にVPS上で加工を行い記事別の文書ドラフトファイルに分割する処理を行いました。ドラフトファイルを生成する際に本文を加工するTwigフィルタプラグイン「EstDraftUtil」も作成しました。
curl -o estdraft https://www.anothersky.pw/path/to/draft_collection
csplit -z -f entry -n 4 [filename] /^entryid=.*/ {*}
for f in entry*; do mv "$f" "$f.est"; done
これをインデックスとして登録します。「キーワード抽出」の項に記述がありますが、estcmd gather
で文書を登録しただけではキーワードの抽出は行われないそうなので、estcmd extkeys
を実行してキーワードを抽出し、補助インデックスとキーワードデータベースに登録します。estcmd extkeys
を実行する際は-um
オプションを指定し、日本語用の形態素解析器「MeCab」を分かち書きに利用するようにします。
estcmd gather -il ja -cl -xs -sd -um casket ./draft
estcmd extkeys -fc -um casket
以上でインデックスの作成は完了です。
検索方法
まず、表示している記事(種文書)がどのようなIDでインデックスに登録されているかを調査するために以下のコマンドを実行します。
estcmd uriid casket https://www.anothersky.pw/path/to/entry
そして、上記で取得したIDを基に類似検索を実行します。estcmd search
を実行する際に-sim
オプションで種文書のIDを指定します。-max
で表示件数が指定できますが、種文書も検索結果に含まれるため+1した値を指定するようにします。
search -vx -max 6 -sim [doc_id] casket
これらのコマンドをPHPから実行し、検索結果として返ってきたXMLをパースしてJavaScriptで記事ページに挿入します。PHPはPowerCMSのテーマに含まれるコードを夜中にRecommendEntriesAPIクラスとしてリファクタリングしたものを使用しています。かなり変更したとは言えど公開するのはやめておきます…。
JavaScriptはHTMLのソースを開けば見えますので…、以下のような感じです。とりあえず表示させたかったのでjQueryに甘えましたが、いつか書き直そうと思います。
(function ($) {
const currentEntryId = {{ entry.id }};
const jqxhr = $.ajax('/related-entries.php?type=entry&limit=3&uri={{ entry.url | url_encode }}');
jqxhr.done(function (json) {
if (json.length > 0) {
let listHTML = '';
for (let key in json) {
const entryId = parseInt(json[key].entryid);
const uri = json[key].uri;
const title = json[key].title;
if (currentEntryId !== entryId) {
const html = '<li><a href="' + uri + '">' + title + '</a></li>';
listHTML += html;
}
}
const $list = $('<ul />');
$list.append(listHTML);
$('#related_entries').append($list).show();
}
}
}(jQuery));
20:17追記…jQueryを使わなくても、Fetch APIですぐ書けましたね。
(function () {
const currentEntryId = {{ entry.id }};
fetch('/related-entries.php?type=entry&limit=3&uri={{ entry.url | url_encode }}')
.then(function (response) {
return response.json()
})
.then(function (json) {
if (json.length > 0) {
const relatedEntriesElem = document.getElementById('related_entries');
let listHTML = '<ul>';
for (let key in json) {
const entryId = parseInt(json[key].entryid);
const uri = json[key].uri;
const title = json[key].title;
if (currentEntryId !== entryId) {
const html = '<li><a href="' + uri + '">' + title + '</a></li>';
listHTML += html;
}
}
listHTML += '</ul>';
relatedEntriesElem.insertAdjacentHTML('beforeend', listHTML);
relatedEntriesElem.style.display = 'block';
}
});
}());
実装結果
このブログ内のいくつかの記事を見ましたが、いまいちの抽出具合の時もありますし、かなり良い抽出具合の時もあります。しばらくこのまま設置して様子を見たいと思います。