【langchain】textを指定長さにマージしつつ、元のtextに付与された属性情報を保持する

# 概要
「ある文章を分割したsubsubtextがある。このsubsubtextは短すぎるので、より長いsubtextのリストに変換したい。このsubtextはsubsubtextを結合した文字列をCharacterTextSplitterで分割したものに相当して欲しい。同時に各subtextがどのsubsubtextから構成されるのかの情報を保持したい」という要求を満たすための実装方法を検討しました。

とりあえず結論のコードだけ示しておきます。

```Python
from langchain.text_splitter import CharacterTextSplitter
from typing import List

def merge_splits(subsubtexts: List[dict], chunk_size, chunk_overlap, *, text_key="text"):
    input_texts = []
    digit = len(str(len(subsubtexts)))
    for i, subsubtext in enumerate(subsubtexts):
        text = subsubtext[text_key]
        new_text = text[:-digit] + str(i).zfill(digit)
        # 改行の削除
        new_text = " ".join(new_text.splitlines())
        input_texts.append(new_text)

    separator = "\n"
    splitter = CharacterTextSplitter(chunk_size=chunk_size, chunk_overlap = chunk_overlap, separator=separator, )

    subtexts = splitter.split_text(separator.join(input_texts))

    subtexts_with_param = []
    for subtext in subtexts:
        obj = []
        lines = subtext.splitlines()
        for line in lines:
            id = int(line[-digit:])
            subsubtext = subsubtexts[id]
            obj.append(subsubtext)
        subtexts_with_param.append(obj)

    return subtexts_with_param
```
# 背景
textの部分文字列であるsubtext、subtextの部分文字列であるsubsubtextがあるとします。
最初、subsubtextが得られており、subsubtextは短すぎるため、もう少し長いsubtextにしたいとします。自前で実装してもよいのですが、subtext同士をオーバーラップさせるなど、気の利いたことをやろうと思うと、実装が面倒になります。

どのくらい面倒かというと、例えば、langchainのCharacterTextSplitterでは、chunk_size（subtextの長さ）、chunk_overlap（subtextと同士のオーバーラップ）を与えると、以下の処理が行われます。

- 入力strをseparatorで分割する。分割されたものをsubsubtextとする
- subsubtextを先頭からchunk_sizeを超えないギリギリまで結合しsubtextを得る。
  - もし先頭のsubsubtextがchunk_sizeより大きい場合は、そのsubsubtextをsubtextとする
- 2つめのsubtextを以下の手順で得る
  - 最後に結合されたsubsubtextの次のsubsubtextをAとする。Aの長さがchunk_sizeより大きい場合、そのsubsubtextをsubtextとする
  - Aの長さがchunk_sizeより小さい場合、Aより前のsubsubtextをchunk_overlapを超えない、かつ、Aとの合計値がchunk_sizeを超えないギリギリの長さまで結合し、overlap文字列を作る。Aの直前のsubsubtextがその条件を満たさない場合は、overlap文字列は空文字となる。
- Aより後ろのsubsubtextをchunk_sizeを超えないギリギリまで結合し、overlap文字列と合わせた文字列を2つめのsubtextとする。
- 3つめ以降のsubtextも2つ目と同様の手順で得る。

自前実装できなくはないですが、思ったより複雑なことをやっているという印象はあると思います。そこで、CharacterTextSplitterをそのまま使いたくなってきます。

CharacterTextSplitterの難点は、strを入力とするため、出力の各subtextが、どのsubsubtextの結合によって作られたかを復元しにくくなることです。このため、subsubtextに付与された属性情報などがsubtextになった時点で失われてしまいます。例えば、複数人対話の発話ログがsubsubtextであったとして、各subtextに含まれる発話者が誰だったのかの情報を復元したいときなどに困ります。

以上から、subtextをCharacterTextSplitterの力をかりて作りつつ、各subtextが含むsubsubtextも復元可能な状態にする、ということを試みます。

# 環境
langchainのバージョンは0.0.329です。

# 入出力
## 入力
textというキーをもつ辞書（subsubtext）のリスト、chunk_size、chunk_overlapを入力とします。
## 出力
subsubtextのtext要素を結合してCharacterTextSplitterで分割した時の各subtextがもともとどのsubsubtextだったのかの情報を出力とします。例えばsubsubtextのidのリストのリストなどです。
# 実装

subsubtextのリストを以下とします(ChatGPTにより適当に生成）

```Python
subsubtexts = [
    {"text": "最近どう?", "speaker": "A"},
    {"text": "忙しいけど、充実してるよ。", "speaker": "B"},
    {"text": "私は新しいプロジェクトを始めたんだ。", "speaker": "C"},
    {"text": "おー、いいね。何のプロジェクト?", "speaker": "A"},
    {"text": "環境保護に関するものだよ。", "speaker": "C"},
    {"text": "素晴らしいことだね。手伝えることがあったら言ってくれ！", "speaker": "B"},
    {"text": "ありがとう、そうするよ。", "speaker": "C"},
    {"text": "Aも最近何か新しいこと始めた?", "speaker": "B"},
    {"text": "実は写真を撮り始めたんだ。", "speaker": "A"},
    {"text": "写真か。いい趣味だね！", "speaker": "C"},
    {"text": "何をメインで撮ってるの?", "speaker": "B"},
    {"text": "主に風景写真を撮ってるよ。自然の美しさを伝えたいんだ。", "speaker": "A"},
    {"text": "Aも環境保護に貢献してるじゃん！", "speaker": "C"},
    {"text": "そう言ってもらえると嬉しいな。", "speaker": "A"},
    {"text": "僕も何か始めようかな。", "speaker": "B"},
    {"text": "何に興味があるの？", "speaker": "C"},
    {"text": "料理かな。健康的な食生活を送りたいし。", "speaker": "B"},
    {"text": "素敵だね。お互い新しいことを始めて、成長していこうよ！", "speaker": "A"},
    {"text": "そうだね、刺激し合えるのは良いことだ。", "speaker": "C"},
    {"text": "じゃあ今度、みんなで何かを共有する時間を作ろうよ。", "speaker": "B"}
]
```

subsubtextsの各textを取り出して末尾をidを桁揃えした文字列に置き換えます。また後でseparatorとして使う文字を別の文字に変換しておきます。今回は改行をseparatorとするため、input_textに含まれる改行をスペースに変換します。

```Python
input_texts = []
digit = len(str(len(subsubtexts)))
for i, subsubtext in enumerate(subsubtexts):
  text = subsubtext["text"]
  new_text = text[:-digit] + str(i).zfill(digit)
  # 改行の削除
  new_text = " ".join(new_text.splitlines())
  input_texts.append(new_text)
```

input_textsを出力すると以下のような感じです。

```Python
for input_text in input_texts[:5]:
  print(input_text)
```

```
最近ど00
忙しいけど、充実してる01
私は新しいプロジェクトを始めたん02
おー、いいね。何のプロジェク03
環境保護に関するものだ04
```

改行コードををseparatorとし、input_textsをseparatorで結合した文字列をCharacterTextSplitterに与えます。

```
from langchain.text_splitter import CharacterTextSplitter
import uuid
chunk_size = 30
chunk_overlap = 7
separator = "\n"
splitter = CharacterTextSplitter(chunk_size=chunk_size, chunk_overlap = chunk_overlap, separator=separator, )

subtexts = splitter.split_text(separator.join(input_texts))
```

subtextsの中身を確認します。

```Python
for subtext in subtexts[:5]:
    print(subtext)
    print("---")
```

```
最近ど00
忙しいけど、充実してる01
---
私は新しいプロジェクトを始めたん02
---
おー、いいね。何のプロジェク03
環境保護に関するものだ04
---
素晴らしいことだね。手伝えることがあったら言ってく05
---
ありがとう、そうする06
Aも最近何か新しいこと始め07
---
```

subtextの末尾のidから元のsubsubtextを復元します。

```Python
subtexts_with_param = []
for subtext in subtexts:
    obj = []
    lines = subtext.splitlines()
    for line in lines:
        id = int(line[-digit:])
        subsubtext = subsubtexts[id]
        obj.append(subsubtext)
    subtexts_with_param.append(obj)
```

中身を確認します。

```Python
for subsubtext in subtexts_with_param[:5]:
    print(subsubtext)
    print("---")
```

```
[{'text': '最近どう?', 'speaker': 'A'}, {'text': '忙しいけど、充実してるよ。', 'speaker': 'B'}]
---
[{'text': '私は新しいプロジェクトを始めたんだ。', 'speaker': 'C'}]
---
[{'text': 'おー、いいね。何のプロジェクト?', 'speaker': 'A'}, {'text': '環境保護に関するものだよ。', 'speaker': 'C'}]
---
[{'text': '素晴らしいことだね。手伝えることがあったら言ってくれ！', 'speaker': 'B'}]
---
[{'text': 'ありがとう、そうするよ。', 'speaker': 'C'}, {'text': 'Aも最近何か新しいこと始めた?', 'speaker': 'B'}]
---
```

無事復元できました。

ここまでの処理を関数にしたものが概要で示したコードです。

# 制限事項
subsubtextのtextに結合しても失われないIDを付与しつつ、文字数を変えない、というのがポイントなので、IDの付与によって文字数が変わってしまうケースでは、splitの結果が変わり得ます。
具体的には、最大IDの桁数よりも短い文字数のsubsubtextがあると微妙に結果が変わります。例えば1文字のsubsubtextに"0005"みたいな4文字のIDを付与した場合などです。chunk_sizeやchunk_overlapがある程度大きければ無視できる誤差ですが、そうでない場合には注意が必要です。
IDよりも短い文字列に対しては別途同文字数の専用IDを付与するという方法もあるのですが、制限された文字数で作成可能なIDの種類より、短い文字列の数が多い場合にはいずれにせよ完璧な対応とはならないので、実装の手間の割に安心感が低いです。よって、誤差と割り切るのが良いと思います。

本実装は、CharacterTextSplitterの使用を前提としており、他のSplitterへの置き換えが容易かどうかはよくわかりません。トークン数ベースのSplitterに置き換える実装は比較的簡単にできそうです。ただし、IDを付与する前後でトークン数が一定である保証がないため、結果が少し不安定になります。概ね無視できる誤差とは思われます。その他のSplitterで置き換えやすいものはない気がします。

# 本当に自前で実装するよりも楽だったのか？
よくわかりません、が少なくとも自分は自前で書こうとしてめんどくさくなってしまいました。短いsubsubtextが多発した場合などに多少結果が変換する可能性があったとしてもCharacterTextSplitterをなるべく使う方が楽な気はします。