コーディング規約QAボット開発

プロジェクト概要
- プロジェクト名: コーディング規約QAボット
- 最終成果物:
社内の「コーディング規約」が書かれたドキュメント(PDF形式を想定)の内容について、自然言語で質問すると、AIがドキュメントに基づいて回答してくれるStreamlit製のWebアプリケーション。 - 使用技術:
- 言語: Python
- AI/LLM: LangChain, OpenAI API
- UI: Streamlit
- Vector Store: ChromaDB

st.spinner()
と with
の意味
✅ with st.spinner("PDFを読み込み中"):
extract_text = extract_text_from_pdf(uploaded_file)
-
st.spinner()
は「処理中にスピナーを表示するUI部品」。 -
with
と一緒に使うことで、「この処理が終わるまで表示→終わったら非表示」を自動化。 - JSで言うところの
try { showSpinner(); ... } finally { hideSpinner(); }
に近い。
🔍 学び:Streamlitは一時的なUI状態も
with
を使って明示的に管理する設計思想
st.session_state
を使うのか?
✅ Streamlitの状態管理はなぜ st.session_state.pdf_text = extract_text
- Streamlitは UI操作のたびにスクリプト全体が再実行される。
- 通常の変数(例:
text = ...
)は毎回初期化されるため状態が保持されない。 -
st.session_state
を使うと、セッション単位で状態が保持される。
💡 Laravelの
session(['key' => $value])
、JSのsessionStorage.setItem()
に近いが、保持はあくまでPythonサーバー側
✅ コメントは「自己解説力」を高める武器
# ⭕ 意図:再実行で状態が消えないようPDFテキストをセッション保持
# ⚠️ 問題:通常変数だとボタン押下で毎回リセットされる
# ✅ 解決:セッション変数 st.session_state を使用
- コメントに「**なぜそれを書く必要があるか(意図)」「なければどうなるか(問題)」「どう解決しているか(対策)」」を入れると、コードの背景が明確に。
- Markdown的に記号(⭕⚠️✅)を使うと視認性がUP!
[:500]
の意味
✅ Pythonのスライス構文 extract_text[:500] + "..."
-
[:500]
はextract_text[0:500]
の省略形で、「先頭から500文字だけ取り出す」意味。 - JSで言えば
text.slice(0, 500)
に相当。 - Pythonでは範囲外アクセスでもエラーにならず安全に切り出せる。
🧠 学び:スライスはPythonの安全・直感的な文字列処理の代表例
st.expander()
でも with
が使われる理由
✅ with st.expander("📖 テキストプレビュー"):
st.text_area(...)
-
with
は「このUI部品の中にまとめて表示する要素のスコープ」を作る。 -
st.expander()
は 折りたたみ可能な領域。中に入れるものはすべてwith
ブロックに書くとわかりやすい。
✅ ただし、
st.expander(...).text_area(...)
のようにチェーン形式でも同じ意味
✅ Streamlitの再実行モデルの本質
- Streamlitは「UIイベントが発生するとPythonスクリプト全体を再実行してUIを再構成する」というモデル。
- 「JSのイベントが起きたら描画が変わる」ではなく、「イベント → Python再実行 → 新しいUI状態を構築して送信」という構造。
🧠 ユーザー操作 → Streamlitサーバーに通知 → Python再実行 → Reactで描画
🏁 学びのまとめ
-
with
は UIスコープ・状態制御どちらにも活躍 - Streamlitは「リアクティブUI × 再実行モデル」で成り立っている
- 状態管理は
st.session_state
にまとめ、再実行による初期化を防ぐ - コメントに「意図・問題・解決」を書くことで理解もメンテも楽になる

PythonでLLMアプリをつくる前に押さえたい!
トークン数・チャンク分割・Streamlitキャッシュの基礎知識まとめ
✍️ はじめに
LLM(大規模言語モデル)を使ったアプリを作る際、避けて通れない概念があります。それが以下の3つ:
- トークン(token)
- チャンク分割(text chunking)
- キャッシュ(Streamlit)
これらを「なんとなく」で使い始めてしまうと、後でトラブルになりがちです。本記事では、PythonとLangChainでLLMアプリを構築する前提として、必ず理解しておきたい基礎知識をやさしく解説します。
🧠 トークンとは何か?なぜカウントするのか?
LLMが文章を理解・処理するときの最小単位、それが トークン(token) です。
トークン数を数える理由は主に3つ。
理由 | 説明 |
---|---|
入力制限のため | APIごとに「トークン数の上限」がある(例:gpt-4oは128k) |
コスト管理のため | OpenAIなどのAPIは「トークン数ベース」で課金される |
精密な分割のため | 特に日本語やコードは「文字数≠トークン数」なので、より正確な基準として用いる |
たとえば、tiktoken
ライブラリを使えば、モデルごとのトークン数を簡単に測れます:
import tiktoken
encoding = tiktoken.encoding_for_model("gpt-4o-mini")
num_tokens = len(encoding.encode("これはテストです。"))
✂️ チャンク分割とは?chunk_sizeは文字数 or トークン数?
LangChainなどで使うチャンク分割関数は、テキストを一定サイズで分割します。そのサイズの単位は**length_function
の設定次第**で変わります。
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=200,
length_function=len, # ← ここが「文字数ベース」
)
この例では len
を指定しているので、**chunk_sizeは「文字数」**です。
🔁 もしトークン数で分割したければ、こう変えます:
length_function=lambda x: len(tiktoken.encoding_for_model("gpt-4o").encode(x))
@st.cache_data
vs @st.cache_resource
の違い
🧊 Streamlitでは、処理結果を再利用したいときに「キャッシュ」を使います。2種類のキャッシュの違いを整理しておきましょう。
デコレータ | いつ使う? | 何をキャッシュ? |
---|---|---|
@st.cache_data |
CSV読み込みやテキスト分割など | 計算結果(DataFrame, リストなど) |
@st.cache_resource |
EmbeddingモデルやAPI接続 | 道具・リソース(OpenAIEmbeddings など) |
🔑 ポイント:resource
は「一度だけ作って、ずっと使い回す」ための道具キャッシュです!
👣 embeddingの理解は後回しでOK? → YES!
埋め込み(embedding)とは、テキストを「ベクトル(数値のかたまり)」に変換して、意味的な近さを比較できるようにする技術です。
💡 今の段階では:
- 「embedding = 類似検索やチャット履歴フィルタに使うもの」
- 「ベクトルで意味を数値化してる」
これだけ把握しておけば十分。
写経しながら、あとで振り返って理解するスタイルでOKです!
✅ まとめ
LLMアプリを作る前に最低限おさえておきたいのは以下の3点:
- トークン単位で処理することの重要性
- チャンク分割は「文字数」か「トークン数」かを意識する
- Streamlitでは「データ用」と「道具用」でキャッシュを分ける
🔗 参考リンク

🧠 RAG(Retrieval-Augmented Generation)解説
✅ RAGとは?
**RAG(リトリーバル強化生成)**は、検索と生成を組み合わせたLLM活用構成です。
LLM単体では回答できない内容に対して、外部知識(文書)を検索して補強することで、
より正確で文脈に沿った回答を生成できます。
🔄 全体構成:2フェーズ構成
📘 フェーズ1:事前準備(ドキュメントのベクトル化と登録)
1. ドキュメントを読み込む
2. 意味のまとまり(チャンク)単位に分割
3. 各チャンクをベクトル化(埋め込みモデル使用)
4. ベクトル+メタデータをベクトルストアに保存(例:ChromaDB)
- ✅ チャンク化:文脈を保持しつつ検索しやすくする
- ✅ Embeddingモデル:例)OpenAI
text-embedding-ada-002
- ✅ ベクトルストア:例)Chroma、FAISS、Pinecone
💬 フェーズ2:実行時処理(ユーザーの質問から回答生成)
5. ユーザーが自然言語で質問
6. 質問をベクトル化(埋め込みモデル)
7. ベクトルストアから意味的に近いチャンクを検索
8. 検索結果+質問をプロンプトにしてLLMに渡す
9. LLMが文脈を考慮して回答を生成
- ✅ 質問も同じ埋め込みモデルでベクトル化(同一空間が前提)
- ✅
.similarity_search()
で類似文書を取得 - ✅ GPT-3.5 / GPT-4などのLLMで自然な文章を生成
🧠 重要な概念整理
用語 | 意味 |
---|---|
Embedding | テキストを「意味ベースのベクトル」に変換する操作 |
ベクトルストア | ベクトル化された文書を保存・検索するDB(例:Chroma) |
チャンク | ドキュメントを意味のまとまりで分割した単位(数文〜段落) |
LLM | 回答を生成するAIモデル(GPTなど) |
RAG | 検索(Retrieval)+生成(Generation)の構成全体 |
👩🏫 設計ポイント
- EmbeddingとLLMは別物(検索は軽量モデル、生成はLLM)
- チャンクサイズ(例:500文字)は検索精度とプロンプトサイズに直結
- 検索結果の渡し方(プロンプト設計)は回答品質を大きく左右
- 質問と文書で同じEmbeddingモデルを使うことが必須
📚 関連技術スタック
機能 | 技術候補 |
---|---|
埋め込み | OpenAI Embedding, HuggingFace Transformers |
ベクトルストア | Chroma, FAISS, Pinecone |
LLM | GPT-3.5, GPT-4, Claude, Mistral |
統合フレームワーク | LangChain, LlamaIndex |
🌟まとめ
RAGとは、
「検索で情報を取り出し、LLMで文章を生成する」構成
であり、信頼性と柔軟性を両立させた現代LLMアプリの王道アーキテクチャです。

ChromaDB ベクトルストアリセット時の「readonly database」エラー対策まとめ
開発中に遭遇した、ベクトルストア再構築時の
attempt to write a readonly database
エラーを解消するための対策を、古いコード⇔修正後コードで比較しながら解説します。
🔍 問題の再現
古い実装では、既存ディレクトリを丸ごと消してから再生成していました。
しかし Streamlit セッションや ChromaDB クライアントが開いたままの SQLite ファイルを削除しようとすると、
OS レベルで「読み取り専用データベース」と誤認され、再構築に失敗してしまいます。
def create_vector_store(text_chunks, embeddings, persist_directory="./vectorstore"):
# 既存フォルダを無条件で削除 → ロック中のDBファイルが残る
if os.path.exists(persist_directory):
shutil.rmtree(persist_directory)
vs = Chroma.from_texts(
texts=text_chunks,
embedding=embeddings,
persist_directory=persist_directory
)
vs.persist()
return vs
🛠️ 対策の全体像
-
設定レイヤー(ChromaDB)
-
allow_reset=True
を指定して、クライアント内の安全なリセット機能を有効化
-
-
クライアントレイヤー
-
.reset()
で「コレクション丸ごと初期化 + ハンドル閉鎖」
-
-
ランタイムレイヤー
-
del vs_old
+gc.collect()
で Python/C拡張の参照を完全に断ち切り
-
-
ファイルシステムレイヤー
-
cleanup_dirs()
で「現行 UUID 以外」のフォルダだけ削除
-
⚖️ コード比較
Settings
の追加
1) 設定レイヤー:-from langchain.vectorstores import Chroma
+from chromadb.config import Settings
+from langchain_community.vectorstores import Chroma
CHROMA_SETTINGS = Settings(
allow_reset=True, # ← 開発用リセット機能を許可
is_persistent=True,
persist_directory=str(PERSIST_DIR),
anonymized_telemetry=False,
)
2) 再構築ロジック:安全なリセット+差分削除
def rebuild_vectorstore(chunks, embeds):
- # 丸ごと削除してから生成 → readonly error
- if os.path.exists(str(PERSIST_DIR)):
- shutil.rmtree(str(PERSIST_DIR))
+ # ① 既存インスタンスを pop して取り出し
+ vs_old = st.session_state.pop("vectorstore", None)
+ if vs_old:
+ # ② クライアント内部リセットで DB をクリーンに初期化
+ vs_old._client.reset()
+ # ③ Python/C側リソースを解放
+ del vs_old
+ gc.collect()
+ # ④ 新コレクションを client_settings 経由で生成
vs_new = Chroma.from_texts(
texts=chunks,
embedding=embeds,
- persist_directory=str(PERSIST_DIR)
+ client_settings=CHROMA_SETTINGS
)
vs_new.persist()
st.session_state.vectorstore = vs_new
+ # ⑤ 不要フォルダを個別に削除
current_uuid = vs_new._collection.id
cleanup_dirs(current_uuid)
return vs_new
cleanup_dirs()
3) 差分削除ユーティリティ:def cleanup_dirs(keep_uuid: str | None):
if not PERSIST_DIR.exists():
return
for p in PERSIST_DIR.iterdir():
# 現行コレクション以外を物理削除
if p.is_dir() and p.name != keep_uuid:
shutil.rmtree(p)
🎯 各対策の役割(レイヤー別まとめ)
対策 | レイヤー | 対象 | 狙い |
---|---|---|---|
allow_reset=True |
ChromaDB 設定 | クライアント内部 |
.reset() を安全に実行可能に |
.reset() |
ChromaDB クライアント | SQLite+Parquet データベース | コレクション丸ごと初期化+ファイルハンドルを閉じる |
del ... + gc.collect() |
Python ランタイム | メモリ/C 拡張のリソース | 参照を断ち切り、OS のファイルロックを解除 |
cleanup_dirs() |
ファイルシステム | vectorstore/UUID フォルダ群 | 古いフォルダのみを削除し、容量肥大化を防止 |
✨ まとめ
- readonly database エラー は、DB ファイルを開いたまま強制削除しようとすることで発生
- 「設定 → クライアント操作 → ランタイム解放 → 差分削除」の4ステップ で確実にリセット

vs_new._collection
を徹底解説
🗂️ChromaDB のコレクションとは?はじめに
LangChain と ChromaDB を組み合わせたベクトルストア構築で、
vs_new._collection.id
を使って古いフォルダを削除する場面があります。
current_uuid = vs_new._collection.id
ここで登場する ._collection
は一体何を指しているのか?
本記事では ChromaDB の「コレクション」 という概念と、
vs_new._collection
の正体・データ型・使いどころを解説します。
コレクション(Collection)とは?
- コレクション は ChromaDB における「ベクトル+メタデータ」をまとめた 保存単位
- 複数のドキュメント(テキストチャンク)を一つの コレクション に登録し、
類似検索やフィルタリングを行う際の対象グループとなります
コレクションの役割
- データ登録(Insert)
- 類似検索(Query)
- メタデータ管理
- 永続化/読み込み
これらをまとめて扱うのが「コレクション」です。
vs_new._collection
の正体
vs_new
は LangChain のラッパーである以下のインスタンスです。
from langchain_community.vectorstores import Chroma
vs_new = Chroma.from_texts(
texts=chunks,
embedding=embeds,
client_settings=CHROMA_SETTINGS,
)
-
._collection
は、その内部で実際の ChromaDB クライアントが保持する
「コレクションオブジェクト」を参照する属性です - データ型は ChromaDB 本体の実装によりますが、Local モードでは例えば以下のようなクラスです:
>>> type(vs_new._collection)
<class 'chromadb.db.local.LocalCollection'>
-
このオブジェクトが保持する主な属性・メソッド:
-
id
:UUID 形式の一意識別子 -
add()
/get_nearest_neighbors()
などの CRUD API - 永続化先パス(
persist_directory/UUID
)の管理
-
実際の利用例
# コレクションの登録ディレクトリ確認
collection = vs_new._collection
print("コレクションID:", collection.id)
# → vectorstore/<collection.id>/ 以下に SQLite/Parquet が保存される
# 類似検索はラッパー経由で実行
results = vs_new.similarity_search_with_score("検索クエリ", k=5)
-
UUID を使うことで、複数コレクションを同一ディレクトリ下に共存させたり、
古いコレクションのみを削除したりする運用が可能になります。
まとめ
-
vs_new._collection
は ChromaDB が管理するコレクションオブジェクト - コレクションはベクトルとメタデータの「グループ」であり、
登録・検索・永続化の単位となる -
._collection.id
を活用して、UUIDごとにフォルダを管理・掃除するのがベストプラクティス

【ChromaDB】ベクトルストアの新規作成時と読み込み時の引数の違いを解説
Streamlit と LangChain + ChromaDB を使ったベクトルストア構築では、
「新規に作る」 場合と 「既存のものを読み込む」 場合で呼び出すメソッド・引数が異なります。
本記事ではそれぞれの違いと意図をまとめます。
rebuild_vectorstore
)
1. 新規作成(vs_new = Chroma.from_texts(
texts=chunks, # 👉 チャンク化したテキストリスト
embedding=embeds, # 👉 OpenAIEmbeddings インスタンス
client_settings=CHROMA_SETTING # 👉 永続化設定付き Settings オブジェクト
)
vs_new.persist()
-
Chroma.from_texts()
- テキスト → ベクトル化 → ストア登録 までを一気に行う ファクトリメソッド
-
texts
:埋め込み対象の文字列リスト -
embedding
:埋め込み生成を担当するインスタンス -
client_settings
:ディスク永続化や reset 許可などを持つ設定オブジェクト
-
.persist()
でメタデータ(SQLite)+ベクトル(Parquet)を実際に書き出します。
load_vectorstore
)
2. 読み込み(vs = Chroma(
persist_directory=str(PERSIST_DIR), # 👉 永続化ディレクトリ
embedding_function=embeds, # 👉 埋め込み生成を行うコールバック
client_settings=CHROMA_SETTING # 👉 永続化設定付き Settings オブジェクト
)
-
Chroma(...)
(コンストラクタ)- 既存ディレクトリ からコレクションを再アタッチするための呼び出し
-
persist_directory
:すでに書き出されたフォルダを指すパス -
embedding_function
:新規登録時や検索時に使う埋め込み関数 -
client_settings
:新規作成時と共通の設定
3. なぜ引数が異なるのか?
Chroma.from_texts() |
Chroma(...) |
|
---|---|---|
用途 | 新しいコレクションを作ってデータを登録する | 既存コレクションを読み込んで検索などに使う |
必要な情報 | ・テキスト本体(texts )・埋め込みインスタンス |
・永続ディレクトリ ・埋め込み関数 |
内部処理イメージ | 1. テキストを埋め込み→2. コレクション作成→3. 永続化 | 1. ディレクトリを開く→2. メタデータ読み込み |
-
from_texts
は「テキストを持っていないと始まらない」ため、texts
引数が必須。 -
コンストラクタ は「既にディスクにあるもの」を扱うため、
persist_directory
を指定して再利用します。
まとめ
-
新規作成:
Chroma.from_texts(texts, embedding, client_settings)
+.persist()
-
読み込み:
Chroma(persist_directory, embedding_function, client_settings)
- 両者の違いは 「テキストを渡すか」「ディレクトリを渡すか」 に集約されます。

StreamlitでPDFからテキストを効率的に抽出・キャッシュする方法
はじめに
Webアプリや機械学習パイプラインでPDFのテキストを扱う際、毎回すべてのページを読み込むと処理時間やメモリ使用量が増大します。本記事では、Streamlitのキャッシュ機能とPyPDF2を組み合わせ、必要なときだけPDFをパースしてテキストを取得する実装パターンをご紹介します。
@st.cache_data
キャッシュ機能の活用:-
目的:一度処理した結果を再利用し、同じPDFファイルに対する重複処理を防ぐ
-
書き方:関数定義の前にデコレータを付与
@st.cache_data def 関数(...): ...
-
メリット:同じ引数で呼び出された場合、内部処理をスキップしてキャッシュ結果を返すため高速化が可能
PyPDF2.PdfReader
PDF読み込み:-
PyPDF2.PdfReader(pdf_file)
でPDFをパース -
.pages
プロパティからページオブジェクトのリストを取得 - 各ページは
.extract_text()
でテキストを取り出せるが、失敗時はNone
になる可能性あり
ジェネレータ式による遅延評価
-
構文
(page.extract_text() or "" for page in reader.pages)
-
特徴:
- 全ページ分のテキストを一度にメモリに保持せず、
next()
呼び出し時に都度生成 - 大量のページを扱う場合にメモリ効率が向上
- 全ページ分のテキストを一度にメモリに保持せず、
ページ結合と返却
-
"\n".join(...)
でジェネレータが生成するテキストを改行区切りで連結 - 1つの関数呼び出しで、すべてのページのテキストをまとめた文字列を取得
@st.cache_data
def extract_text_from_pdf(pdf_file: Union[str, bytes]) -> str:
reader = PyPDF2.PdfReader(pdf_file)
texts = (page.extract_text() or "" for page in reader.pages)
return "\n".join(texts)
他言語/フレームワークでの対比
-
Laravel/PHP
- 同様の遅延評価は
LazyCollection::make()
やcursor()
+yield
で実現 - キャッシュは
Cache::remember()
- 同様の遅延評価は
-
JavaScript/Node.js
- PDF解析:
pdf-parse
モジュール - キャッシュ:
node-cache
やブラウザのlocalStorage
- PDF解析:
よくある疑問
-
BytesIO
を渡してもキャッシュされる?
内部データが同一ならハッシュで検知されますが、不安定な場合は明示的にファイルパス文字列をキーにする方法も有効です。 -
一度キャッシュした結果をクリアするには?
Streamlitのサイドバーに表示される「キャッシュをクリア」ボタンか、st.cache_data.clear()
を利用します。 -
ページ区切りをわかりやすくしたい場合は?
改行の代わりに"\n\n--- ページ区切り ---\n\n"
などのカスタム文字列を使いましょう。
参考リンク
- Streamlit公式:キャッシュ機能入門
https://docs.streamlit.io/library/advanced-features/caching#cache_data - PyPDF2チュートリアル:PDF読み込みとテキスト抽出
https://pypdf2.readthedocs.io/en/latest/user/cookbook.html#reading-and-writing-pdf-files - Python公式:ジェネレータ式リファレンス
https://docs.python.org/3/reference/expressions.html#generator-expressions - Laravelドキュメント:Lazy Collections
https://laravel.com/docs/10.x/collections#lazy-collections - MDN:JavaScriptジェネレータとイテレータ
https://developer.mozilla.org/ja/docs/Web/JavaScript/Guide/Iterators_and_Generators

LangChainのRecursiveCharacterTextSplitter初心者向けガイド:RAGシステムの心臓部を理解する
はじめに
RAGシステムを構築する際、「テキスト分割」は検索精度を大きく左右する重要な要素です。LangChainのRecursiveCharacterTextSplitter
は「推奨される分割方法」とされていますが、その動作原理を正確に理解している人は意外と少ないのではないでしょうか?
この記事では、実際のコード例を交えながら、RecursiveCharacterTextSplitter
の内部動作を完全に理解し、最適な設定方法まで詳しく解説します。
RecursiveCharacterTextSplitterとは?
RecursiveCharacterTextSplitter
は、大きなテキストを検索に適したサイズのチャンクに分割するLangChainのクラスです。
主な特徴
- 🔄 再帰的分割:複数の分割方法を段階的に適用
- 📝 意味保持:テキストの構造と文脈を可能な限り保持
- ⚙️ 柔軟な設定:様々なパラメータで分割方式をカスタマイズ可能
基本的な使用方法
from langchain.text_splitter import RecursiveCharacterTextSplitter
def split_text(text: str, size=1000, overlap=200):
splitter = RecursiveCharacterTextSplitter(
chunk_size=size, # チャンクの最大サイズ
chunk_overlap=overlap, # 重複させる文字数
separators=["\n\n", "\n", " ", ""], # 分割に使用する区切り文字
length_function=len, # 長さの測定方法
)
return splitter.split_text(text)
🔍 動作原理の解説
1. 処理の順序:separators → chunk_size
多くの人が誤解しがちな点ですが、処理は以下の順序で行われます:
❌ 誤解: chunk_sizeで切ってからseparatorsを適用
✅ 正解: separatorsで分割してからchunk_sizeをチェック
2. 再帰的分割のステップ
separators = ["\n\n", "\n", " ", ""]
ステップ1:段落区切りで分割(\n\n
)
元テキスト:
段落1の内容が続きます...(1500文字)
段落2の内容です。(300文字)
段落3の内容が続きます...(1200文字)
ステップ2:サイズチェック
- 段落1: 1500文字 → ❌ オーバー(1000文字制限)
- 段落2: 300文字 → ✅ OK
- 段落3: 1200文字 → ❌ オーバー
ステップ3:再帰的分割
オーバーした段落を次のseparator(\n
)で再分割:
段落1 → 改行で分割 → 800文字 + 700文字 → ✅ 両方OK
段落3 → 改行で分割 → 600文字 + 600文字 → ✅ 両方OK
🔗 chunk_overlapの機能
よくある誤解と正しい理解
❌ 誤解: chunk_overlapによってchunk_sizeを超える
✅ 正解: chunk_sizeは絶対制限、overlapは重複を作る
実際の動作例
chunk_size = 1000
chunk_overlap = 200
3000文字のテキストの場合:
チャンク1: 文字位置 0 ~ 1000 (1000文字)
チャンク2: 文字位置 800 ~ 1800 (1000文字)← 200文字重複
チャンク3: 文字位置 1600 ~ 2600 (1000文字)← 200文字重複
チャンク4: 文字位置 2400 ~ 3000 (600文字)
重複は必ずしも正確ではない
# 目標の重複:200文字
# 実際の重複:separatorsによって調整される
# 例:段落境界で調整
チャンク1の末尾: "...クラス名の命名規則について"
チャンク2の先頭: "クラス名の命名規則について詳しく説明します..."
# → 実際の重複:180文字(段落境界を優先)
⚡ 文字数 vs トークン数
デフォルトは文字数測定
length_function=len, # 文字数で測定
トークン数での測定
LLMではトークン数が重要なため、以下のように変更可能:
import tiktoken
def count_tokens(text: str, model="gpt-4o-mini"):
enc = tiktoken.encoding_for_model(model)
return len(enc.encode(text))
splitter = RecursiveCharacterTextSplitter(
chunk_size=1000, # 1000トークン
chunk_overlap=200,
separators=["\n\n", "\n", " ", ""],
length_function=lambda x: count_tokens(x, "gpt-4o-mini"), # トークン数で測定
)
🎯 実践的な設定方法
日本語文書に最適化した設定
def create_japanese_splitter(chunk_size=1000, overlap=200):
return RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=overlap,
separators=[
"\n\n", # 段落区切り(最優先)
"\n", # 改行
"。", # 日本語の句点
"!", # 感嘆符
"?", # 疑問符
" ", # 空白
"", # 文字単位(最後の手段)
],
length_function=len,
)
コード文書用の設定
from langchain.text_splitter import Language
# Python用の設定
python_splitter = RecursiveCharacterTextSplitter.from_language(
language=Language.PYTHON,
chunk_size=1000,
chunk_overlap=200
)
📊 パラメータの最適化ガイド
chunk_size の選び方
# 用途別推奨値
document_qa = 1000 # 文書QA:詳細な情報が必要
summary = 2000 # 要約:長めの文脈が必要
code_search = 500 # コード検索:関数単位での分割
chunk_overlap の選び方
# chunk_sizeの10-20%が目安
chunk_size = 1000
chunk_overlap = chunk_size * 0.2 # 200文字(20%)
# 文書の種類による調整
technical_doc = chunk_size * 0.1 # 技術文書:重複少なめ
narrative_text = chunk_size * 0.3 # 物語:文脈重視で重複多め
🚀 実際のRAGシステムでの活用
import streamlit as st
from langchain_community.vectorstores import Chroma
def process_pdf_for_rag(pdf_text: str):
# 最適化されたスプリッター
splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=200,
separators=["\n\n", "\n", "。", " ", ""],
length_function=len,
)
# チャンクに分割
chunks = splitter.split_text(pdf_text)
# ベクトルストアに保存
vectorstore = Chroma.from_texts(
texts=chunks,
embedding=embeddings,
)
return vectorstore, chunks
# 分割結果の確認
st.write(f"📊 分割統計:")
st.write(f"- 元文書: {len(pdf_text):,}文字")
st.write(f"- チャンク数: {len(chunks)}")
st.write(f"- 平均チャンクサイズ: {sum(len(c) for c in chunks) // len(chunks)}文字")
💡 よくあるトラブルと対処法
1. チャンクが大きすぎる/小さすぎる
# 問題:チャンクサイズのばらつきが大きい
# 対処:separatorsを文書の構造に合わせて調整
# Markdown文書の場合
markdown_separators = [
"\n## ", # 見出し2
"\n### ", # 見出し3
"\n\n", # 段落
"\n", # 改行
" ", # 空白
"", # 文字
]
2. 重複が想定通りにならない
# 問題:overlapが期待値と大きく異なる
# 対処:separatorの優先順位を見直す
# 厳密な重複が必要な場合
strict_splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=200,
separators=[" ", ""], # 空白と文字のみ(構造を無視)
length_function=len,
)
まとめ
RecursiveCharacterTextSplitter
の核心:
- 🔄 再帰的処理: separators → chunk_size の順序で段階的分割
- 📏 厳密な制限: chunk_sizeは絶対に超えない
- 🔗 柔軟な重複: chunk_overlapは目標値、実際はseparatorsで調整
- 📝 意味保持: テキストの構造を尊重した分割
- ⚙️ カスタマイズ: 文書の特性に合わせた設定が重要
適切な設定により、RAGシステムの検索精度と回答品質を大幅に向上させることができます。ぜひ自分のプロジェクトに合わせて最適化してみてください!
参考資料

【fetch API】Request ボディの扱い方
1. DELETE(req: Request) に渡るボディ形式と読み取り方法
クライアントから次のように送信する場合:
fetch('/api/file/delete', { method: 'DELETE', body: 'foo.txt' });
- この場合、
req
に届くのは プレーンテキスト。 - サーバー側は
await req.text()
で取得する。 - JSON を送る場合は、以下のように
Content-Type
を必ず指定:
fetch('/api/file/delete', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileName: 'foo.txt' }),
});
- サーバー側では
await req.json()
で読み取る。
使い分け
内容形式 | クライアント設定 | サーバー読み取り |
---|---|---|
プレーンテキスト | body: '文字列' | req.text() |
JSON | JSON.stringify() + Content-Type: application/json | req.json() |
2. 「Request のボディはストリーム」の意味
-
ReadableStream の性質:
- データは逐次読み込み(全体を一度に持たない)
-
一度きりの消費(
req.text()
/req.json()
などを呼ぶと再読み取り不可) - 大きなデータでもメモリ効率が良い
再読み込みが必要な場合は req.clone()
を使用:
export async function DELETE(req: Request) {
const copy = req.clone();
const raw = await copy.text();
const data = await req.json().catch(() => null);
}
3. ヘルパーを使わずに全ボディを得る方法
Web Streams API 手動読み取り
const reader = req.body?.getReader();
const chunks: Uint8Array[] = [];
while (reader) {
const { value, done } = await reader.read();
if (done) break;
if (value) chunks.push(value);
}
const text = new TextDecoder().decode(
Uint8Array.from(chunks.flat())
);
簡便な代替
const text = await new Response(req.body).text();
注意: 一度しか使えない点は同じ。
4. 今回の実装例(プレーンテキスト)
// app/api/file/delete/route.ts
import { NextResponse } from 'next/server';
export async function DELETE(req: Request) {
const fileName = await req.text();
console.log('[DELETE /api/file/delete] fileName =', fileName);
return NextResponse.json({ ok: true, fileName });
}
-
ログの出力先
- 開発中:
next dev
実行ターミナル - Vercel: Functions/Edge Logs
- 開発中:
5. 注意点
- ボディは一度しか読めない → 複数回使うなら
req.clone()
- 空ボディの可能性に備える(400エラー返却など)
-
req.json()
は適切な Content-Type 前提 - 未使用 import は削除
- DELETE メソッドのボディは HTTP 的に許容されるが、環境によって制限される場合あり(必要ならクエリパラメータ利用)
6. 仕様の位置付け
-
Request,
req.text()
などは Web 標準 Fetch API の仕様(WHATWG Fetch Standard) - Node.js (v18+) が Fetch API をサポート
- Next.js Route Handler はこの Fetch API 準拠の Request/Response を採用
参考リンク

fetch周りは「型」で書く(プロキシ/初期同期/エラー/キャンセル)
目的: 毎回迷わない。URLと整形だけ差し替えで済むようにする
原則:
- API → 整形 → State(UIはStateのみを見る)
- 失敗は早く意味を持って返す(detailを拾う)
- いつでも中断可能(AbortController)
- キャッシュしない(no-store)
サーバープロキシの型(Next API ルート)
役割: CORS/認証/ベースURL/タイムアウト/エラー整形を一箇所で
差し替え点: URL、メソッド、タイムアウト
// app/api/any/route.ts
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'http://localhost:8000'
const API_PATH = process.env.NEXT_PUBLIC_API_BASE_PATH ?? '/api/v1'
async function handleResponse(res: Response) {
if (res.ok) return res.json()
let detail = 'Unknown error'
try { detail = (await res.json()).detail || detail } catch {}
throw new Error(detail)
}
export async function GET() {
const ac = new AbortController()
const t = setTimeout(() => ac.abort(), 10000)
try {
const r = await fetch(`${API_BASE}${API_PATH}/resource`, {
method: 'GET',
signal: ac.signal,
cache: 'no-store',
})
const data = await handleResponse(r)
return Response.json(data)
} catch (e) {
const msg = e instanceof DOMException && e.name === 'AbortError'
? 'タイムアウトしました。ネットワーク状況を確認してください。'
: e instanceof Error ? e.message : '処理中にエラーが発生しました。'
return Response.json({ error: msg }, { status: 500 })
} finally {
clearTimeout(t)
}
}
クライアント初期同期の型(マウント時に一回)
役割: 初期表示の整合。アンマウント後に触らない
差し替え点: fetchUrl、onSuccess(整形)
// hooks/useInitialSync.ts
import { useEffect } from 'react'
export function useInitialSync(
fetchUrl: string,
onSuccess: (data: any) => void,
onError?: (msg: string) => void
) {
useEffect(() => {
const ac = new AbortController()
let cancelled = false
;(async () => {
try {
const res = await fetch(fetchUrl, { signal: ac.signal, cache: 'no-store' })
if (!res.ok) {
let detail = 'Unknown error'
try { const err = await res.json(); detail = err.error || err.detail || detail } catch {}
throw new Error(detail)
}
const data = await res.json()
if (!cancelled) onSuccess(data)
} catch (e) {
if (!cancelled) onError?.(e instanceof Error ? e.message : 'Unknown error')
}
})()
return () => { cancelled = true; ac.abort() }
}, [fetchUrl, onSuccess, onError])
}
使用例(UIの都合に整形してStateへ)
// 任意のコンポーネント
useInitialSync('/api/any', (data) => {
const names: string[] = Array.isArray(data.items) ? data.items.map((x: any) => String(x.name)) : []
setNames(names)
setReady(names.length > 0)
}, (msg) => console.error('initial sync failed:', msg))
データ整形の型(UIフレンドリーに)
APIはリッチでもUIはシンプルで良い。使う要素だけ抜く。派生状態は計算。
function toNames(payload: unknown): string[] {
const items = (payload as any)?.items
if (!Array.isArray(items)) return []
return items.map((x: any) => String(x?.name ?? ''))
}
const names = toNames(data)
const ready = names.length > 0
エラー文言の型
まずdetailを見る → なければ短く要点のみ。タイムアウトは明示。
const msg = e instanceof DOMException && e.name === 'AbortError'
? 'タイムアウトしました。ネットワーク状況を確認してください。'
: e instanceof Error ? e.message : '処理中にエラーが発生しました。'
よくやるミス(自分チェック)
-
/api/...
の先頭スラッシュ忘れ(相対パス崩れ) -
cache: 'no-store'
を付け忘れて古い表示 - クリーンアップ未実装でアンマウント後に setState
- エラー整形しない(detail捨てて原因不明)
コピペ手順(自分向け)
- サーバー: ルート作る → URL/メソッド/タイムアウト差し替え
- クライアント: useInitialSync 使う → onSuccess内で整形→State
- UI: Stateのみレンダ。派生は計算(例: ready = list.length > 0)

Zustand入門ガイド
Zustandとは
- 軽量・ボイラープレート少のグローバル状態管理。
- Provider不要、任意のコンポーネントで直接フックを呼ぶだけ。
- セレクタで部分購読でき、再レンダリングを抑制しやすい。
インストール
npm i zustand
# 任意: 永続化やDevTools等のミドルウェア
npm i zustand@latest
最小のストア(JS/TS共通)
'use client'
import { create } from 'zustand'
type CounterState = {
count: number
inc: () => void
}
export const useCounter = create<CounterState>((set) => ({
count: 0,
inc: () => set((s) => ({ count: s.count + 1 })),
}))
- 使い方
const count = useCounter((s) => s.count)
const inc = useCounter((s) => s.inc)
型付け(TypeScript)
-
create<State>
のジェネリック型引数でストアの形を明示。 - ミドルウェア併用時は二段階呼び出しがきれい。
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
type SettingsState = {
topK: number
setTopK: (n: number) => void
}
export const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
topK: 5,
setTopK: (n) => set({ topK: Math.max(1, n) }),
}),
{ name: 'settings' } // localStorage key
)
)
セレクタと再レンダリング最適化
- 必要なスライスのみ購読(再レンダリング最小化)。
- 複数フィールドは
shallow
で浅い比較。
import { shallow } from 'zustand/shallow'
const { a, b } = useStore((s) => ({ a: s.a, b: s.b }), shallow)
- 注意: セレクタ関数は常に新しいオブジェクトを返さないよう工夫(
shallow
推奨)。
ミドルウェア活用
-
persist
: 自動永続化(localStorage等) -
devtools
: Redux DevTools連携 -
immer
: イミュータブル操作を直感的に
import { devtools, persist, immer } from 'zustand/middleware'
export const useStore = create<State>()(
devtools(
persist(
immer((set) => ({ /* ... */ })),
{ name: 'app-store' }
),
{ name: 'AppStore' }
)
)
非同期アクション
type UserState = {
user?: { id: string; name: string }
fetchUser: (id: string) => Promise<void>
}
export const useUser = create<UserState>((set) => ({
user: undefined,
fetchUser: async (id) => {
const res = await fetch(`/api/users/${id}`)
const data = await res.json()
set({ user: data })
},
}))
スライス設計(モジュール化)
type BearSlice = { bears: number; addBear: () => void }
type FishSlice = { fish: number; addFish: () => void }
type Store = BearSlice & FishSlice
const createBearSlice = (set): BearSlice => ({
bears: 0,
addBear: () => set((s: Store) => ({ bears: s.bears + 1 })),
})
const createFishSlice = (set): FishSlice => ({
fish: 0,
addFish: () => set((s: Store) => ({ fish: s.fish + 1 })),
})
export const useStore = create<Store>()((set) => ({
...createBearSlice(set),
...createFishSlice(set),
}))
Next.js(App Router)での使い方
- フック(
useStore
)を使うコンポーネントは**'use client'
**が必要。 - Provider不要=ツリー構造を意識せずどこでも使える。
- SSRでサーバーでも読みたい場合は
zustand/vanilla
を利用してサーバー側で値を作り、クライアントでuseStore
に接続するパターンもある(必要時のみ検討)。
よくある落とし穴
- セレクタが毎回新しい参照を返して再レンダリング多発 →
shallow
を使うか、スライス1項目ずつ購読。 -
NaN
/不正値が状態に入る → ストア側でクランプやバリデーションを実施。 - ステート更新関数の古いクロージャ参照 →
set((s) => ...)
形を優先。 - 大きなオブジェクトを丸ごとセレクト → 必要なプロパティだけ選ぶ。
実例:Top K 設定を共有(RAG検索)
- ストア
'use client'
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
type SettingsState = {
topK: number
setTopK: (n: number) => void
}
export const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
topK: 5,
setTopK: (n) => {
const v = Number.isFinite(n) ? n : 5
const clamped = Math.min(20, Math.max(1, v))
set({ topK: clamped })
},
}),
{ name: 'settings' }
)
)
- サイドバーで編集
import { useSettingsStore } from '@/lib/settings-store'
import { Slider } from '@/components/ui/slider'
const topK = useSettingsStore((s) => s.topK)
const setTopK = useSettingsStore((s) => s.setTopK)
<Slider value={[topK]} onValueChange={(v) => setTopK(v[0] ?? 5)} min={1} max={20} step={1} />
- 送信時に利用
const topK = useSettingsStore((s) => s.topK)
await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages, askToBot, model, top_k: topK }),
})
テスト(簡易)
import { useSettingsStore } from './settings-store'
test('setTopK clamps value', () => {
const { setTopK, topK } = useSettingsStore.getState()
setTopK(0)
expect(useSettingsStore.getState().topK).toBeGreaterThanOrEqual(1)
})
まとめ
-
Provider不要・APIが小さく、ZustandはContextよりも実装が簡潔になりがち。
-
セレクタ・ミドルウェアで性能・DX・永続化が取りやすい。
-
Next.jsでも相性が良く、スケールするグローバル状態に適している。
-
重要ポイント
-
型は
create<State>
で明示。ミドルウェア併用時は二段階呼び出しが安定。 -
セレクタで部分購読し、必要に応じて
shallow
。 - ストア側でバリデーション(下限/上限、NaN対策)を行い、状態の健全性を担保。
-
型は