【生成AI】RAGを使ってケネディ暗殺の真相に迫ってみた

# 目次
[はじめに](#はじめに)
[公開された機密データはどんなデータ？](#公開された機密データはどんなデータ？)
[ダウンロードできるデータの問題点](#ダウンロードできるデータの問題点)
[データのソース](#データのソース)
[主な技術スタック](#主な技術スタック)
[作業リポジトリ](#作業リポジトリ)
[処理の流れ](#処理の流れ)
[各処理ポイントの解説](#各処理ポイントの解説)
[構築したRAGで検索してみる](#構築したRAGで検索してみる)
[終わりに](#終わりに)

# はじめに

最近トランプ大統領がケネディ暗殺機密文書を全公開したとのことで、どんなことが書いてあるんだろう？と気になったのですが、「NYタイムズ電子版は、国立公文書館がHPで公開した全てのファイルを開くだけでも2日を要し、解析にはなお時間がかかると報じた。」という話で、どのメディアでもすぐには情報を発信していませんでした。
情報は鮮度が大事なので、自分で分析して何か面白い情報を見つけてみたくなりRAGを活用してみることにしました。

# 公開された機密データはどんなデータ？
アメリカ政府のアーカイブサイトに以下のような形で機密文書が公開されています。32000pagesと書いてあるので相当な量ですね！
![スクリーンショット 2025-03-22 0.19.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/896062/17fe8743-10a8-4c69-93b3-7c8f6ea1549b.png)

pdfファイルの中を見てみると、各ページに「SECRET」文字が。機密文書っぽい！さっと眺めてみるとCIAとかオズワルドとか書いてあったりして、とても興味深いです。
![スクリーンショット 2025-03-22 0.21.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/896062/049c5d2a-08d9-4454-b5ba-14e037d4f24a.png)

# ダウンロードできるデータの問題点
データが大量にあるので一括処理できる形でETLを考えたいです。また、pdfは紙媒体をスキャンされたものなので、文字起こしをする必要があります。また、ドキュメントによってページ数やレイアウト、印刷の質も異なるので厄介そうです。とりあえず、読み取れる範囲で文字起こししつつ、RAGを作りたいと思います。


# データのソース
- JFK Assassination Records - 2025 Documents Releaseから取得しました
- https://www.archives.gov/research/jfk/release-2025


# 主な技術スタック

まず、RAGを作成するにあたり、何を使って作るか悩みました。最近よく触っているAmazon BedrockやAmazon Q business、Amazon Kendraなどを組み合わせて作るのが一番簡単かな、と思いつつ、費用も結構かかりそうなので、langchain + openai apiでプログラムコードを書いて作成してみることにします。

# 作業リポジトリ
- https://github.com/Haradasn/jfk-assassination-rag

# 処理の流れ

1. 機密文書のダウンロード
    1. Beautiful Soupにて機密文書公開サイトからローカルにファイルをダウンロード
1. スキャンされたpdfからの文字起こし
    1. 各ファイルを画像に変換
    1. 変換した画像ファイルをopenai api batchで文字起こしするため、jsonlファイルを作成する
    1. jsonlファイルは200MB制限があるので、制限を超えないようにファイル分割
    1. openai api batchを実行
    1. 実行結果をダウンロード
1. 文字起こしテキストのベクトルDB化
1. streamlitアプリを作成し、RAGを実行する

# 各処理ポイントの解説

### 機密文書のダウンロード
1. ファイル数が多いので進捗がわかるようにしました
1. サーバに負荷がかかるため、waitを入れるようにしています
1. エラーが発生した場合のハンドリングも実装しています

```python download_pdf.py
import requests
from bs4 import BeautifulSoup
import os
import re
import time
from tqdm import tqdm
from urllib.parse import urljoin

# 設定
url = 'https://www.archives.gov/research/jfk/release-2025'
save_dir = 'jfk_pdfs'
os.makedirs(save_dir, exist_ok=True)

headers = {
    'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)'
}

# 取得関数（リトライ付き）
def download_file(pdf_url, save_path, retries=3):
    for attempt in range(retries):
        try:
            response = requests.get(pdf_url, headers=headers, timeout=30)
            if response.status_code == 200:
                with open(save_path, 'wb') as f:
                    f.write(response.content)
                return True
            else:
                print(f"⚠️ Status code {response.status_code}: {pdf_url}")
        except requests.exceptions.Timeout:
            print(f"⏳ Timeout: {pdf_url}")
        except Exception as e:
            print(f"❌ Error: {e}")
        time.sleep(2)  # リトライ前にちょっと休憩
    return False

# ページからPDFリンク抽出
print("🔎 PDFリンク抽出中...")
response = requests.get(url, headers=headers)
soup = BeautifulSoup(response.text, 'html.parser')

pdf_links = set()
for a_tag in soup.find_all('a', href=True):
    href = a_tag['href']
    if re.search(r'\.pdf$', href, re.IGNORECASE):
        full_url = urljoin(url, href)
        pdf_links.add(full_url)

print(f"✅ 見つかったPDF数: {len(pdf_links)}")

# PDFダウンロード開始（進捗付き）
for pdf_url in tqdm(pdf_links, desc="📥 Downloading PDFs"):
    filename = os.path.join(save_dir, os.path.basename(pdf_url))
    success = download_file(pdf_url, filename)
    if not success:
        print(f"❌ ダウンロード失敗: {pdf_url}")
    time.sleep(1)  # サーバーに優しく1秒休憩

print("🎉 全PDFダウンロード完了！")
```

### スキャンされたpdfからの文字起こし

 最初にダウンロードした各ファイルを画像に変換します。その上で、変換した画像ファイルをopenai api batchで文字起こしするため、base64化した上で、バッチ処理用のjsonlファイルを作成します。

```python ocr_with_openai_create_batch_json.py
import os
import fitz
import base64
import json
from dotenv import load_dotenv

load_dotenv()
api_key = os.getenv("OPENAI_API_KEY")

os.makedirs("batch_payload", exist_ok=True)

with open("vision_batch_job.jsonl", "w") as batch_file:
    for pdf_file in os.listdir("data"):
        if not pdf_file.endswith(".pdf"):
            continue
        pdf_path = os.path.join("data", pdf_file)
        doc = fitz.open(pdf_path)

        for page_num, page in enumerate(doc):
            pix = page.get_pixmap(dpi=300)
            image_bytes = pix.tobytes("png")
            b64_img = base64.b64encode(image_bytes).decode('utf-8')

            # ✅ JSONL形式でBatch用リクエストを作る
            payload = {
                "custom_id": f"{pdf_file}_page_{page_num+1}",
                "method": "POST",
                "url": "/v1/chat/completions",
                "body": {
                    "model": "gpt-4o-mini",
                    "messages": [
                        {"role": "system", "content": "You are an OCR assistant. Extract clean English text from this image."},
                        {"role": "user", "content": [
                            {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64_img}"}}
                        ]}
                    ],
                    "max_tokens": 2000
                }
            }
            batch_file.write(json.dumps(payload) + "\n")

print("✅ Batch用 JSONL 生成完了！")
```

 作成したjsonlファイルは30GB以上のファイルになりました。openai api batchの制限として、１処理あたり200MB制限があるので、制限を超えないようにファイル分割します。ちなみにopen ai apiのバッチ処理をする理由は、オンラインAPIだとコストが高いためです。

```python batch_split.py
import os

max_chunk_size = 200 * 1024 * 1024  # 200MB
input_file = 'vision_batch_job.jsonl'
output_dir = 'batch_splits'
os.makedirs(output_dir, exist_ok=True)

chunk_num = 1
current_chunk_size = 0
chunk_lines = []

with open(input_file, 'rb') as f:
    for line in f:
        if current_chunk_size + len(line) > max_chunk_size:
            output_path = os.path.join(output_dir, f'vision_batch_part_{chunk_num}.jsonl')
            with open(output_path, 'wb') as chunk_file:
                chunk_file.writelines(chunk_lines)
            print(f'✅ 分割完了: {output_path}')
            chunk_num += 1
            chunk_lines = []
            current_chunk_size = 0
        chunk_lines.append(line)
        current_chunk_size += len(line)

# 最後の残りを書き出し
if chunk_lines:
    output_path = os.path.join(output_dir, f'vision_batch_part_{chunk_num}.jsonl')
    with open(output_path, 'wb') as chunk_file:
        chunk_file.writelines(chunk_lines)
    print(f'✅ 分割完了: {output_path}')
```

その後、分割したjsonlファイルを１つ１つ入力しながら、openai api batchを実行します。

```python batch_run_multiple.py
import os
import openai
from dotenv import load_dotenv

load_dotenv()
openai.api_key = os.getenv("OPENAI_API_KEY")

batch_dir = "batch_splits"  # 分割JSONL格納フォルダ

def upload_and_run_batch(jsonl_path):
    print(f"📤 アップロード開始: {jsonl_path}")
    upload = openai.files.create(
        file=open(jsonl_path, "rb"),
        purpose="batch"
    )
    print(f"✅ アップロード成功 File ID: {upload.id}")

    print(f"🚀 Batchジョブ実行開始 for {jsonl_path}")
    batch = openai.batches.create(
        input_file_id=upload.id,
        endpoint="/v1/chat/completions",
        completion_window="24h"
    )
    print(f"✅ Batchジョブ登録成功！Batch ID: {batch.id}")
    print(f"🔗 確認リンク: https://platform.openai.com/batch/{batch.id}")
    return batch.id

if __name__ == "__main__":
    # 分割されたファイル順に回す
    for file_name in sorted(os.listdir(batch_dir)):
        if file_name.endswith(".jsonl"):
            jsonl_path = os.path.join(batch_dir, file_name)
            try:
                upload_and_run_batch(jsonl_path)
            except Exception as e:
                print(f"❌ エラー発生: {file_name} - {e}")
```

実行結果はopenai apiのダッシュボードサイトからダウンロードするかapiを用いてダウンロードできます。今回はAPIでダウンロードしました。

- ポータルサイトのバッチ画面はこんな感じでinputのjsonlファイルに対して、生成AIがどんなアウトプットファイルを出力したかを確認できます
![スクリーンショット 2025-03-21 16.12.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/896062/ad74fd13-b778-4f4b-82d7-c30c77e45daa.png)


```python download_all_batches.py
import os
import openai
import requests
import time
from dotenv import load_dotenv

load_dotenv()
api_key = os.getenv("OPENAI_API_KEY")
openai.api_key = api_key

save_dir = "batch_results"
os.makedirs(save_dir, exist_ok=True)

all_batches = []
next_page = None

print("📥 Batch一覧をすべて取得中...")

# ✅ ページネーション対応で全件取得
while True:
    response = openai.batches.list(limit=200, after=next_page) if next_page else openai.batches.list(limit=200)
    all_batches.extend(response.data)

    if not response.has_more:
        break
    next_page = response.data[-1].id

print(f"✅ 総バッチ数: {len(all_batches)} 件")

# ✅ 各バッチの結果をダウンロード
for batch in all_batches:
    batch_id = batch.id
    status = batch.status
    print(f"{batch_id} {status}")

    if status != "completed":
        print(f"❌ 未完了のためスキップ")
        continue

    output_file_id = batch.output_file_id
    download_url = f"https://api.openai.com/v1/files/{output_file_id}/content"
    out_file = os.path.join(save_dir, f"{batch_id}.jsonl")

    # ✅ すでにDL済みならスキップ
    if os.path.exists(out_file):
        print(f"✅ 既にダウンロード済みスキップ → {out_file}")
        continue

    # ✅ リトライ付きダウンロード
    for attempt in range(5):
        try:
            headers = {"Authorization": f"Bearer {api_key}"}
            response = requests.get(download_url, headers=headers, timeout=120)
            if response.status_code == 200:
                with open(out_file, "wb") as f:
                    f.write(response.content)
                print(f"✅ {batch_id} ダウンロード完了 → {out_file}")
                break
            elif response.status_code == 429:
                print(f"⚠️ レート制限 429 → 30秒待機 (Attempt {attempt+1}/5)")
                time.sleep(30)
            else:
                print(f"❌ {batch_id} ダウンロード失敗: {response.status_code} - {response.text}")
                break
        except requests.exceptions.Timeout:
            print(f"⚠️ タイムアウト発生 → 30秒待機 (Attempt {attempt+1}/5)")
            time.sleep(30)
    else:
        print(f"❌ {batch_id} リトライ限界、スキップ")
```



### 文字起こしテキストのベクトルDB化
バッチ処理結果をダウンロードできたら、いよいよベクトルDBを作ります。ベクトルDBを作ればあとは、生成AIから呼び出しさえできればいいので後少しです！！


```python batch_result_to_chroma.py
import os
import json
from langchain_core.documents import Document
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.embeddings import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
from dotenv import load_dotenv

load_dotenv()
os.environ["OPENAI_API_KEY"] = os.getenv("OPENAI_API_KEY")

batch_results_dir = "batch_results"   # ダウンロードしたjsonlフォルダ
chroma_dir = "chroma_db"
os.makedirs(chroma_dir, exist_ok=True)

all_docs = []

# ✅ 1. JSONL全読み込み
for file in os.listdir(batch_results_dir):
    if not file.endswith(".jsonl"):
        continue

    with open(os.path.join(batch_results_dir, file), "r") as f:
        for line in f:
            data = json.loads(line)
            custom_id = data.get("custom_id", "unknown")

            # ✅ 正しいパスでGPTのcontent取り出す
            try:
                text = data["response"]["body"]["choices"][0]["message"]["content"]
            except (KeyError, IndexError):
                print(f"❌ データ欠損スキップ: {custom_id}")
                continue

            if not text.strip():
                print(f"⚠️ 空テキストスキップ: {custom_id}")
                continue

            # ✅ LangChain用ドキュメント化
            all_docs.append(Document(page_content=text, metadata={"source": custom_id}))

print(f"✅ 読み込んだドキュメント数: {len(all_docs)}")

# ✅ 2. チャンク分割
splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
chunks = splitter.split_documents(all_docs)

if not chunks:
    print("❌ チャンクが空です。処理を終了します。")
    exit()

# ✅ 3. ベクトル化してChroma保存
embedding = OpenAIEmbeddings(model="text-embedding-ada-002")
vectorstore = Chroma.from_documents(chunks, embedding, persist_directory=chroma_dir)
vectorstore.persist()

print("✅ 全バッチ結果 → ChromaDB ベクトル化完了！")
```

### streamlitアプリを作成し、RAGを実行する
いよいよ検索をしていきます。streamlitアプリを作ってコマンドで起動します。

```console
stremalit run app.py
```

```python app.py
import streamlit as st
from langchain_community.vectorstores import Chroma
from langchain.chat_models import ChatOpenAI
from langchain_openai import OpenAIEmbeddings
from dotenv import load_dotenv
import os

load_dotenv()
os.environ["OPENAI_API_KEY"] = os.getenv("OPENAI_API_KEY")

st.set_page_config(page_title="JFK RAG Search（推論＋スコア表示）", layout="wide")
st.title("📚 JFK Document RAG Search")

query = st.text_input("質問を入力してください（例: オズワルドとCIAの関係は？）")

if st.button("検索する"):
    with st.spinner("検索中..."):
        embedding = OpenAIEmbeddings(model="text-embedding-ada-002")
        vectorstore = Chroma(
            persist_directory="chroma_db",
            embedding_function=embedding
        )

        # ✅ チャンク数制限して取得（上位10件）
        retrieved_docs = vectorstore.similarity_search(query, k=10)

        # ✅ ドキュメント結合
        docs_content = "\n\n".join([doc.page_content for doc in retrieved_docs])

        # ✅ System Prompt強化
        system_prompt = """
あなたはJFK関連の専門家AIです。
以下のドキュメントを読み取り、質問に答えてください。
ドキュメント内に直接的な回答が無い場合は、文脈から推測・補完して回答してください。
絶対に「わかりません」などと言わず、ドキュメントから得られる情報・状況証拠・背景知識を元に考察してください。
        """

        # ✅ Chat APIへ直接投げる
        llm = ChatOpenAI(model="gpt-4-turbo", temperature=0)
        messages = [
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": f"【ドキュメント】\n{docs_content}\n\n【質問】\n{query}"}
        ]

        llm_response = llm.invoke(messages)

        # ✅ 回答表示
        st.subheader("📌 回答（推論・補完あり）")
        st.write(llm_response.content)

        # ✅ 参照ソース表示（チャンク10件）
        st.subheader("📚 参照したソース")
        for doc in retrieved_docs:
            st.write(f"- {doc.metadata.get('source')}")
```

# 構築したRAGで検索してみる

構築したRAGがどんなことを返してくるか質問してみました。

- 【質問1】
    - ケネディ暗殺の真実を教えてください
- 【回答】
    - ドキュメントには、ケネディ暗殺に関する具体的な真実や詳細な事実が直接記述されていませんが、NOSENKOという人物がCIAと接触し、KGBの操作について情報を提供していたことが示されています。NOSENKOは、KGBの指示の下で活動していたと述べており、彼が提供した情報がケネディ暗殺の調査にどのように影響を与えたかについては、文書からは明確な結論は導かれていません。彼の信頼性についても疑問が呈されており、彼の証言が真実かどうかについては議論の余地があるとされています。


![スクリーンショット 2025-03-22 2.03.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/896062/5fa88582-63a1-4266-82f9-095a50e3d831.png)

面白い！！私はノセンコという人物を知りませんでした！

- 【質問2】
    - 機密文書の中に、政府がケネディ暗殺事件に関与されていたとされる情報は有りますか

- 【回答】
    - ドキュメントには、政府がケネディ暗殺事件に関与していたという直接的な情報は含まれていません。ただし、いくつかの文書は、暗殺に関連するさまざまな主張や情報を記録しています。例えば、リチャード・フォン・クライストがFBIに連絡し、1963年7月にメキシコシティのホテル・ルナで特定の人物が陰謀を企てたと主張しています。また、他の文書では、暗殺について「狂信者」からの多くの手紙や、FBIエージェントがルイジアナ州のクレイ・ショー裁判で証言することを強制する裁判手続きに関する情報が含まれています。
    - これらの記述から、政府の関与を示唆する具体的な証拠は示されていませんが、暗殺に関連する様々な調査や証言が行われていたことは確認できます。政府機関が暗殺の詳細について情報を持っている可能性はありますが、それが政府の関与を意味するものではなく、調査の一環としての情報収集である可能性が高いです。
    ![スクリーンショット 2025-03-22 2.13.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/896062/2b7ace89-95a4-4224-8362-332591db2c92.png)

- 【質問3】
    - CIAはケネディ暗殺事件に関与しているのでしょうか？
- 【回答】
    - ドキュメントには、CIAがケネディ暗殺に関与しているかどうかについて直接的な証拠は示されていませんが、いくつかの点でCIAの行動に関する疑念が示されています。特に、1961年の通信では「CIA職員が暗殺の準備に直接関与している場合、少なくとも足跡を隠すために撤退させる必要がある」と述べられています。これはCIAが暗殺計画に関与していた可能性があることを示唆していますが、これがケネディ暗殺を指しているかどうかは明確ではありません。
    - また、ギャリソンの発言によれば、ウォーレン委員会はCIAの関与を隠蔽するために形成されたとされています。彼はCIAの関与を示す証拠が破壊されたり、報告されなかったりしたと主張しています。これもCIAが何らかの形で暗殺に関与していた可能性を示唆していますが、これを裏付ける直接的な証拠は示されていません。
    - したがって、ドキュメントからはCIAがケネディ暗殺に関与していた可能性があることが示唆されていますが、これを確実に証明する証拠は提供されていません。このため、CIAの関与については確定的なことは言えず、さらなる情報や証拠が必要とされる状況です。
![スクリーンショット 2025-03-22 2.18.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/896062/2ed85e81-550c-441b-bc83-5cdca31d734b.png)

なるほど！やっぱりCIAが関与していた可能性が示唆されているんですね！具体的な事例を交えて話してくれるので信憑性が増しますね。


# 終わりに

openai apiを使って、公開されたケネディ暗殺事件の機密文書の簡易分析を行ってみました。紙媒体をスキャンしたデータなので、中々全ての情報を抽出することができず、またデータ量も膨大のため、より精度を上げるためにはAdvanced RAGも試した方が良さそうです。ちなみに今回はOCR処理やベクトルDB作成のための埋め込み処理で200ドルくらいかかりました。コストを抑えながら処理を作成するのも生成AIを使いこなす上では重要ですね。

この後も引き続きこのRAGを活用しながらケネディ暗殺の新たな情報を発掘していきたいと思います！

おっと誰かが来たようだ。それではまた！