Solr 8.8で入れ子構造の文書をインデックスする

はじめに

以前書いた「Solrで入れ子構造の文書をインデックスする」という記事で対象としていたのはSolr 7.5でしたが、現時点での最新版のSolr 8.8ではいろいろと変わった部分があるので改めて採り上げてみます。

Nested Documents 機能について Solr 7.5 から 8.8 で変わった点

Solr 7.5 の頃と変わったのは以下の点です。

  • 3層以上の階層を持てるようになった
  • どの階層のドキュメントかを示すフィールドを明示的に定義しなくても良くなった。階層構造を保持する _nest_path_ フィールドを Solr が自動的に管理するようになったため
  • 入れ子構造内の特定のドキュメントだけを部分的に更新できるようになった

スキーマ設定

Nested Documents を扱うためのスキーマ設定は以下の通りです。

_root_ フィールド (必須)

<field name="_root_" type="string" indexed="true" stored="false" docValues="false" />

インデックス作成時にSolrが自動的に作成するフィールドです。入れ子になったドキュメントはルートドキュメント(一番の先祖にあたるドキュメント)のidフィールドの値を _root_ フィールドの値として持ちます。

_nest_path_ フィールド (オプション)

<fieldType name="_nest_path_" class="solr.NestPathField" />
<field name="_nest_path_" type="_nest_path_" />

入れ子構造のルートドキュメント以外のドキュメントにSolrが自動的に付与するフィールドです。このフィールドは検索結果を取得するときに ChildDocTransformer で子ドキュメント以下の階層構造を扱うのに使われます。もしこのフィールドが無い場合はすべての子孫がフラットなリストとして扱われます。

_nest_path_ フィールドを使わない場合は、それぞれの階層を区別するための自前のフィールドを定義することが推奨されます。(手間が掛かるだけなので、素直に _nest_path_ フィールドを使った方が良さそうです)

_nest_parent_ フィールド (オプション)

<field name="_nest_parent_" type="string" indexed="true" stored="true" />

ルートドキュメント以外のドキュメントにSolrが自動的に付与するフィールドです。親ドキュメントのidフィールドの値を持ちます。

制限

  • スキーマ内で各フィールド名の定義は1回だけ。親と子で同じフィールド名を別のフィールドタイプで定義するようなことはできない
  • すべてのドキュメントタイプで共通するフィールドにだけ require を使うことができる
  • 階層を問わず、すべてのドキュメントは id フィールドにユニークな値を持たなければならない

例: プレイリスト

以下のようなプレイリスト情報を Solr に与えます。
(_default の configset でコレクションを作っていればスキーマレスでインデックス作成できます)

[{
    "id":"list_1",
    "title_t":"list1",
    "songs":[
	{
	    "id":"l1!song1",
	    "title_t":"title1",
	    "artist_t":"artist1",
	    "trackNum_i":1
	},
	{
	    "id":"l1!song2",
	    "title_t":"title2",
	    "artist_t":"artist2",
	    "trackNum_i":2
	}
    ]
},
{
    "id":"list_2",
    "title_t":"list2",
    "songs":[
	{
	    "id":"l2!song1",
	    "title_t":"title3",
	    "artist_t":"artist3",
	    "trackNum_i":1
	},
	{
	    "id":"l2!song2",
	    "title_t":"title1",
	    "artist_t":"artist1",
	    "trackNum_i":2
	}
    ]
},
{
    "id":"list_3",
    "title_t":"list3",
    "sublist":[
        {
            "id":"l3!sublist1",
            "title_t":"sublist1",
            "songs":[
	            {
	                "id":"l3!song1",
	                "title_t":"title4",
	                "artist_t":"artist4",
	                "trackNum_i":1
	            },
	            {
	                "id":"l3!song2",
	                "title_t":"title2",
	                "artist_t":"artist2",
	                "trackNum_i":2
	            }
            ]
        },
        {
            "id":"l3!sublist2",
            "title_t":"sublist2",
            "songs":[
	            {
	                "id":"l3!song3",
	                "title_t":"title1",
	                "artist_t":"artist1",
	                "trackNum_i":1
	            },
	            {
	                "id":"l3!song4",
	                "title_t":"title3",
	                "artist_t":"artist3",
	                "trackNum_i":2
	            }
            ]
        }
    ]
}]

プレイリスト名が list1 のプレイリストを曲も含めて検索

ヒットしたプレイリストの子ドキュメントを取得するために ChildDocTransformer を利用します。

$ curl 'http://localhost:8983/solr/nestedDocuments/select' -d 'omitHeader=true' --data-urlencode 'q={!parent which="title_t:list1 -_nest_path_:*"}' -d 'fl=*,[child]'
{
  "response":{"numFound":1,"start":0,"numFoundExact":true,"docs":[
      {
        "id":"list_1",
        "title_t":"list1",
        "_version_":1697935145902800896,
        "songs":[
          {
            "id":"l1!song1",
            "title_t":"title1",
            "artist_t":"artist1",
            "trackNum_i":1,
            "_version_":1697935145902800896},
          
          {
            "id":"l1!song2",
            "title_t":"title2",
            "artist_t":"artist2",
            "trackNum_i":2,
            "_version_":1697935145902800896}]}]
  }}

曲名が title1 の曲が含まれるすべてのプレイリストを検索

_nest_path_ フィールドでルート直下の songs の階層を指定しています。 {!parent} 内での -_nest_path_:* は「_nest_path_フィールドを持つもの以外」つまりルートドキュメントだけを対象とするという意味です。

$ curl -s 'http://localhost:8983/solr/nestedDocuments/select' -d 'omitHeader=true' --data-urlencode 'q={!parent which="*:* -_nest_path_:*"}(+_nest_path_:\/songs +title_t:title1)' -d 'fl=*,[child]'
{
  "response":{"numFound":2,"start":0,"numFoundExact":true,"docs":[
      {
        "id":"list_1",
        "title_t":"list1",
        "_version_":1697935145902800896,
        "songs":[
          {
            "id":"l1!song1",
            "title_t":"title1",
            "artist_t":"artist1",
            "trackNum_i":1,
            "_version_":1697935145902800896},
          
          {
            "id":"l1!song2",
            "title_t":"title2",
            "artist_t":"artist2",
            "trackNum_i":2,
            "_version_":1697935145902800896}]},
      {
        "id":"list_2",
        "title_t":"list2",
        "_version_":1697935145926918144,
        "songs":[
          {
            "id":"l2!song1",
            "title_t":"title3",
            "artist_t":"artist3",
            "trackNum_i":1,
            "_version_":1697935145926918144},
          
          {
            "id":"l2!song2",
            "title_t":"title1",
            "artist_t":"artist1",
            "trackNum_i":2,
            "_version_":1697935145926918144}]}]
  }}

サブリストに曲名が title1 の曲を含まれるプレイリストを検索

_nest_path_ フィールドで sublist 階層の下の songs の階層を指定しています。

$ curl -s 'http://localhost:8983/solr/nestedDocuments/select' -d 'omitHeader=true' --data-urlencode 'q={!parent which="*:* -_nest_path_:*"}(+_nest_path_:\/sublist\/songs +title_t:title1)' -d 'fl=*,[child]'
{
  "response":{"numFound":1,"start":0,"numFoundExact":true,"docs":[
      {
        "id":"list_3",
        "title_t":"list3",
        "_version_":1697935153103372288,
        "sublist":[
          {
            "id":"l3!sublist1",
            "title_t":"sublist1",
            "_version_":1697935153103372288,
            "songs":[
              {
                "id":"l3!song1",
                "title_t":"title4",
                "artist_t":"artist4",
                "trackNum_i":1,
                "_version_":1697935153103372288},
              
              {
                "id":"l3!song2",
                "title_t":"title2",
                "artist_t":"artist2",
                "trackNum_i":2,
                "_version_":1697935153103372288}]},
          
          {
            "id":"l3!sublist2",
            "title_t":"sublist2",
            "_version_":1697935153103372288,
            "songs":[
              {
                "id":"l3!song3",
                "title_t":"title1",
                "artist_t":"artist1",
                "trackNum_i":1,
                "_version_":1697935153103372288},
              
              {
                "id":"l3!song4",
                "title_t":"title3",
                "artist_t":"artist3",
                "trackNum_i":2,
                "_version_":1697935153103372288}]}]}]
  }}

MDN の動作サンプルを改造して遊ぶ

先日、MDN Web Docs で調べものをしているときに、
たまたま面白そうなサンプルを見つけました。


今日は、それを使って遊んでみたいと思います。


その前に、MDN Web Docs について分からない人向けに簡単に説明すると、
Mozilla.org による、Web技術者向けのドキュメントが集まったウェブサイトです。

MDN Web Docs (以前は MDN — Mozilla Developer Network と呼ばれていました) は、ウェブ技術とウェブを支えるソフトウェア、 CSS、 HTML、 JavaScript などについて学ぶための進化し続ける学習プラットフォームです。

MDN Web Docs について – MDN プロジェクト | MDN (mozilla.org)


私の中で、辞書変わりに常に開いておきたいサイトランキング1位に輝いています。
人によっては、読んでるだけで楽しい! ワクワクする! という噂もあります。


今回取り上げるサンプルと、元になるコードは以下にあります。
KeyboardEvent.code – Web APIs | MDN (mozilla.org)


“Try it out” と書かれた文字の下に、
黒い正方形と、その中にオレンジの二等辺三角形が描画されています。


コードを見ると、
オレンジの二等辺三角形は spaceship という名前のようです。
黒い四角は world。


私がつけたわけではありません。
が、ここに世界と、宇宙船が誕生したわけです。


ここからコードを追加し、
スペースキーを押すとショットを撃てるようにしたいと思います。


一部抜粋ですが、こんな感じに。

const spaceship = document.getElementById("spaceship");
const shot      = document.getElementById("shot");
const world     = document.querySelector('.world');
let isFiring    = false

function refresh() {
	let x = position.x - (shipSize.width/2);
	let y = position.y - (shipSize.height/2);
	let transform = "translate(" + x + " " + y + ") rotate(" + angle + " 15 15)";
	spaceship.setAttribute("transform", transform);

	if (isFiring) {
		let rad = angle * (Math.PI / 180);
		shotPosition.x += (Math.sin(rad) * shotMoveRate);
		shotPosition.y -= (Math.cos(rad) * shotMoveRate);
		let sx = shotPosition.x + (shipSize.width / 2);
		let sy = shotPosition.y + (shipSize.height / 2);
		shot.setAttribute("transform", 'translate(' + sx + ' ' + sy +')');

		if (isOutOfArea(sx, sy)) {
			isFiring = false;
		}
	} else {
		shotPosition.x = x;
		shotPosition.y = y;
	}
}
function isOutOfArea(x, y) {
	return (x < -1 || x > world.clientWidth || y < -1 || y > world.clientHeight);
}
setInterval(() => {
	refresh();
}, 33);


MDN のコードは、あくまでも KeyboardEvent のサンプルなので、
キーボードを押した際にしか処理が行われないようになっています。


これはこれで省エネなのですが、
この世界の神がキーボードを押している間しか時間が進みませんので、
いろいろと不都合です。

定期的に画面を描画し直すように変更しました。

次に、スペースキーで shot を発射するための処理を入れるのですが、
この宇宙船は、2次元平面上で回転します。

宇宙船の向きに合わせて前方に発射しましょう。

宇宙船と角度を合わせて、
座標を移動させるだけです。

無事、撃てるようになったら、
世界の領域を越えたショットを消すようにします。

宇宙の外に迷惑をかけてはいけません。


ここまでの動作サンプルを置いておきます。
下の枠内をクリックすると読み込みます。

クリックで読み込み

推奨環境は Google Chrome、MS Edge です。


気付いた人もいらっしゃるかもしれませんが、
angle の値を使いまわしているため、
ショットを撃ったあとに回転するとショットがカーブします。
これはこれで面白いかと思い放置しています。


自機が動いてショットを撃つ。

これだけでも、
なんとなくゲームのような片鱗が見えてきたのではないでしょうか。

360度全方位の2Dシューティングゲーム (画面は3D) が
何年か前にありましたが、
ものすごく頑張ればそれに近いものが作れるかもしれません。


MDN にはいろいろな動作サンプルがあります。
あなたの求めるものが意外なところから見つかるかもしれません。

緑は心の拠り所

テレワークや緊急事態宣言などで、おうち時間がぐんと増えたので癒やしを求めて観葉植物を育てはじめました。
現在我が家には3つの観葉植物ちゃんがいます。
それぞれ紹介したいと思います。

我が家の観葉植物ちゃんたち

ガジュマル


精霊が宿る神秘の木と言われていて、とても生命力が強い植物です。
めちゃくちゃ斜めに反って生えているのに一目惚れしました。
小さい新芽が出てきているので、こちらも成長がとても楽しみです。

スパティフィラム


特に手をかけなくても、新芽がどんどん出てきて成長してくれてかわいいです。
毎日どれぐらい成長しているか見るのが日課です。

名前わすれた


吊るす系の観葉植物です。
ツタが伸びていて、吊るすだけで部屋をとてもおしゃれな雰囲気にしてくれます。
カーテンレールなどに引っ掛けれるため、設置場所にも特に困らなかったです。

水やりについて

水やりは基本的に土が乾いてからやるのが鉄則です。
よく毎朝水やりをするという方がいますが、土の水分が乾く前に水をどんどん与えてしまうと根腐れになり、枯れる原因にもなってしまいます。なので指で表面の土を触ってカサカサしてるなと思ったら水をやるぐらいのペースで十分です。
目安としては、夏場なら3日に1回、冬場なら1週間に1回ぐらいでいいと思います。ただし、毎日表面の土のチェックは忘れずに行って、適宜水やりが必要と思えばやりましょう。

観葉植物とともに暮らそう

水やりのコツさえ分かれば、初心者でもかんたんに観葉植物は育てることができます!
ぜひ毎日の生活に緑を添えて、気分をリフレッシュさせながら生活してみるのはどうでしょうか。

WSL2とVS Codeの準備覚え書き

Windowsの再インストールや複数台セットアップ時に
WSL2(Windows Subsystem for Linux)とVS Code(VisualStudio Code)で、
最低限の作業ができるようになる以前の準備を都度調べなくてもいいようメモとしてまとめます。

対象環境は、Windows10です。

WSL2の有効化。

Windows Subsystem for Linux (WSL) を Windows 10 にインストールする | Microsoft Docs
https://docs.microsoft.com/ja-jp/windows/wsl/install-win10

MS提供のドキュメント通りにすれば問題なし。

MS StoreからUbuntu, Windows Terminalのダウンロード。

Ubuntu 20.04 LTS
https://www.microsoft.com/store/apps/9n6svws3rx71

Windows Terminal を入手 – Microsoft Store ja-JP
https://www.microsoft.com/ja-jp/p/windows-terminal/9n0dx20hk701

Ubuntuである必要はありません。
ここでは省略しますが、併用も簡単。

Windows Terminal のテーマカラー。

Windows Terminal Color Schemes | Microsoft Docs
https://docs.microsoft.com/ja-jp/windows/wsl/user-support

個人的に “One Half Dark” を選択。

Windows ターミナルのすりグラステーマ
Windows ターミナルすりグラステーマの構成 | Microsoft Docs

何故かターミナルは透過設定したくなりがち。

VS Codeのインストール。

Visual Studio Code – Code Editing. Redefined
https://code.visualstudio.com/

VS Code拡張機能Remote SSHを追加

VS Code内Ctrl + Shift + Xから “Remote SSH” で検索。
色々面倒な場合は “Remote Development” で色々一括で入る(教えてもらった)

WSL2からWindowsディレクトリの参照。

C:ドライブ直下を参照する場合

$ ls -l mnt/c/

おわり。

ここから更にVS Codeの拡張を入れたり、
Dockerやgit、npm等を必要に応じてインストールしていきますが、
ひとまず作業に取り掛かろうという空気はそこはかとなく出せる状態になったはず。

VMでLinuxの環境を作るよりもお手軽かつ高速で、
改めてWindowsで環境を準備するのも楽になったと感じます。

諸々の事情によりVMの環境も欲しくなりますが…。

ここまでご覧いただきありがとうございます。

【Solr】Result Grouping と Facet との組み合わせ

前回取り上げた Result Grouping は Facet と組み合わせることができます。大阪の施設情報の例で言うと、type フィールドでファセットを作り、それぞれに含まれる area フィールドのグループの件数を求め る、といったことができます。

ただし、分散検索においては同じグループに属するドキュメントは同じシャードに置かれなければならない、という制限があります。

特定の条件に当てはまるドキュメントを同じシャードに配置するためにはドキュメントIDに複合キーを指定します。具体的には以下のような形になります。

通常
{"id":"10","type":"官公庁","area":"住之江区","name":"軽自動車検査協会大阪主管事務所","address":"住之江区南港東3-4-62","address_p":"34.6164938333333,135.438210722222"}
areaフィールドの値が同じドキュメントを同じシャードに配置
{"id":"住之江区!10","type":"官公庁","area":"住之江区","name":"軽自動車検査協会大阪主管事務所","address":"住之江区南港東3-4-62","address_p":"34.6164938333333,135.438210722222"}

idの値を “areaの値!本来のid” とすることでareaの値が同じドキュメントが同じシャードでインデックスされます。

組み合わせを試す前に、 まずは type フィールドを使った通常の Facet 検索の結果です。

$ curl -s 'http://localhost:8983/solr/osaka_shisetsu2/select?q=*:*&rows=0&facet=on&facet.field=type_str'
{
  "responseHeader":{
    "zkConnected":true,
    "status":0,
    "QTime":2,
    "params":{
      "q":"*:*",
      "facet.field":"type_str",
      "rows":"0",
      "facet":"on"}},
  "response":{"numFound":9238,"start":0,"maxScore":1.0,"numFoundExact":true,"docs":[]
  },
  "facet_counts":{
    "facet_queries":{},
    "facet_fields":{
      "type_str":[
        "駅・バス停",2574,
        "公園・スポーツ",1090,
        "駐車場・駐輪場",1049,
        "学校・保育所",1045,
        "医療・福祉",840,
        "会館・ホール",642,
        "名所・旧跡",561,
        "警察・消防",330,
        "公衆トイレ",322,
        "官公庁",301,
        "文化・観光",290,
        "その他",141,
        "環境・リサイクル",53]},
    "facet_ranges":{},
    "facet_intervals":{},
    "facet_heatmaps":{}}}

それぞれの件数は Facet 内のドキュメントの件数を示しています。

次にグループ検索と Facet 検索の組み合わせです。

$ curl -s 'http://localhost:8983/solr/osaka_shisetsu2/select?q=*:*&group=true&group.field=area_str&rows=0&group.facet=true&facet=on&facet.field=type_str'
{
  "responseHeader":{
    "zkConnected":true,
    "status":0,
    "QTime":4,
    "params":{
      "q":"*:*",
      "facet.field":"type_str",
      "group.facet":"true",
      "rows":"0",
      "facet":"on",
      "group.field":"area_str",
      "group":"true"}},
  "grouped":{
    "area_str":{
      "matches":9238,
      "groups":[]}},
  "facet_counts":{
    "facet_queries":{},
    "facet_fields":{
      "type_str":[
        "公園・スポーツ",25,
        "公衆トイレ",25,
        "医療・福祉",25,
        "名所・旧跡",25,
        "学校・保育所",25,
        "官公庁",25,
        "文化・観光",25,
        "駅・バス停",25,
        "その他",24,
        "会館・ホール",24,
        "警察・消防",24,
        "駐車場・駐輪場",24,
        "環境・リサイクル",20]},
    "facet_ranges":{},
    "facet_intervals":{},
    "facet_heatmaps":{}}}

それぞれの件数はグループ数(Facet に含まれるエリアの種類)です。公共の施設なのでほとんどのタイプはほとんどの区に存在している(大阪24区+「大阪市以外」というタイプがあるためほとんどのグループ数は25になっている)ためあまりおもしろい結果ではありませんが、通常の Facet 検索との違いは良く分かると思います。

ちなみに、ドキュメントルーティングの設定をせずに作ったインデックスでの同じクエリの結果は以下の通りです。

$ curl -s 'http://localhost:8983/solr/osaka_shisetsu2/select?q=*:*&group=true&group.field=area_str&rows=0&group.facet=true&facet=on&facet.field=type_str'
{
  "responseHeader":{
    "zkConnected":true,
    "status":0,
    "QTime":7,
    "params":{
      "q":"*:*",
      "facet.field":"type_str",
      "group.limit":"3",
      "group.facet":"true",
      "rows":"0",
      "facet":"on",
      "group.field":"area_str",
      "group":"true"}},
  "grouped":{
    "area_str":{
      "matches":9238,
      "groups":[]}},
  "facet_counts":{
    "facet_queries":{},
    "facet_fields":{
      "type_str":[
        "医療・福祉",50,
        "学校・保育所",50,
        "官公庁",50,
        "駅・バス停",50,
        "公衆トイレ",49,
        "名所・旧跡",49,
        "会館・ホール",48,
        "公園・スポーツ",48,
        "駐車場・駐輪場",48,
        "警察・消防",47,
        "文化・観光",46,
        "その他",40,
        "環境・リサイクル",29]},
    "facet_ranges":{},
    "facet_intervals":{},
    "facet_heatmaps":{}}}

2つのシャードそれぞれでグループ化が実行されるため、重複が発生してグループの数が倍近くになってしまいます。これを防ぐために複合キーを用いたドキュメントルーティングが必要になる訳です。