段落ID、章、作品、meta.yaml

本文へ戻るためには、まず本文を戻れる形にしておく必要がある。

当たり前のようだが、これは意外と大事なところである。

小説本文は、ただテキストファイルとして存在しているだけでは、まだ検索基盤にはならない。もちろん、ファイル検索やエディタの検索機能で文字列を探すことはできる。けれど、それだけでは「どの作品の、どの章の、どの段落か」を安定して扱いにくい。

検索botが返すべきものは、単なる一致箇所ではない。

本文への入口である。

そのためには、本文を適切な単位に分け、名前を付け、あとから同じ場所へ戻れるようにしておく必要がある。

このbotでは、小説本文を worldworkchapterparagraph の四段階で扱うことにした。

world は世界観。
work は作品。
chapter は章。
paragraph は段落。

第2章で見たように、worldは世界観を混ぜないための箱であり、workは同じ世界の中で作品を分ける箱である。第3章では、その中に実際の本文をどう入れるかを見ていく。

章ファイルとして置く

本文は、次のような形で置く。

texts/<world_id>/<work_id>/chNN.txt

たとえば、kimihana というworldの中に作品があるなら、その作品用のディレクトリを作り、章ごとに ch01.txtch02.txtch03.txt のようなファイルを置く。

この形にすると、本文の所属がパスだけで分かる。

どの世界の本文か。
どの作品の本文か。
どの章の本文か。

それが、ファイルの置き場所と名前に埋め込まれる。

小説本文の管理方法はいろいろある。ひとつの巨大なテキストファイルに全部入れてもいい。章ごとではなく、話ごとや節ごとに分けてもいい。Markdownでも、プレーンテキストでも、別の形式でも構わない。

ただ、検索botに取り込むためには、どこかの段階で「作品」と「章」を識別できる形にする必要がある。

章ファイル方式は、そのための素直な方法だった。

人間にとっても分かりやすい。
プログラムにとっても扱いやすい。
ファイル名を見れば順番も分かる。

この「人間にもプログラムにも分かる」というのは、個人用の道具ではかなり大切である。

完全に機械向けの形式にしてしまうと、あとから手で直すのがつらくなる。
逆に、人間の見た目だけを優先すると、取り込み処理が複雑になる。

章ごとのテキストファイルは、その中間にある。

空行で段落に分ける

章ファイルを置いたら、次に本文を段落へ分ける。

このbotでは、空行を区切りとして段落を分割する。
小説本文は、文ではなく段落で扱うことにした。

文単位では細かすぎる。
章単位では大きすぎる。

一文だけだと、検索結果としては文脈が足りないことが多い。誰が何をしているのか、前後の流れが分からない。特に小説では、主語を省いたり、会話文が続いたり、感情や状況が前の段落から続いていたりする。

一方で、章まるごとを一単位にすると、大きすぎる。
検索結果として返すには長い。
RAGの文脈として渡すにも重い。
引用として確認するにも、目的の箇所まで遠い。

段落は、その中間にある。

ひとつの描写、ひとまとまりの会話、ひとつの動き。
小説の段落は、意味のまとまりとして機能していることが多い。

もちろん、段落が常に最適とは限らない。短い段落もあれば、長い段落もある。会話文だけの段落もあるし、説明が詰まった段落もある。作品によって癖も違う。

それでも、まず本文を扱う単位としては、段落がちょうどよかった。

検索結果として表示できる。
前後の段落を足せば文脈も補える。
出典IDとしても扱いやすい。

だから、このbotでは本文を段落ごとに取り込む。

段落IDを付ける

段落に分けたら、次にIDを付ける。

形式は、こうである。

ch17:p005

ch17 は第17章。
p005 は、その章の5番目の段落。

このIDがあることで、本文の場所を短く表せる。

たとえば、検索結果に次のようなIDが付いていれば、どの章のどの段落なのかが分かる。

[kimihana ch17:p005]

これは、単なる飾りではない。

検索結果の末尾にIDがあると、あとから本文ファイルへ戻れる。
RAG回答の出典としてIDが出ていれば、AIがどの段落を根拠にしたか確認できる。
初登場や最終登場を調べるときにも、本文上の位置を示せる。

IDがない検索結果は、その場では読めても、あとから追いかけにくい。

たとえば、本文抜粋だけが返ってきたとする。
その文章がどこにあったかを、もう一度探す必要がある。
作品名だけでは足りない。
章名だけでも、章が長ければまだ探すことになる。

一方で、段落IDがあれば、戻る場所が決まる。

検索botの答えは、そこで完結しなくていい。
むしろ、完結しない方がいい。

答えを見て、必要なら本文へ戻る。
前後を読む。
本当にそう書いてあるか確認する。
他の描写と照らし合わせる。

そのための足場として、段落IDがある。

作品情報を補う

本文ファイルだけでは、表示に必要な情報が足りないことがある。

work_id は機械的には便利だが、人間にとっては作品タイトルの方が分かりやすい。
ch17 という番号だけより、章題が出た方が思い出しやすい。

そこで、必要に応じて meta.yaml を置く。

texts/<world_id>/<work_id>/meta.yaml

ここに、作品タイトルや章題、作品の順序などを書いておく。

検索botの内部では、IDや番号で扱う。
けれど、Discordに返すときには、人間が読みやすい形にしたい。

たとえば、検索結果に作品タイトルと章題が出れば、本文抜粋を読む前に「あのあたりか」と見当を付けられる。シリーズが長いほど、この手がかりは効いてくる。

本文への入口として返すなら、機械にとって一意であるだけでは足りない。
人間が見て、思い出せることも大事である。

meta.yaml は、そのための補助情報である。

ルビを剥がす

小説本文には、ルビが含まれることがある。

青空文庫形式なら、たとえば次のように書く。

人攫い蜂《ひとさらいばち》

ルビは、読者に読みを伝えるためには便利である。
けれど、検索や埋め込みのためには、少し扱いにくい。

本文としては「人攫い蜂」を検索したい。
しかし、テキスト上にルビ記法が混ざっていると、検索結果の見た目が煩雑になることがある。
埋め込みを作るときにも、読み情報が混ざることで、本文そのものとは少し違うテキストになる。

だから、取り込み時には青空文庫形式のルビを除去し、親文字だけを残す。

ルビを完全に捨てるかどうかは、用途による。

読みを検索したい場合は、ルビも情報として持っていた方がいいかもしれない。
難読名の読みを確認したいなら、むしろルビを残したいこともある。

けれど、このbotの主目的は、本文の表記と文脈を検索することである。
そのため、まずは本文をすっきり扱う方を優先した。

ここでも大事なのは、目的を決めることである。

すべての情報を残そうとすると、設計は複雑になる。
検索したいものが何かを決めれば、処理は少し単純になる。

このbotでは、まず親文字を残す。
ルビは取り込み時に剥がす。

そう決めた。

doc_idで一意にする

段落IDは、人間が見て分かりやすい。

ただし、検索エンジンに入れるときには、もう少し機械向けのIDも必要になる。

同じ ch01:p001 という段落IDは、別の作品にも存在しうる。
第1章の1段落目は、どの作品にもあるからだ。

そのため、実際の主キーとしては、作品IDと段落IDを組み合わせた doc_id を使う。

たとえば、内部的には、

<work_id>__ch17_p005

のように、作品と段落を合わせて一意にする。

人間に見せるIDと、機械が管理するIDは、役割が違う。

人間には、短く読めるIDが向いている。
機械には、衝突しないIDが必要である。

この二つを分けておくと、扱いやすくなる。

表示では ch17:p005 と出す。
内部では doc_id で一意に管理する。

検索結果を見た人間は場所を理解できる。
検索エンジンは文書を正確に取り出せる。

順番を持たせる

検索botでは、本文の順番も重要である。

単に検索に引っかかった段落を返すだけなら、順番はそこまで重要ではないかもしれない。けれど、初登場や最終登場を調べるなら、本文上の順序が必要になる。

ある人物が最初に出てきた段落を知りたい。
ある語が最後に出てきた場面を知りたい。

そのためには、段落が作品全体の中でどの位置にあるかを並べられなければならない。

そこで、章番号と段落番号から順序を作る。
さらに、作品が複数ある場合は、作品順も合わせて全体の順序を作る。

このような順序情報があれば、検索結果を本文順に並べられる。
/first では先頭を取ればいい。
/last では末尾を取ればいい。

つまり、初登場や最終登場は、特別な魔法ではない。

本文を段落単位で取り込み、順番を持たせ、名前で検索し、最初や最後の結果を返す。
その積み重ねでできている。

ここでも、最初の設計が効いてくる。

段落に分けていなければ、細かい位置が分からない。
順番を持たせていなければ、初出や最終出を決められない。
worldやworkがなければ、別作品の登場と混ざる。

検索botの便利な機能は、本文の持たせ方に支えられている。

検索結果を入口にする

ここまでで、本文は検索できる形に近づいた。

世界ごとに分ける。
作品ごとに分ける。
章ごとのテキストファイルとして置く。
空行で段落に分ける。
段落IDを付ける。
作品情報や章題を補う。
ルビを剥がす。
doc_idで一意にする。
順番を持たせる。

こうして初めて、検索botは本文を扱えるようになる。

検索結果として返すのは、本文抜粋、作品名、章、段落IDである。

それは、読者に対する「答え」であると同時に、本文への「入口」でもある。

この考え方は、RAG回答でも変わらない。

AIが自然文で答える場合でも、その答えは本文から切り離されてはいけない。
どの段落を根拠にしたのか。
どの作品の、どの章にあるのか。
あとから確認できるのか。

それがなければ、創作確認の道具としては不安が残る。

小説本文を検索できる形にするとは、単に全文検索エンジンに投げ込むことではない。

本文を、戻れる単位に切ること。
その単位に名前を付けること。
人間が読める形と、機械が扱える形の両方を用意すること。
そして、検索結果を本文への入口として返すこと。

この土台があって、ようやく次の段階へ進める。

つまり、検索である。

第4章では、実際にその本文をどう探すかを見ていく。
キーワード検索だけで足りるのか。
意味で探すにはどうするのか。
そして、なぜ全文検索とベクトル検索の両方を使うことにしたのか。

本文を戻れる形にしたら、次は、そこへどう辿り着くかである。