こちらの記事でエンべディング戦略について考えてみました。
前回は1アイテム1チャンクのシンプルなテストデータで検証しました。
しかし実運用上では様々なチャンキング戦略でチャンク分割が行われます。
分割されたチャンクデータをdynamoDBでどう管理していくかを考えてみました。
チャンキングの種類をサクッとおさらい
・固定長:特定の長さでブチっ!と区切ってチャンク分け
・再起的:段落とか句読点とかいい感じのところでチャンクを分ける
・構造認識:大見出し、小見出し、表等の構造を理解した上でチャンクを切る
・セマンティック:文章の意味の変わり目でチャンクを切る
様々なチャンキング戦略がありますが、どう区切るか?という観点の違いだけでデータの保持という感では同じです。今回は固定長のチャンキング戦略で試していきます。
検証環境
・リージョン:東京リージョン
・課金モード:オンデマンド
・emmbeddingmodel:amazon.titan-embed-text-v2
・次元数:1024
・チャンク戦略:固定長(token:64 overlap:8)
投入データ
アニメのオープニング語りをベクトルデータとして取り込むという前提で考えていきます。
[名探偵コナン]
俺は高校生探偵・工藤新一。幼馴染で同級生の毛利蘭と遊園地に遊びに行って、黒ずくめの男の怪しげな取引現場を目撃した。取引を見るのに夢中になっていた俺は、背後から近づいてくるもう一人の仲間に気づかなかった。俺はその男に毒薬を飲まされ、目が覚めたら…体が縮んでしまっていた!!工藤新一が生きていると奴らにバレたら、また命が狙われ、周りの人間にも危害が及ぶ。阿笠博士の助言で正体を隠すことにした俺は、蘭に名前を聞かれてとっさに、江戸川コナンと名乗り、奴らの情報をつかむために、父親が探偵をやっている蘭の家に転がり込んだ。謎に包まれた黒ずくめの組織…。わかっているのは、そのコードネームがお酒にちなんだ名前であることくらいだ…。そんな奴らの正体を暴くため、小さな探偵、江戸川コナンの活躍が始まった!!たったひとつの真実見抜く見た目は子供、頭脳は大人その名は、名探偵コナン!!
[ONE PIECE]
富、名声、力。かつてこの世の全てを手に入れた男、海賊王ゴールド・ロジャー。彼の死に際に放った一言は、人々を海へ駆り立てた。「おれの財宝か?欲しけりゃくれてやる。探せ!この世の全てをそこに置いてきた」。世はまさに、大海賊時代!"
[名探偵プリキュア!]
【明智あんな】は、マコトミライタウンに暮らす14歳の中学2年生!誕生日に現れた妖精【ポチタン】とお部屋にあったペンダントに導かれて2027年から1999年のまことみらい市にタイムスリップ…!そこで出会ったのは、名探偵に憧れている14歳の女の子【小林みくる】!そんな中、事件発生!?大切なものを盗まれて困っている人がいるみたい…!事件は【怪盗団ファントム】のしわざ…!?困っている人を見逃せない…!そんな思いから、あんなとみくるは【名探偵プリキュア】に変身‼名探偵プリキュアがみんなの笑顔を推理で守る!「そのナゾ!キュアット解決!」そして、あんなは元の時代に戻ることができるのか…!?"
分割されたチャンクのデータ構造を考えてみる
このデータをDynamo DBで管理するにはどうすれば良いでしょう?
私は3つの方式を考えてみました。
・ 方式A:横持ち作戦
・ 方式B:縦持ち作戦
・ 方式C:親子管理作戦
私の頭の中から出した方式なので、まだまだ良い方式はあるかもしれません。
その際はぜひ、コメント等で教えていただければ大変嬉しく思います。
それではそれぞれを詳しくみていきたいと思います。
方式A:横持ち作戦
チャンクの分だけ属性として保持する方式です。
| PK | title |
chunk1 |
chunk2 |
chunk3 |
embedding1 |
embedding2 |
embedding3 |
|---|---|---|---|---|---|---|---|
CONAN |
名探偵コナン | 俺は高校生探偵・工藤新一... | 。取引を見るのに... | され、目が覚めたら… | [0.021, -0.113, …] |
[0.008, 0.204, …] |
[-0.077, 0.041, …] |
ONEPIECE |
ONE PIECE | 富、名声、力。かつてこの世の全てを… | 彼の死に際に放った一言は、人々を海へ… | - | [0.134, 0.009, …] |
[-0.052, 0.198, …] |
— |
NoSQLのスキーマレス性(アイテムごとに属性を自由に持てる)を活かした良いアイデア来たー!...と思ったのですがベクトルデータは構造上インデックス定義の VectorAttribute は「単数のオブジェクト」そのため[embedding1,embedding2,embedding3...]のような持ち方はできません。属性としては保持できるがvectorDBに射映されるのは1つということになります。そのためこの方式Aは実運用上使えないという結論になりました...トホホ...
厳密には5個までemmbeddingは可能ですがindexが変わってしまい横断検索できなくなるため不可能と言う位置づけにしております
方式B:縦持ち作戦
チャンクの数だけレコードを作成する方式
| PK | SK | title |
text |
embedding |
|---|---|---|---|---|
CONAN |
001 |
名探偵コナン | 俺は高校生探偵... | [0.021, -0.113, …] |
CONAN |
002 |
名探偵コナン | 。取引を見るのに... | [0.008, 0.204, …] |
CONAN |
003 |
名探偵コナン | され、目が覚めたら… | [-0.077, 0.041, …] |
ONEPIECE |
001 |
ONE PIECE | 富、名声、力... | [0.134, 0.009, …] |
ん〜しっくりきたような感じもしますがちょっとむず痒さがあります。それは基本データ(タイトル等)の情報をそれぞれのレコードに保持する冗長な所です。
方式C:親子管理作戦
親レコードを基本データとして扱い、子レコードをチャンクの数だけ作成する方式
| PK | SK | itemType |
title |
text |
embedding |
|---|---|---|---|---|---|
CONAN |
DOC |
PARENT |
名探偵コナン | — | — |
CONAN |
CHUNK#001 |
CHILD |
— | 俺は高校生探偵... | [0.021, -0.113, …] |
CONAN |
CHUNK#002 |
CHILD |
— | 。取引を見るのに夢中に... | [0.008, 0.204, …] |
CONAN |
CHUNK#003 |
CHILD |
— | され、目が覚めたら… | [-0.077, 0.041, …] |
ONEPIECE |
META |
PARENT |
ONE PIECE | — | — |
ONEPIECE |
CHUNK#001 |
CHILD |
— | 富、名声、力... | [0.134, 0.009, …] |
データの役割は明確に分かれますが、親子構造を意識しておく必要があります。
実装サンプル
aws dynamodb create-table \
--table-name Narrations \
--attribute-definitions AttributeName=DocId,AttributeType=S \
AttributeName=SortKey,AttributeType=S \
--key-schema AttributeName=DocId,KeyType=HASH \
AttributeName=SortKey,KeyType=RANGE \
--billing-mode PAY_PER_REQUEST \
--region ap-northeast-1 \
--vector-indexes \
"[
{
\"IndexName\": \"NarrationChunkIndex\",
\"VectorAttribute\": {\"AttributeName\": \"Embedding\"},
\"Projection\": {\"ProjectionType\": \"INCLUDE\", \"NonKeyAttributes\": [\"ChunkText\"]},
\"Dimensions\": 1024,
\"DistanceFunction\": \"COSINE\"
}
]"
・Projectionで射影対象を絞っています(INCLUDE指定)ここをALLにすると不要な項目まで抽出してコストを圧迫します。
・ベースデータのパーティションキーはDocID(アニメタイトル)を指定していますが、パーディションを横断したベクトル検索を行うため、--vector-indexesのSearchSchemaElementTypeは指定していおりません。
データ構造(データ投入後)

親レコードはemmbeddingされないためベクトルデータ化されません
検索してみる 質問:「謎を解決してほしい」
aws dynamodb search-vectors \
--table-name Narrations \
--index-name NarrationChunkIndex \
--search-vector file://data/query-vector.json \
--top-k 5 \
--return-consumed-capacity TOTAL \
--region ap-northeast-1
| 順位 | スコア | DocId | SortKey | ChunkText |
|---|---|---|---|---|
| 1 | 0.841237 | conan-opening |
CHUNK#0004 |
情報をつかむために、父親が探偵をやっている蘭の家に転がり込んだ。謎に包まれた黒ずくめの組織…。わかっているのは、そのコードネーム |
| 2 | 0.856026 | conan-opening |
CHUNK#0006 |
活躍が始まった!!たったひとつの真実見抜く見た目は子供、頭脳は大人その名は、名探偵コナン!! |
| 3 | 0.856688 | conan-opening |
CHUNK#0003 |
も危害が及ぶ。阿笠博士の助言で正体を隠すことにした俺は、蘭に名前を聞かれてとっさに、江戸川コナンと名乗り、奴らの情報をつかむため |
| 4 | 0.861662 | precure-detective-intro |
CHUNK#0004 |
に変身‼名探偵プリキュアがみんなの笑顔を推理で守る!「そのナゾ!キュアット解決!」そして、あんなは元の時代に戻ることができるのか |
| 5 | 0.885005 | conan-opening |
CHUNK#0005 |
そのコードネームがお酒にちなんだ名前であることくらいだ…。そんな奴らの正体を暴く |
見事に二人の名探偵![]()
がランクインしていますね。
海賊王はランク外です![]()
様々な観点で方式を見比べてみる
登録
登録ではチャンク数:N件書き出すかN+1(親)書き出すかの違いのため大きくは差はありません。
更新
ドキュメントに関するデータ更新時に、方式Bはドキュメント情報を全チャンクに冗長的に保持しているためチャンク数分のレコードを更新する必要があります。一方、方式C(親子作戦)は親レコードのみ更新すれば良いです。チャンキング対象のデータ更新時はどの方式でも全洗い替えが必要になります。
検索
ベクトル検索を行い、タイトル等の付加情報を含めて応答する場合、方式C(親子作戦)では取得した子レコードから親レコードを引き直す(検索する)必要があります。子レコードにはベクトル対象のテキストしか保持しておらず、ドキュメント情報は全て親に保持している為です。
コスト
方式Bはドキュメントデータに関する更新が発生した際に、全チャンクの更新が発生する(WRU増加)。ベクトル検索においても全項目を射影している場合、その分search-vectorsの取得容量が大きくなるためこの部分では方式Cよりも方式Bの方がコスト高になります。方式Cはベクトル検索後の親レコード引き直しの際にGetItemによるAPIを呼び出すためその分のコストがBに比べて多く発生することになります。
Small-to-Big
方式のデータ保持を考える
Small-to-Big方式とは子チャンクでヒットさせて親チャンクで応答する手法
親チャンクを応答させる方式として、以下のようなパターンが存在します
1.子チャンクを全て結合させる
2.ヒットチャンクの前後チャンクを結合させる
3.親に全文を保持して応答する
1.2に関しては上記分割チャンクの検討に沿ってあとはアプリでどう結合するか?という問題の為、今回は方式3のデータ保持を検討します。
検討した結果、このようなイメージのデータ構造となりました。
| PK | SK | itemType |
title |
text |
embedding |
|---|---|---|---|---|---|
CONAN |
DOC |
DOCUMENT |
名探偵コナン | — | — |
CONAN |
P#001 |
PARENT |
— | 俺は高校生探偵・工藤新一。幼馴染で... | — |
CONAN |
P#001#C#001 |
CHILD |
— | — | [0.021, -0.113, …] |
CONAN |
P#001#C#002 |
CHILD |
— | — | [0.008, 0.204, …] |
CONAN |
P#001#C#003 |
CHILD |
— | — | [-0.077, 0.041, …] |
CONAN |
P#002 |
PARENT |
— | 工藤新一が生きていると... | — |
CONAN |
P#002#C#001 |
CHILD |
— | — | [0.093, -0.008, …] |
CONAN |
P#002#C#002 |
CHILD |
— | — | [0.140, 0.022, …] |
ONEPIECE |
DOC |
DOCUMENT |
ONE PIECE | — | — |
ONEPIECE |
P#001 |
PARENT |
— | 富、名声、力。かつて... | — |
ONEPIECE |
P#001#C#001 |
CHILD |
— | — | [0.201, -0.044, …] |
ONEPIECE |
P#001#C#002 |
CHILD |
— | — | [0.088, 0.155, …] |
ベクトル対象のテキストは親レコードに持たせます。ベクトルデータは子レコードに保持します。
子チャンクはベクトル検索さえできればよくテキストの応答は不要です。つまり投影する項目も最小限で良くなるため、"Projection ProjectionTypeはKEYS_ONLYでよくなります。
ここで表現している親レコードはドキュメントのレコードではなくて、Small-to-Big方式の一定のグループ単位の親レコードです。そのため、データ保持の階層としては3階層となります。
1階層:ベースデータ(ドキュメント情報)非ベクトル化
2階層:親レコード(応答するテキストを保持)非ベクトル化
3階層:子レコード(ベクトル対象データ)ベクトル化
副案としてベースデータの属性として 親レコードのテキストを保持(parent_text1,parent_text2...)することで2層の親子階層とすることが可能です。この方式はライフサイクルの管理が楽なのですが、アイテム上限の400KB超えてしまう可能性を考慮して3階層構成を採用しております。
実装サンプル
aws dynamodb create-table \
--table-name NarrationHierarchy \
--attribute-definitions AttributeName=docId,AttributeType=S \
AttributeName=sk,AttributeType=S \
--key-schema AttributeName=docId,KeyType=HASH \
AttributeName=sk,KeyType=RANGE \
--billing-mode PAY_PER_REQUEST \
--region ap-northeast-1 \
--vector-indexes \
"[
{
\"IndexName\": \"NarrationChunkIndex\",
\"VectorAttribute\": {\"AttributeName\": \"embedding\"},
\"Projection\": {\"ProjectionType\": \"KEYS_ONLY\"},
\"Dimensions\": 1024,
\"DistanceFunction\": \"COSINE\"
}
]"
データ構造
検索してみる 質問:「謎を解決してほしい」
| 順位 | スコア | docId | sk |
|---|---|---|---|
| 1 | 0.851284 | CONAN |
P#003#C#001 |
| 2 | 0.854112 | PRECURE |
P#003#C#002 |
| 3 | 0.859768 | CONAN |
P#003#C#002 |
| 4 | 0.865651 | CONAN |
P#002#C#001 |
| 5 | 0.870203 | PRECURE |
P#002#C#002 |
テキストは子チャンクに保持していないため取得できません。ここから親レコードを引き直して取得します。
親テキスト抽出
aws dynamodb get-item \
--table-name NarrationHierarchy \
--key '{"docId":{"S":"CONAN"},"sk":{"S":"P#003"}}' \
--projection-expression "bodyText" \
--region ap-northeast-1 \
--query 'Item.bodyText.S' --output text
阿笠博士の助言で正体を隠すことにした俺は、蘭に名前を聞かれてとっさに、江戸川コナンと名乗り、奴らの情報をつかむために、父親が探偵をやっている蘭の家に転がり込んだ。謎に包まれた黒ずくめの組織…。
親データが抽出されてますね。
課題 ~ 取得件数上限問題(100件)~
RAG検索ではリランキング方式のようにある程度広めのデータを抽出した後、アプリ側で集約やリランキングを行う方式もあります。しかしsearch-vectorsでは最大100件までしか取得できません。(2026年8月時点)
データ量が増えると100件内で集約やリランキングでは納得のいく回答精度が出ないパターンも考えられます。これは上記Small-to-Big方式にも同じことが言えます。top-kの上位を同一の親を持つ子チャンクで占領![]()
![]()
されると検索領域が絞られてしまい、広く取ってアプリで絞る戦略が取れなくなります。
s3vectorでは最近1万件まで取得できるようになったためdynamoDBでもアップデートに期待です。
課題 ~ライフサイクル問題~
今回の構造ではチャンクの件数分だけレコードが増える方式を検討しました。dynamoDBは兄弟/親子でカスケードしないので削除やTTLの項目更新時にはパーティションキーで指定して一緒に更新する必要があります。これを忘れて一部レコードまた親レコードだけ削除した場合は。ベクトルデータだけ残る孤児ベクトルが発生して、削除したはずの情報がひょっこり顔
を出すことになります。
せっかく同一レコードで基本データとベクトルデータのライフサイクルを共同で管理できるぞぉー!と喜んでいたのですがチャンク分割で早速離れ離れになってしまうのは悲しいところです。とはいえ同じdynamoDB内なのでまだ制御はしやすい方ですね。
締め
チャンク分割のデータ構造を考えてみましたが、小規模でドキュメント情報が頻繁に更新されないデータならチャンク単位でレコードを保持して管理し、中規模・頻繁に更新されるデータは親子形式で管理するのが良いという結論に至りました。また、Small-to-Bigは3層構造にすることで柔軟な対応が使えるのですが、やはりtop-k上限が100件というところが痛く、分割チャンクデータの規模が大きくなってくると厳しいのでは...という見解に至りました。今後のアップデートにも注目したいところです。
