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

はじめに
本スクラップは前身の「コーディング規約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
- トレースに
_normalize
→re.sub
でcoroutine
が渡っている旨のTypeError
原因
-
extract_text_from_pdf
はasync 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)
/ask
が返ってこない(ハング)
事象2: 症状
-
/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では幻覚リスク増)
エラーログの読み方(ステップ)
- HTTPステータスとエンドポイントを特定(500 on
/embed/docs/upload
) - スタックトレースの最下段の例外を読む
TypeError: expected string or bytes-like object, got 'coroutine'
- 直前の呼び出し箇所をコードで確認
_normalize(dp.extract_text_from_pdf(content))
-
extract_text_from_pdf
がasync def
であることを確認 →await
漏れが原因 -
/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.py
のdelete_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 の順で動作確認すると確実