実現したいこと
完全無料かつクレジットカード登録なしで、業務用チャットボットを構築したいと考えています。
対象は外部案件で、業務マニュアルやQ&Aが存在します。
これらをもとに、人手ではなくAIがチャット形式で回答できる仕組みを作りたいです。
現在はGeminiの無料APIを使用しており、
マニュアルやQ&AはSQLiteなどのデータベースに登録し、
検索(キーワード検索+簡易的な意味検索)を行ったうえでAIに渡す構成にしています。
そのため、
・誤回答を訂正した内容を蓄積し、次回以降の回答精度を上げる仕組みはあるのか
・完全無料でそれに近い構成を実現する方法があるのか
を知りたいです。
発生している問題・分からないこと
この案件では伝票画像などの画像情報が非常に重要で、
回答は「質問文」ではなく「画像の内容」を基準に判断する必要があります。
ただし、OCRでは読み取り精度に限界があり、正確にテキスト化できないケースも多いため画像フォルダにマニュアル画像を入れてAPIに渡しています。
さらに問題として、
質問者の前提が誤っているケース(例:対象外と書かれていないのに対象外だと思い込んでいる)が多く、
AIには単なる回答ではなく「正誤判定」もさせたいと考えています。
現状では、
・質問+画像を送信してもマニュアルと異なる回答が返ってくる
・一般知識に引っ張られた誤回答が発生する
という課題があります。
また、Gemini APIはリクエスト単位で完結するため、
誤回答に対して「それは違う」と訂正しても、その内容が蓄積されず次回に活かされません。現状、手動で調べて回答する方が精度が高く現段階のこのアプリは全く使い物になりません。
プロンプトの問題でしょうか?
マニュアルは20数ページ、Q&Aの数は100個程度あります。
よろしくお願いいたします。またより良い方法があれば教えてください。
該当のソースコード
search.py
1import sqlite3 2 3 4# ===== マニュアル(全件取得)===== 5def search_knowledge(query): 6 7 conn = sqlite3.connect("db.sqlite") 8 cur = conn.cursor() 9 10 cur.execute(""" 11 SELECT content, answer, page 12 FROM knowledge 13 WHERE type = 'manual' 14 """) 15 16 rows = cur.fetchall() 17 conn.close() 18 19 manuals = [] 20 for row in rows: 21 manuals.append({ 22 "content": row[0], 23 "answer": row[1], 24 "page": row[2], 25 }) 26 27 return manuals 28 29 30# ===== 事例(LIKE検索で取得)===== 31def search_cases(query, limit=5): 32 33 conn = sqlite3.connect("db.sqlite") 34 cur = conn.cursor() 35 36 cur.execute(""" 37 SELECT question, answer, keywords, image_path 38 FROM knowledge 39 WHERE type = 'case' 40 AND ( 41 question LIKE ? 42 OR keywords LIKE ? 43 ) 44 LIMIT ? 45 """, ('%' + query + '%', '%' + query + '%', limit)) 46 47 rows = cur.fetchall() 48 conn.close() 49 50 cases = [] 51 for row in rows: 52 cases.append({ 53 "question": row[0], 54 "answer": row[1], 55 "keywords": row[2], 56 "image": row[3], 57 }) 58 59 return cases
app.py
1from flask import Flask, request, render_template 2import os 3import requests 4import base64 5from dotenv import load_dotenv 6from search import search_knowledge 7 8load_dotenv() 9 10app = Flask(__name__) 11 12GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") 13 14# ===== Gemini呼び出し ===== 15def ask_gemini(prompt, image_bytes=None): 16 17 url = f"https://generativelanguage.googleapis.com/v1/models/gemini-2.5-flash:generateContent?key={GEMINI_API_KEY}" 18 19 parts = [{"text": prompt}] 20 21 # 画像がある場合 22 if image_bytes: 23 encoded = base64.b64encode(image_bytes).decode() 24 parts.append({ 25 "inline_data": { 26 "mime_type": "image/png", 27 "data": encoded 28 } 29 }) 30 31 payload = { 32 "contents": [ 33 { 34 "parts": parts 35 } 36 ] 37 } 38 39 response = requests.post(url, json=payload) 40 41 if response.status_code != 200: 42 return f"Geminiエラー: {response.text}" 43 44 data = response.json() 45 46 try: 47 return data["candidates"][0]["content"]["parts"][0]["text"] 48 except: 49 return "回答生成に失敗しました" 50 51 52# ===== プロンプト作成(RAGの核)===== 53def build_prompt(manuals, question): 54 55 manual_text = "" 56 57 for m in manuals: 58 manual_text += f""" 59【ルール】 60ページ: {m['page']} 61内容: {m['content']} 62処理: {m['answer']} 63""" 64 65 prompt = f""" 66あなたはこの案件専用の処理判定AIです。 67基本的には以下のルールに従って判断してください。 68 69==================== 70【ルール一覧】 71{manual_text} 72==================== 73 74【質問】 75{question} 76 77【重要ルール】 78・マニュアルのルールを最優先。世間のルールと相違している場合はマニュアルのルールを優先。 79・質問者の前提が正しいか必ず検証する 80・マニュアルと矛盾している場合は「誤り」と明確に指摘する 81・画像に書かれている内容も必ず考慮する。 82・判断できる場合は必ず答える 83・どうしても不明な場合のみ「該当情報なし」とする 84 85 86【出力形式(必須)】 87① 結論: 88② 理由: 89③ 根拠ルール(ページ or 事例): 90 91※③は必ずページ番号または事例内容を明記すること 92※該当なしの場合は「該当ルールなし」と書く 93""" 94 95 return prompt 96 97 98# ===== メイン処理 ===== 99@app.route("/ask", methods=["POST"]) 100def ask(): 101 102 try: 103 question = request.form.get("question") 104 file = request.files.get("image") 105 106 if not question: 107 return render_template("index.html", answer="質問を入力してください", results=[]) 108 109 image_bytes = file.read() if file else None 110 111 # ===== ① ハイブリッド検索 ===== 112 results = search_knowledge(question) 113 114 # ===== ② プロンプト生成 ===== 115 prompt = build_prompt(results, question) 116 117 # ===== ③ AI回答 ===== 118 answer = ask_gemini(prompt, image_bytes) 119 120 # ===== ④ 画面表示 ===== 121 return render_template( 122 "index.html", 123 answer=answer, 124 results=results 125 ) 126 127 except Exception as e: 128 return render_template("index.html", answer=f"エラー: {str(e)}", results=[]) 129 130 131# ===== トップページ ===== 132@app.route("/") 133def home(): 134 return render_template("index.html") 135 136 137if __name__ == "__main__": 138 # ローカルPC上にWebサーバーを立てる 139 app.run(debug=True)
pdf_import.py
1import sqlite3 2 3conn = sqlite3.connect("db.sqlite") 4cur = conn.cursor() 5 6# ===== カラム追加(なければ)===== 7try: 8 cur.execute("ALTER TABLE knowledge ADD COLUMN type TEXT") 9except: 10 pass 11 12try: 13 cur.execute("ALTER TABLE knowledge ADD COLUMN question TEXT") 14except: 15 pass 16 17 18# ===== マニュアル =====外部案件のためマニュアルや事例の内容はダミーにしてあります 19manual_data = [ 20 { 21 "content": "赤線や青線がない場合の処理", 22 "keywords": "赤線,青線, 23 "answer": "赤線や青線がなければそのままSaveする。", 24 "image_path": "3.png", 25 "page": 3, 26 "section":"(1) 処理手順" 27 } 28] 29 30 31# ===== 事例 ===== 32case_data = [ 33 { 34 "question": "警告マークが出ている場合は対象外ですか?", 35 "answer": "対象外ボタンを押してください", 36 "image_path": "receipt_sekiryō.png", 37 "keywords": "警告","対象外" 38 } 39] 40 41 42# ===== マニュアル登録 ===== 43for m in manual_data: 44 cur.execute(""" 45 INSERT INTO knowledge 46 (type, question, content, keywords, answer, image_path, source, page, section) 47 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 48 """, ( 49 "manual", 50 None, 51 m.get("content"), 52 m.get("keywords"), 53 m.get("answer"), 54 m.get("image_path") or m.get("image"), # ←ここ重要 55 m.get("source", "manual"), 56 m.get("page"), 57 m.get("section") 58 )) 59 60 61# ===== 事例登録 ===== 62for c in case_data: 63 cur.execute(""" 64 INSERT INTO knowledge 65 (type, question, content, keywords, answer, image_path, source, page, section) 66 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 67 """, ( 68 "case", 69 c.get("question"), 70 None, 71 c.get("keywords"), 72 c.get("answer"), 73 c.get("image_path"), 74 "case", 75 None, 76 None 77 )) 78 79 80conn.commit() 81conn.close() 82 83print("登録完了")
index.html
1//文字数制限のため省略 2
migrate_to_chroma.py
1import sqlite3 2import chromadb 3import requests 4import os 5from dotenv import load_dotenv 6 7load_dotenv() 8GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") 9 10def get_embedding(text: str): 11 url = f"https://generativelanguage.googleapis.com/v1beta/models/text-embedding-004:embedContent?key={GEMINI_API_KEY}" 12 13 payload = { 14 "model": "models/text-embedding-004", 15 "content": {"parts": [{"text": text}]} 16 } 17 18 res = requests.post(url, json=payload) 19 res.raise_for_status() 20 21 return res.json()["embedding"]["values"] 22 23 24def migrate(): 25 26 conn = sqlite3.connect("db.sqlite") 27 cur = conn.cursor() 28 29 cur.execute(""" 30 SELECT id, type, question, content, answer, page, image_path 31 FROM knowledge 32 """) 33 34 rows = cur.fetchall() 35 conn.close() 36 37 chroma = chromadb.PersistentClient(path="./chroma_db") 38 39 collection = chroma.get_or_create_collection( 40 name="knowledge", 41 metadata={"hnsw:space": "cosine"} 42 ) 43 44 for row in rows: 45 id_, type_, question, content, answer, page, image = row 46 47 # ★ embedding対象(ここ重要) 48 text = f"{question or ''} {content or ''} {answer or ''}".strip() 49 50 if not text: 51 continue 52 53 print("Embedding:", text[:50]) 54 55 embedding = get_embedding(text) 56 57 collection.add( 58 ids=[str(id_)], 59 embeddings=[embedding], 60 documents=[text], 61 metadatas=[{ 62 "type": type_ or "", 63 "question": question or "", 64 "content": content or "", 65 "answer": answer or "", 66 "page": str(page) if page else "", 67 "image": image or "" 68 }] 69 ) 70 71 print("✅ 移行完了") 72 73 74if __name__ == "__main__": 75 migrate() 76 77 78 79
試したこと・調べたこと
- teratailやGoogle等で検索した
- ソースコードを自分なりに変更した
- 知人に聞いた
- その他
上記の詳細・結果
LIKE検索のみだと、質問者が的外れなことを言っている場合、データベースから検索されないので意味検索も含めた。マニュアルの大量にある画像全てをリクエストごとにAIに送ると重くなり精度も落ちるため質問文や質問画像から近い場合のみ該当するデータベース情報を引き渡す方が良いと知った。
補足
特になし
回答1件
あなたの回答
tips
プレビュー