はじめに
社内文書をRAGで使えるようにする作業をしていると、「PDFをMarkdownに変換できれば終わり」ではないことに気づきます。
変換自体はできていても、章タイトルが普通の文章になっていたり、ページ番号が本文に紛れ込んでいたりします。表や箇条書きが崩れていることもあります。人間が読めば何となく分かるのですが、そのままチャンクに分けると、RAGにとっては扱いにくい文書になってしまいます。
毎回エディタで直すのも大変だったので、変換後の確認や手直し、チャンク分割までを1つの画面で行える「RAG Markdown Converter」を作りました。
今回いちばん工夫したのは、マウスで選んだ部分だけをAIで補正する機能です。この記事では、この機能を中心に、実装で考えたことを書いてみます。
どんなツールか
処理の流れは、だいたい次のようになっています。
ファイルをアップロード
↓
Microsoft MarkItDownでMarkdownに変換
↓
ルールベースで自動整形
↓
人が編集・プレビュー
↓
必要な部分だけAIで補正
↓
章分割・チャンク分割
↓
MarkdownまたはZIPでダウンロード
PDF、DOCX、XLSX、TXT、HTML、Markdownに対応しています。
左側でMarkdownを編集し、右側のプレビューで変換結果を確認できます。
画面はStreamlitで作りました。処理部分はファイルを分けて、UIを通さなくても整形や分割を確認できるようにしています。
app.py # Streamlit UI
components/text_selector.py # マウスでの範囲選択
services/converter.py # MarkItDown変換
services/formatter.py # Markdownの自動整形
services/splitter.py # 章・チャンク分割
services/ai_corrector.py # 選択範囲のAI補正
services/file_manager.py # 一時ファイルとZIP
services/auth.py # 簡易認証
技術構成はPython、Streamlit、Microsoft MarkItDown、OpenAI Python SDKです。データベースやFastAPIは使っていません。社内で使う小さな道具なので、追いかけやすい構成にしたかったためです。
AIに任せるのは「選んだ部分だけ」
最初は、変換したMarkdown全体をAIに整えてもらう案も考えました。実装だけを考えれば、その方が簡単です。
ただ、実際に扱うのは社内文書です。直す必要のない文章まで外部APIへ送るのは避けたいですし、長い文書では利用量も増えます。何より、AIが文書全体を触ると「どこが変わったのか」を人が確認するのが難しくなります。
そこで、通常の変換や自動整形ではOpenAI APIを呼ばないことにしました。APIを使うのは、利用者が範囲を選び、補正ボタンを押したときだけです。
操作は次のようにしました。
- 補正したい文章をマウスで選ぶ
- 「この範囲を選択」を押す
- AI補正を実行する
- 補正前と補正後を見比べる
- 問題なければ反映し、違っていればキャンセルする
選択した80文字だけを送信し、補正前と補正後を見比べてから反映します。
AIの結果をいきなり本文へ入れないのは、かなり大事なところだと思っています。文章の意味を変えていないつもりでも、数値や固有名詞に思わぬ変更が入る可能性はあります。最後は人が見て決める形にしました。
マウスで範囲を選べるようにする
ここは思ったより悩みました。
Streamlitの st.text_area からは入力内容を取得できますが、ブラウザ上で選択している文字の開始位置と終了位置まではPython側へ返ってきません。
一度は「開始位置: 120、終了位置: 240」のように数字を入力する画面も作りました。しかし、長いMarkdownで文字位置を調べながら入力するのは、実際に触ってみるとかなり使いづらいものでした。
最終的にはStreamlit Custom Components v2を使い、読み取り専用の textarea を用意しました。JavaScript側で selectionStart と selectionEnd を取得します。
const start = textarea.selectionStart ?? 0;
const end = textarea.selectionEnd ?? 0;
if (end > start) {
setTriggerValue("selection", {
start,
end,
text: textarea.value.slice(start, end),
});
}
Python側へ渡しているのは、開始位置、終了位置、選択された文字列の3つです。選択中は「123文字を選択中」と表示し、何も選ばれていないときは確定ボタンを押せないようにしました。
範囲選択欄を読み取り専用にしたのにも理由があります。編集と選択を同じ欄で同時に行えると、Python側が持っているMarkdownとブラウザ側の文字位置がずれやすくなります。先に編集内容を保存して再プレビューし、その確定済みの文章から範囲を選ぶ流れにしました。
選択位置だけを信用しない
JavaScriptから開始位置と終了位置が届けば、その部分だけを置換できます。ただし、位置だけを保存する実装には少し怖さがあります。
たとえばAIの応答を待っている間に本文が編集されると、同じ120文字目でも、すでに別の文章になっているかもしれません。その状態で補正結果を差し込むと、関係のない箇所を壊してしまいます。
そのため、選択時の文字列も一緒に保持し、反映する直前に現在のMarkdownと照合しています。
@dataclass(frozen=True)
class Selection:
start: int
end: int
text: str
def replace_selection(markdown, selection, corrected):
current = markdown[selection.start:selection.end]
if current != selection.text:
raise ValueError(
"編集内容が変わったため、AI補正範囲を選び直してください。"
)
return (
markdown[:selection.start]
+ corrected
+ markdown[selection.end:]
)
地味なチェックですが、範囲補正を安心して使うには必要な処理でした。
OpenAI APIへ送る内容
APIの input に入れるのは、選択された文字列だけです。文書全体は渡しません。
response = OpenAI(api_key=api_key).responses.create(
model=model,
instructions=SYSTEM_INSTRUCTIONS,
input=selected_text,
)
corrected = response.output_text
指示では、見出し、改行、箇条書き、表、章・条・項の構造を整えることに役割を絞っています。意味、数値、固有名詞、規則の内容は変更しないようにし、判断できない箇所は原文を残すよう伝えています。
もちろん、プロンプトに書けば絶対に変更されない、というわけではありません。だからこそ、送信範囲を小さくすることと、結果を反映する前に人が確認することをセットにしています。
MarkItDownの出力をそのまま使わない
文書変換にはMicrosoft MarkItDownを使いました。変換部分はとてもシンプルです。
from markitdown import MarkItDown
result = MarkItDown(enable_plugins=False).convert(str(path))
markdown = result.text_content
ただし、変換結果をそのまま完成品にはしていません。次のような処理は、AIではなくルールベースで行っています。
- 行末空白と連続空行を整理する
- 「第1章」「第2条」などを見出しとして扱う
- 箇条書き記号をそろえる
- ページ番号を取り除く
- 繰り返し現れる短いヘッダーやフッターを取り除く
機械的に決められることまでAIへ頼む必要はありません。ルールベースなら同じ入力に同じ結果が返り、何をしたかもコードで追えます。
一方で、ヘッダー除去のような処理は強くしすぎると本文まで消してしまいます。今回は「短い同一行が3回以上現れる」といった控えめな条件にしました。コードフェンスの中には整形ルールを適用しません。
RAG向けのチャンク分割
Markdownを一定文字数で機械的に切るだけだと、見出しと本文が離れてしまうことがあります。切り出したチャンクだけを見たときに、「これは何についての文章なのか」が分かりにくくなります。
そこで、まずMarkdown見出しを基準にセクションを作り、その中を段落単位でまとめています。長すぎる段落だけは追加で分割し、各チャンクには現在の見出しを残します。必要なら前のチャンク末尾を引用形式で少し重ねることもできます。
文字数は800〜1,500文字の範囲で指定でき、初期値は1,000文字にしました。万能な数字ではありませんが、文書ごとに調整できる方が現実的だと考えています。
出力は全文Markdownのほか、章別ファイルとチャンク別ファイルをまとめたZIPを選べます。
文書をサーバーに残さない
このツールでは社内文書を扱うため、変換精度と同じくらい、処理後にデータを残さないことを気にしました。
アップロードした原本はランダム名の作業ディレクトリへ一時保存し、成功した場合もエラーになった場合も finally で削除します。
try:
with upload_path.open("wb") as destination:
shutil.copyfileobj(source, destination)
yield upload_path
finally:
shutil.rmtree(work_dir, ignore_errors=True)
編集途中のMarkdownはStreamlitのセッション内だけで持ちます。ダウンロード用ZIPも io.BytesIO 上で作り、ディスクには保存しません。異常終了で一時ディレクトリが残った場合に備えて、一定時間後のクリーンアップも用意しました。
ログへ出すのは処理時間、ファイル種別、成功・失敗、例外の種類までです。ファイル名や本文、AIへ送った範囲は記録しません。
最初は「ダウンロード後にZIPを削除する」処理を考えていましたが、そもそもディスクに作らなければ削除タイミングを気にせずに済みます。このあたりは、作りながら設計がシンプルになった部分です。
作ってみて感じたこと
今回の実装で一番印象に残ったのは、AIの使いどころを狭くした方が、かえって使いやすかったことです。
全文をAIへ渡す機能は目立ちますが、利用者からすると「どこまで変わるのか分からない」という不安があります。自分で範囲を選び、変更前後を見てから反映する形なら、AIは文章を勝手に作り替える存在ではなく、手作業を少し助ける道具になります。
また、範囲選択はUIだけの話ではありませんでした。選択位置の検証、編集後のずれの検出、送信データの限定、反映前の確認までそろって、初めて安心して使える機能になります。
もう1つ、AIとルールベース処理を分けたのも良かった点です。空行整理やページ番号除去はコードに任せ、表や章構造が崩れた部分だけAIに手伝ってもらう。それぞれが得意な仕事に絞ることで、処理内容が分かりやすくなりました。
今後やりたいこと
実際に使いながら、次のような改善を考えています。
- スキャンPDF向けのOCR
- 補正箇所がひと目で分かる差分表示
- 文書の種類に合わせた見出し推定設定
- チャンクの品質を確認するための指標
- 利用者が確認したうえでDifyへ登録する機能
履歴保存や全文AI補正も便利そうですが、社内文書を必要以上に残さない、外部へ送らないという方針とは相性がよくありません。このあたりは機能を先に作るのではなく、運用ルールが決まってから考えるつもりです。
まとめ
RAG用の文書を作る作業は、ファイル形式をMarkdownへ変えるだけでは終わりません。見出しを整え、不要な行を除き、内容を確認し、意味のまとまりを残して分割するところまで必要でした。
今回のツールでは、機械的に直せる箇所はルールベースで処理し、人の判断が必要な箇所だけAIを使っています。中でも、マウスで選択した範囲だけを送り、補正結果を確認してから反映する流れは、便利さと安心感のバランスを取りやすい方法でした。
AIに文書全体を任せるのではなく、人が主導したまま必要なところだけ手伝ってもらう。RAG投入前の文書整備では、このくらいの距離感がちょうどよいのではないかと思っています。
最後に
本ツールは株式会社LYDIAで、社内のRAG構築を効率化するために開発しました。
実際に運用しながら改善を続けているため、新しい工夫や気づきがあれば、また記事として紹介したいと思います。
今後も Embedded × DX × AI をテーマに、実務で得た知見や開発事例を発信していきます。

