カテゴリー: テクノロジー

[Solr]コレクションのインクリメンタルバックアップ

はじめに

Solr 8.9から SolrCloud におけるコレクションのバックアップの形式が変更されてインクリメンタルバックアップ対応となりました。

バックアップ

インクリメンタルバックアップ対応に伴い、コレクションAPIのBACKUPアクションのパラメータに以下の2つが追加されました。

incremental

true (デフォルト)ならインクリメンタルバックアップ、false なら従来と同様のスナップショットによるバックアップとなります。スナップショットによるバックアップは近いうちに廃止となる予定です。

maxNumBackupPoints

インクリメンタルバックアップではバックアップポイントという形で直前のバックアップとの差分が保存されていきます。
最大で何個をバックアップポイントを保存するのかを maxNumBackupPoints で指定します。

サンプル

サンプルとして大阪の施設情報を利用します。

以下のようなデータを1件ずつ追加して、その都度バックアップします。

os_1.json
[{"id":"10","type":"官公庁","area":"住之江区","name":"軽自動車検査協会大阪主管事務所","address":"住之江区南港東3-4-62","address_p":"34.6164938333333,135.438210722222"}]

os_2.json
[{"id":"11","type":"官公庁","area":"住之江区","name":"大阪陸運支局なにわ自動車検査登録事務所","address":"住之江区南港東3-1-14","address_p":"34.6190439722222,135.442191833333"}]

os_3.json
[{"id":"12","type":"官公庁","area":"住吉区","name":"住吉税務署","address":"住吉区住吉2丁目17番37号","address_p":"34.6109641111111,135.491388722222"}]

os_4.json
[{"id":"13","type":"官公庁","area":"住之江区","name":"玉出年金事務所","address":"住之江区北加賀屋2-3-6","address_p":"34.6231918888888,135.477992138888"}]
$ curl 'http://localhost:8983/solr/backup_test/update?commit=true&indent=true' --data-binary @os_1.json -H 'Content-Type: application/json'

$ curl 'http://localhost:8983/solr/admin/collections?omitHeader=true&action=BACKUP&name=backup1&collection=backup_test&location=/tmp/solr_backup&incremental=true&maxNumBackupPoints=3'
{
  "success":{
    "127.0.1.1:8983_solr":{
      "responseHeader":{
        "status":0,
        "QTime":2},
      "response":[
        "startTime","2021-06-28T14:00:11.906389Z",
        "indexFileCount",18,
        "uploadedIndexFileCount",18,
        "indexSizeMB",0.005,
        "uploadedIndexFileMB",0.005,
        "shard","shard1",
        "endTime","2021-06-28T14:00:11.908343Z",
        "shardBackupId","md_shard1_1"]}},
  "response":[
    "collection","backup_test",
    "numShards",1,
    "backupId",1,
    "indexVersion","8.9.0",
    "startTime","2021-06-28T14:00:11.903010Z",
    "indexSizeMB",0.005]}

$ curl 'http://localhost:8983/solr/backup_test/update?commit=true&indent=true' --data-binary @os_2.json -H 'Content-Type: application/json'

$ curl 'http://localhost:8983/solr/admin/collections?omitHeader=true&action=BACKUP&name=backup1&collection=backup_test&location=/tmp/solr_backup&incremental=true&maxNumBackupPoints=3'
{
  "success":{
    "127.0.1.1:8983_solr":{
      "responseHeader":{
        "status":0,
        "QTime":2},
      "response":[
        "startTime","2021-06-28T14:00:36.142084Z",
        "indexFileCount",35,
        "uploadedIndexFileCount",18,
        "indexSizeMB",0.01,
        "uploadedIndexFileMB",0.005,
        "shard","shard1",
        "endTime","2021-06-28T14:00:36.144073Z",
        "shardBackupId","md_shard1_2"]}},
  "response":[
    "collection","backup_test",
    "numShards",1,
    "backupId",2,
    "indexVersion","8.9.0",
    "startTime","2021-06-28T14:00:36.137745Z",
    "indexSizeMB",0.01]}

$ curl 'http://localhost:8983/solr/backup_test/update?commit=true&indent=true' --data-binary @os_3.json -H 'Content-Type: application/json'

$ curl 'http://localhost:8983/solr/admin/collections?omitHeader=true&action=BACKUP&name=backup1&collection=backup_test&location=/tmp/solr_backup&incremental=true&maxNumBackupPoints=3'
{
  "success":{
    "127.0.1.1:8983_solr":{
      "responseHeader":{
        "status":0,
        "QTime":4},
      "response":[
        "startTime","2021-06-28T14:01:38.399993Z",
        "indexFileCount",52,
        "uploadedIndexFileCount",18,
        "indexSizeMB",0.015,
        "uploadedIndexFileMB",0.005,
        "shard","shard1",
        "endTime","2021-06-28T14:01:38.404263Z",
        "shardBackupId","md_shard1_3"]}},
  "response":[
    "collection","backup_test",
    "numShards",1,
    "backupId",3,
    "indexVersion","8.9.0",
    "startTime","2021-06-28T14:01:38.398621Z",
    "indexSizeMB",0.015],
  "deleted":[[
      "startTime","2021-06-28T13:56:09.437719Z",
      "backupId",0,
      "size",5223,
      "numFiles",18]],
  "collection":"backup_test"}

$ curl 'http://localhost:8983/solr/backup_test/update?commit=true&indent=true' --data-binary @os_4.json -H 'Content-Type: application/json'

$ curl 'http://localhost:8983/solr/admin/collections?omitHeader=true&action=BACKUP&name=backup1&collection=backup_test&location=/tmp/solr_backup&incremental=true&maxNumBackupPoints=3'
{
  "success":{
    "127.0.1.1:8983_solr":{
      "responseHeader":{
        "status":0,
        "QTime":3},
      "response":[
        "startTime","2021-06-28T14:02:05.875017Z",
        "indexFileCount",69,
        "uploadedIndexFileCount",18,
        "indexSizeMB",0.019,
        "uploadedIndexFileMB",0.005,
        "shard","shard1",
        "endTime","2021-06-28T14:02:05.878413Z",
        "shardBackupId","md_shard1_4"]}},
  "response":[
    "collection","backup_test",
    "numShards",1,
    "backupId",4,
    "indexVersion","8.9.0",
    "startTime","2021-06-28T14:02:05.873449Z",
    "indexSizeMB",0.019],
  "deleted":[[
      "startTime","2021-06-28T14:00:11.903010Z",
      "backupId",1,
      "size",5223,
      "numFiles",18]],
  "collection":"backup_test"}

各バックアップの応答に backupId が記載されています。リストアの際に backupId を指定することで特定のバックアップポイントに戻すことができます。

4回目のバックアップの応答に deleted という項目があります。maxNumBackupPoints=3 を指定しているので、4回目のバックアップでは最初のバックアップが削除されてバックアップポイントが3個に保たれていることが分かります。

ここでインデックスの状態を確認します。ドキュメントを1件ずつ4回投入したので、全件検索すると4件ヒットします。

$ curl -s 'http://localhost:8983/solr/backup_test/select?omitHeader=true&q.op=OR&q=*%3A*&rows=0'
{
  "response":{"numFound":4,"start":0,"numFoundExact":true,"docs":[]
  }}

リストア

LISTBACKUP アクションで現在利用可能なバックアップポイントを確認できます。

$ curl 'http://localhost:8983/solr/admin/collections?omitHeader=true&action=LISTBACKUP&name=backup1&location=/tmp/solr_backup'
{
  "collection":"backup_test",
  "backups":[{
      "indexFileCount":0,
      "indexSizeMB":0.0,
      "shardBackupIds":{"shard1":"md_shard1_2.json"},
      "collection.configName":"backup_test",
      "backupId":2,
      "collectionAlias":"backup_test",
      "startTime":"2021-06-28T14:00:36.137745Z",
      "indexVersion":"8.9.0"},
    {
      "indexFileCount":0,
      "indexSizeMB":0.0,
      "shardBackupIds":{"shard1":"md_shard1_3.json"},
      "collection.configName":"backup_test",
      "backupId":3,
      "collectionAlias":"backup_test",
      "startTime":"2021-06-28T14:01:38.398621Z",
      "indexVersion":"8.9.0"},
    {
      "indexFileCount":0,
      "indexSizeMB":0.0,
      "shardBackupIds":{"shard1":"md_shard1_4.json"},
      "collection.configName":"backup_test",
      "backupId":4,
      "collectionAlias":"backup_test",
      "startTime":"2021-06-28T14:02:05.873449Z",
      "indexVersion":"8.9.0"}]}

ドキュメント2件のときのバックアップ(backupId=2)に戻します。

$ curl 'http://localhost:8983/solr/admin/collections?action=RESTORE&name=backup1&collection=backup_test&location=/tmp/solr_backup&backupId=2'
{
  "responseHeader":{
    "status":0,
    "QTime":599}}
tfukui@deskmini:~/dev/splout_blog/sample_data$ curl -s 'http://localhost:8983/solr/backup_test/select?omitHeader=true&q.op=OR&q=*%3A*&rows=0'
{
  "response":{"numFound":2,"start":0,"numFoundExact":true,"docs":[]
  }}

ドキュメント3件のときのバックアップ(backupId=3)に戻します。

$ curl 'http://localhost:8983/solr/admin/collections?action=RESTORE&name=backup1&collection=backup_test&location=/tmp/solr_backup&backupId=3'
{
  "responseHeader":{
    "status":0,
    "QTime":624}}
tfukui@deskmini:~/dev/splout_blog/sample_data$ curl -s 'http://localhost:8983/solr/backup_test/select?omitHeader=true&q.op=OR&q=*%3A*&rows=0'
{
  "response":{"numFound":3,"start":0,"numFoundExact":true,"docs":[]
  }}

既存のコレクションに対するリストアができるようになったことと併せて、コレクションのバックアップ機能がより使いやすくなりました。

[Solr]既存のコレクションに対してバックアップをリストアできるようになりました

はじめに

Solr 8.8 までは、SolrCloud で取得したバックアップをリストアするときには新しいコレクションを指定しなければならないという制限がありました。したがって、クライアントに公開するコレクション名をエイリアスで運用して、バックアップのリストア後にエイリアスを切り替えるという工夫が必要でした。

Solr 8.9 からは既存のコレクションに対してリストアできるようになりました。

バックアップ

curl 'http://localhost:8983/solr/admin/collections?action=BACKUP&name=backup1&collection=backup_test&location=/tmp/solr_backup'

backup_test というコレクションのバックアップを /tmp/solr_backup 以下に作成します。リストア時に参照するときの名前は backup1 です。

リストア(8.8 までの場合)

既存のコレクション名 backup_test を指定するとパラメータエラーとなります。

$ curl 'http://localhost:8983/solr/admin/collections?action=RESTORE&name=backup1&collection=backup_test&location=/tmp/solr_backup'
{
  "responseHeader":{
    "status":400,
    "QTime":0},
  "error":{
    "metadata":[
      "error-class","org.apache.solr.common.SolrException",
      "root-error-class","org.apache.solr.common.SolrException"],
    "msg":"Collection 'backup_test' exists, no action taken.",
    "code":400}}

新しいコレクション名 new_backup_test を指定すると、そのコレクションが作られてからリストアされます。

$ curl 'http://localhost:8983/solr/admin/collections?action=RESTORE&name=backup1&collection=new_backup_test&location=/tmp/solr_backup'
{
  "responseHeader":{
    "status":0,
    "QTime":612},
  "success":{
    "127.0.1.1:8983_solr":{
      "responseHeader":{
        "status":0,
        "QTime":238},
      "core":"new_backup_test_shard1_replica_n1"}}}

リストア(8.9の場合)

既存のコレクション名 backup_test を指定してリストアできます。

試しに、バックアップ後にインデックスの内容を全部消去します。

$ curl -s 'http://localhost:8983/solr/backup_test/select?omitHeader=true&q.op=OR&q=*%3A*&rows=0'
{
  "response":{"numFound":9238,"start":0,"numFoundExact":true,"docs":[]
  }}

$ curl -s 'http://localhost:8983/solr/backup_test/update?stream.body=*:*&wt=json&commit=true'
{
  "responseHeader":{
    "rf":1,
    "status":0,
    "QTime":2}}

$ curl -s 'http://localhost:8983/solr/backup_test/select?omitHeader=true&q.op=OR&q=*%3A*&rows=0'
{
  "response":{"numFound":0,"start":0,"numFoundExact":true,"docs":[]
  }}

その後、既存のコレクション backup_test を対象にしてリストアします。8.8までとは違ってエラーになりません。

$ curl 'http://localhost:8983/solr/admin/collections?action=RESTORE&name=backup1&collection=backup_test&location=/tmp/solr_backup'
{
  "responseHeader":{
    "status":0,
    "QTime":977}}

全件検索したときの件数が元に戻りました。

$ curl -s 'http://localhost:8983/solr/backup_test/select?omitHeader=true&q.op=OR&q=*%3A*&rows=0'
{
  "response":{"numFound":9238,"start":0,"numFoundExact":true,"docs":[]
  }}

既存のコレクションに対するリストアの仕組み

SOLR-1587 によると、使用中である可能性もある既存のコレクションに対するリストアを実現するために、Solr 8.1 で追加された Read-Only モードが使われているそうです。

  1. 指定されたコレクションを Read-Only モードにセットする
  2. コレクションの各シャードにバックアップをリストアする
  3. コレクションの Read-Only モードを解除する

ChromeでUA-CHへの移行が再開

最近、Chrome の DevTools上に、
navigator.userAgent に関する警告のようなものが表示されるようになりました。

Google Developers のブログによると Chrome のバージョンアップが行われ、
以前、COVID-19 の影響により停止していた UA-CH への移行を再開し、
userAgent の取得に関するサポートを終了するようです。

Google Developers Japan: Chrome の User-Agent 文字列削減に関する最新情報
https://developers-jp.googleblog.com/2021/06/chrome-user-agent.html

UA-CH がどういったものか分かっていなかったので実際に試してみました。

サーバーにヘッダーの設定を追加します。

add_header Accept-CH "Sec-CH-UA, Sec-CH-UA-Arch, Sec-CH-UA-Model, Sec-CH-UA-Platform, Sec-CH-UA-Platform-Version, Sec-CH-UA-Full-Version, Sec-CH-UA-Mobile";

Chrome からの一度目のアクセスでは、
SEC-CH-UA、SEC-CH-UA-MOBILE のみがブラウザから送信されました。

Chromeから送信されたヘッダー1

二度目移行のアクセスで、残りの Sec-CH-UA ヘッダも送信されるようになりました。

Chromeから送信されたヘッダー2

因みに Edge でも同様でした。

これらのヘッダが送信されるのは https 限定です。
http では送信されません。

ブログには Critical-CH のロールウトも始まったと記述がありましたが、
こちらはどういったものなのかよく分かりませんでした。

必須のクライアントヒントを送信するよう要求できるそうですが、
レスポンスヘッダーに追加しただけでは記述されているような動作にはなりませんでした。

まだ動作しないか、設定方法が全く違うのかもしれません。

しばらく情報を追っていきたいと思います。

Amazon Location Service のジオコーダを試してみた

はじめに

Amazon Location Service が6月に一般公開されました。
主な機能として以下の5つがあります。

  • 地図
  • ジオコーディング(住所や場所の名前を緯度経度に変換する)やリバースジオコーディング(緯度経度を住所に変換する)
  • 出発地から目的地までのルート計算
  • 位置情報デバイスのトラッキング
  • ジオフェンシング(設定した領域への出入りを検出する)

このうちのジオコーディングを試してみました。

ジオコーディングを AWS CLI から利用する

今回は AWS CLI からジオコーダを呼び出します。Location Service は新しいサービスなので、aws コマンドがインストール済みの場合は aws help で Location がサポートされているバージョンかどうかを確認し、サポートされていない場合は最新版にアップデートしておきます。

Place Index 作成

Place Index はジオコーダやリバースジオコーダを利用する際に必要となるリソースです。いくつかの設定項目を指定して Place Index を作成しておき、その Place Index を起点にジオコーダを呼び出す仕組みになっています。

以下のコマンドで、データソースとして Esri、料金プランとしてリクエスト数ベースを利用する Place Index を ExamplePlaceIndex という名前で呼び出せるようになります。

$ aws location \
>   create-place-index \
>   --data-source "Esri" \
>   --description "for investigation Esri" \
>   --index-name "ExamplePlaceIndex" \
>   --pricing-plan "RequestBasedUsage"

{
    "CreateTime": "2021-06-12T13:46:09.170000+00:00",
    "IndexArn": "arn:aws:geo:us-west-2:495463288701:place-index/ExamplePlaceIndex",
    "IndexName": "ExamplePlaceIndex"
}

Place Index 確認

以下のコマンドで、作成した Place Index の内容を確認できます。

$ aws location list-place-indexes
{
    "Entries": [
        {
            "CreateTime": "2021-06-12T13:46:09.170000+00:00",
            "DataSource": "Esri",
            "Description": "for investigation Esri",
            "IndexName": "ExamplePlaceIndex",
            "PricingPlan": "RequestBasedUsage",
            "UpdateTime": "2021-06-12T13:46:09.170000+00:00"
        }
    ]
}

ジオコーディング

ジオコーダを利用するときは search-place-index-for-text というサブコマンドを使います。

$ aws location search-place-index-for-text \
--index-name ExamplePlaceIndex \
--text "大阪市中央区" \
--max-results 5
{
    "Results": [
        {
            "Place": {
                "Country": "JPN",
                "Geometry": {
                    "Point": [
                        135.50988413000005,
                        34.681143990000066
                    ]
                },
                "Label": "大阪府大阪市中央区",
                "Municipality": "中央区",
                "Region": "大阪府"
            }
        }
    ],
    "Summary": {
        "DataSource": "Esri",
        "MaxResults": 10,
        "ResultBBox": [
            135.50988413000005,
            34.681143990000066,
            135.50988413000005,
            34.681143990000066
        ],
        "Text": "大阪市中央区"
    }
}

「大阪市中央区」の緯度経度が(34.681143990000066,135.50988413000005)であるという応答です。この座標を OpenStreetMap で表示すると、確かに大阪市中央区役所であることが確認できます。
https://www.openstreetmap.org/#map=19/34.681143990000066/135.50988413000005/

データソースとして HERE を使う Place Index 作成

Amazon Location Service ではデータソースとして EsriHERE が利用できます。ここではデータソースとして HERE を指定して、Esri のときとどのような違いがあるかを調べてみます。

$ aws location \
  create-place-index \
  --data-source "Here" \
  --description "for investigation HERE" \
  --index-name "ExamplePlaceIndexHERE" \
  --pricing-plan "RequestBasedUsage"
$ aws location list-place-indexes
{
    "Entries": [
        {
            "CreateTime": "2021-06-12T13:46:09.170000+00:00",
            "DataSource": "Esri",
            "Description": "for investigation Esri",
            "IndexName": "ExamplePlaceIndex",
            "PricingPlan": "RequestBasedUsage",
            "UpdateTime": "2021-06-12T13:46:09.170000+00:00"
        },
        {
            "CreateTime": "2021-06-12T14:12:51.610000+00:00",
            "DataSource": "Here",
            "Description": "for investigation HERE",
            "IndexName": "ExamplePlaceIndexHERE",
            "PricingPlan": "RequestBasedUsage",
            "UpdateTime": "2021-06-12T14:12:51.610000+00:00"
        }
    ]
}

HERE でジオコーディング

Esri のときと同じく「大阪市中央区」でジオコーディングします。

$ aws location search-place-index-for-text \
--index-name ExamplePlaceIndexHERE \
--text "大阪市中央区" \
--max-results 5 
{
    "Results": [
        {
            "Place": {
                "Country": "JPN",
                "Geometry": {
                    "Point": [
                        135.50988,
                        34.68114
                    ]
                },
                "Label": "大阪府大阪市中央区",
                "Municipality": "大阪市",
                "Neighborhood": "中央区",
                "Region": "大阪府"
            }
        },
        {
            "Place": {
                "Country": "JPN",
                "Geometry": {
                    "Point": [
                        135.5014,
                        34.66891
                    ]
                },
                "Label": "大阪府大阪市中央区道頓堀1丁目6ミナミ (難波)",
                "Municipality": "大阪市",
                "Neighborhood": "中央区",
                "Region": "大阪府"
            }
        },
        {
            "Place": {
                "AddressNumber": "27",
                "Country": "JPN",
                "Geometry": {
                    "Point": [
                        135.50398,
                        34.69353
                    ]
                },
                "Label": "大阪府大阪市北区中之島1丁目1-27大阪市中央公会堂",
                "Municipality": "大阪市",
                "Neighborhood": "北区",
                "Region": "大阪府"
            }
        },
        {
            "Place": {
                "Country": "JPN",
                "Geometry": {
                    "Point": [
                        135.48485,
                        34.69745
                    ]
                },
                "Label": "大阪府大阪市福島区福島7丁目4ジェイホッパーズ大阪ゲストハウス",
                "Municipality": "大阪市",
                "Neighborhood": "福島区",
                "Region": "大阪府"
            }
        },
        {
            "Place": {
                "Country": "JPN",
                "Geometry": {
                    "Point": [
                        135.50791,
                        34.68903
                    ]
                },
                "Label": "大阪府大阪市中央区伏見町1丁目1ホテルブライトンシティ大阪北浜",
                "Municipality": "大阪市",
                "Neighborhood": "中央区",
                "Region": "大阪府"
            }
        }
    ],
    "Summary": {
        "DataSource": "Here",
        "MaxResults": 5,
        "ResultBBox": [
            135.48485,
            34.66891,
            135.50988,
            34.69745
        ],
        "Text": "大阪市中央区"
    }
}

sri の場合は max-results を 5 で指定していても応答は1件だけでしたが、HERE の場合は 住所が大阪市中央区でないPOIでもアグレッシブに応答しており、ジオコーダとしての性格がかなり異なることが分かります。

[Solr]TextProfileSignatureによるDe-Duplication

はじめに

前回の記事で取り上げた De-Duplication ではハッシュの計算方法として、厳密には一致しなくてもほぼ同内容のドキュメントを同一として扱うためのTextProfileSignature が利用できます。Solr のドキュメントでは以下のように書かれています。

Fuzzy hashing implementation from Apache Nutch for near duplicate detection. It’s tunable but works best on longer text.

https://solr.apache.org/guide/8_8/de-duplication.html

どのくらい Fuzzy でも大丈夫なのか興味があったので調べてみました。

TextProfileSignature クラス

TextProfileSignature クラスの JavaDoc に詳しい説明がありました。

  • 文字と数字以外を取り除いて小文字に統一する
  • ソースを見ると、この判定には Character.isLetterOrDigit() が使われています。
  • 空白区切りでトークンに分割する
  • MIN_TOKEN_LEN(デフォルト2)より短いトークンを捨てる
  • 各トークンの出現回数をカウントする
  • 足きり用の QUANT を計算する。QUANT = QUANT_RATE * 最頻出のトークンの出現回数 (QUANT_RATEのデフォルト0.01)
  • QUANT が2より小さい場合は QUANT = 2 とする。ただし、2回以上出現したトークンが存在しない場合は QUANT = 1 とする。
    • すべてのトークンが1回ずつしか出現しなかった場合は足きりせず全部使うということ
    • ソースを見ると QUANT_RATE * 再頻出のトークンの出現回数 を四捨五入している。つまり、QUANT_RATE が デフォルトの 0.01 であれば、再頻出のトークンの出現回数が250までは QUANT = 2 (1回しか出現しないトークンは捨てられる)となる。
  • QUANT よりも小さい出現回数のトークンを捨てる
  • 残ったトークンを出現回数順に並べて MD5 ハッシュを計算する

ちなみに、空白文字で区切ってトークンを作るという処理なので、日本語のドキュメントにはあまり有効ではなさそうで、日本語ドキュメントで曖昧な De-Deplication をするためには、Tokenizer と連携する ProfileSignature を実装する必要がありそうです。

実験

実験のため、TextProfileSignature を呼び出す簡単なプログラムを作りました。

短いドキュメントでも効果がわかりやすいように、QUANT_RATE は 1 としています。これなら、再頻出のトークンの出現回数が2ならQUANTは2、再頻出のトークンの出現回数が2ならQUANTは3となります。

'I have an apple'  8b821c9e763bb2fc567d473996cfde4a
'I have an apple.' 8b821c9e763bb2fc567d473996cfde4a

記号の有無はハッシュ値に影響を与えません。

'an apple I have' 8b821c9e763bb2fc567d473996cfde4a

トークンの出現回数が同じなら、語順はハッシュ値に影響を与えません。

'I have the apple' 9526cdfcde3ddfad02a0691d564f30ac

トークンが別のものに変わるとハッシュ値も変化します。

'I have apple. I have apple.' 5d5a0ce2d6dc15618d873d5572c4eb5e
'I have a apple. I have the apple.' 5d5a0ce2d6dc15618d873d5572c4eb5e

QUANTが2になるので、1回しか出現しない ‘a’ ‘the’ の有無はハッシュ値に影響を与えません。

'I have an apple. I have an apple. I have the apple.' d95062c38e38e90b1c34b009bf434cda
'I have the apple. I have the apple. I have an apple.' d95062c38e38e90b1c34b009bf434cda

QUANTが3になるので、2回しか出現しない ‘a’ ‘the’ の有無はハッシュ値に影響を与えません。