Open3

AI統合QAボット埋め込みサービス

ぬぬぬぬ

FastAPI×LangChainのRAGでハマった2つの問題と解決策まとめ(PDFアップロード500/askハング)

概要

  • PDFアップロードで500(TypeError)が発生
  • /ask が返ってこない(ハングする)
  • 原因はいずれも「非同期処理の扱い」と「同期APIのブロッキング」
  • 対応は最小修正と根本対策の2段階

対象と環境

  • API: POST /api/v1/embed/docs/upload(PDF/テキスト等の取り込み)
  • API: POST /api/v1/embed/docs/ask(RAGで回答生成)
  • 主要ファイル: backend/app/api/embed_ingest.py, backend/app/core/services/rag_engine.py, backend/app/core/services/document_processor.py

事象1: PDFアップロードで500(TypeError: expected string or bytes-like object, got 'coroutine')

症状

  • PDFアップロード時に500
  • トレースに_normalizere.subcoroutineが渡っている旨のTypeError

原因

  • extract_text_from_pdfasync def(非同期関数)
  • 呼び出し側で await を付けず、_normalize()coroutine を渡していた

最小修正(推奨)

  • await を付ける
116
    elif ext == "pdf":
        text = _normalize(await dp.extract_text_from_pdf(content))
        source_type = "pdf"

根本対策の選択肢

  • 選択肢A: 関数を同期に統一(他拡張子の実装と揃い、シンプル)
  • 選択肢B: 重いPDFでもイベントループを塞がないようto_threadでオフロード(高負荷時に強い)

同期化(案)

# 変更前
async def extract_text_from_pdf(self, data: bytes) -> str:
# 変更後
def extract_text_from_pdf(self, data: bytes) -> str:

スレッドオフロード(案)

def _extract_text_from_pdf_sync(self, data: bytes) -> str:
    ...

async def extract_text_from_pdf(self, data: bytes) -> str:
    return await asyncio.to_thread(self._extract_text_from_pdf_sync, data)

事象2: /ask が返ってこない(ハング)

症状

  • /ask のレスポンスが返らない
  • 温度を2.0→0.0に落とすと返ることがある(ただし根本解決ではない)

原因

  • 非同期エンドポイント内でLangChainの同期API .invoke() を呼び出し、イベントループをブロック
    • retriever.invoke(query)
    • rag_chain.invoke(question)

解決(非同期APIへ置換)

            retriever = self.vectorstore.as_retriever(
                search_type="similarity", search_kwargs=kwargs
            )
-            documents = retriever.invoke(query)
+            documents = await retriever.ainvoke(query)
            return documents
            rag_chain = (
                {"context": lambda x: context, "question": RunnablePassthrough()}
                | prompt
                | llm
                | StrOutputParser()
            )
-            answer = rag_chain.invoke(question)
+            answer = await rag_chain.ainvoke(question)

追加の安定化

  • LLMにタイムアウト付与
-            self._llm_cache[key] = ChatOpenAI(
-                model=used_model, temperature=used_temp, api_key=api_key
-            )
+            self._llm_cache[key] = ChatOpenAI(
+                model=used_model, temperature=used_temp, api_key=api_key, timeout=60
+            )
  • 文脈量を抑制(top_kを3〜5、必要ならコンテキストをトークン上限で切り詰め)

温度設定のベストプラクティス(RAG用途)

  • デフォルトは「0.2」前後が汎用的でおすすめ
    • 0.0: 再現性は高いが硬くなりがち
    • 0.2: 事実ベースで安定しつつ、言い回しに少し幅
    • 0.3–0.5: 要約・下書きなど表現の幅が必要な用途
    • 0.7: クリエイティブ向け(RAGでは幻覚リスク増)


エラーログの読み方(ステップ)

  1. HTTPステータスとエンドポイントを特定(500 on /embed/docs/upload
  2. スタックトレースの最下段の例外を読む
    • TypeError: expected string or bytes-like object, got 'coroutine'
  3. 直前の呼び出し箇所をコードで確認
    • _normalize(dp.extract_text_from_pdf(content))
  4. extract_text_from_pdfasync defであることを確認 → await漏れが原因
  5. /askはハング → 非同期関数内での.invoke()(同期)呼び出しがブロック原因

運用TIPS

  • 温度は0.2を既定にし、リクエストで上書き可能に
  • top_kは3〜5から開始
  • タイムアウト・リトライ・ログ(LLM呼び出し時間/トークン数)を計測
  • 大きなPDFが多い場合はto_threadでオフロードを検討

コミットメッセージ例(200文字以内)

  • PDFアップロード時のTypeError修正: 非同期関数extract_text_from_pdfの呼び出しにawaitを追加し、_normalizeへcoroutineが渡る問題を解消
  • ask遅延の根本対策: LangChainのinvokeをainvokeに変更し非同期化。ChatOpenAIにtimeoutを付与し長時間生成でのハングを防止

まとめ

  • PDF500は「非同期関数のawait漏れ」が原因、最小修正はawait追加

  • /askハングは「同期invokeのブロッキング」、ainvokeへ置換が根本対策

  • 運用は温度0.2・top_k小さめ・タイムアウト付与で安定化

  • 修正の要点

    • await dp.extract_text_from_pdf(...)
    • await retriever.ainvoke(...)
    • await rag_chain.ainvoke(...)
    • ChatOpenAI(..., timeout=60)
ぬぬぬぬ

ChromaDB の where フィルタ仕様変更で DELETE が 500 に。安全な修正と確認手順まとめ

この記事でわかること

  • 症状: ドキュメント削除 API が 500 を返す
  • 原因: ChromaDB の where フィルタ仕様(トップレベル演算子・$and の最小要素数)
  • 解決: 単一条件と複合条件で where の形を切り替える
  • 確認: 動作確認コマンドと再発防止ポイント

対象環境

  • FastAPI(バックエンド)
  • LangChain + ChromaDB(ベクトルストア)
  • エンドポイント: DELETE /api/v1/embed/docs/documents

症状(ログ)

  • 初回エラー(複合条件を直書き)
ValueError: Expected where to have exactly one operator, got {'filename': 'sample.pdf', 'tenant': 'client-a'} in get.
  • 修正後に出た別エラー(単一条件で $and を使用)
ValueError: Expected where value for $and or $or to be a list with at least two where expressions, got [{'tenant': {'$eq': 'client-a'}}] in get.

原因

  • トップレベルは演算子 1 つが必要(例: $and
  • $and は2つ以上の式が必要
  • よって、
    • 複合条件{"$and": [ {...}, {...} ]}
    • 単一条件{"field": {"$eq": value}}($and で包まない)

修正(ポイントだけ反映すればOK)

  • 対象: backend/app/core/services/rag_engine.pydelete_document_by_filename

修正後(抜粋・サンプル)

# where 構築(複合条件は $and、単一条件はそのまま)
conditions = [{"filename": {"$eq": filename}}]
if tenant is not None:
    conditions.append({"tenant": {"$eq": tenant}})
where = {"$and": conditions} if len(conditions) > 1 else conditions[0]

results = collection.get(where=where, include=["metadatas"])
ids = results.get("ids") or []
if not ids:
    raise ValueError(f"ファイル '{filename}'は見つかりませんでした")

deleted_count = len(ids)
collection.delete(where=where)
after_count = collection.count()

# 残数確認のための where(単一条件は $and を使わない)
remaining_where = {"tenant": {"$eq": tenant}} if tenant is not None else None
remaining_results = collection.get(include=["metadatas"], where=remaining_where)
  • 参考(任意): 一覧 API も将来互換のため $eq に揃えると安全
# get_document_list 内
where = {"tenant": {"$eq": tenant}} if tenant is not None else None
results = collection.get(include=["metadatas"], where=where)

再起動

docker compose restart api
# 必要に応じて
docker compose up -d --build api

動作確認

  • 削除 API
curl -X DELETE "http://localhost:8000/api/v1/embed/docs/documents" \
  -H "Content-Type: application/json" \
  -H "x-embed-key: <埋め込みキー>" \
  -d '{"filename":"sample.pdf"}'
  • 一覧 API(削除後の残件確認)
curl -X GET "http://localhost:8000/api/v1/embed/docs/documents" \
  -H "x-embed-key: <埋め込みキー>"

よくある落とし穴

  • 単一条件に $and を使わない(検証で弾かれる)
  • 複合条件をトップレベルに直書きしない(演算子がないと弾かれる)
  • LangChain の retriever に渡す filter={"tenant": tenant} はラッパー側で適切に変換されるため、現状エラーが出てなければそのままでOK(将来的に挙動が変わったら $eq へ明示的に揃える)

まとめ

  • DELETE の where を「単一条件はそのまま、複合条件は $and」に修正するだけで解消
  • GET は現状でも動作しているが、将来の互換性のため $eq に揃えるのが安全
  • 変更後は再起動し、DELETE→GET の順で動作確認すると確実