系列:30 天用 Google AI 打造臺灣防災速報 App(Day 12/30)
昨天整理好 29 塊防災知識。今天讓它可以被搜尋,並且讓有資料的回答都附上所根據的來源。流程分三步:把文字轉成向量、用問題找出最接近的幾塊、請模型只根據這幾塊回答。
Embedding 是把一段文字轉成一串數字(向量)的技術。意思相近的文字,轉出來的向量也會接近。把知識庫的每一塊、還有使用者的問題都轉成向量,就能用數學算出哪幾塊和問題最接近。
動手前先查了官方文件(2026-09-21 查閱),有兩件事和我原本以為的不一樣:
gemini-embedding-2。舊的 gemini-embedding-001 可以用 task_type 參數指定用途(建庫或查詢),新模型不支援這個參數,改成把用途寫在文字最前面。Content。下面的程式是節錄。numpy 是 Python 的數值計算套件,用來處理一大堆數字的運算;chunks 是昨天存好的 29 塊資料。
import json
import numpy as np
from google import genai
from google.genai import types
client = genai.Client()
chunks = json.load(open("kb/chunks.json", encoding="utf-8")) # 昨天整理的 29 塊
BATCH = 5 # 一次送幾段文字
def embed(texts: list[str]) -> np.ndarray:
vecs = []
for i in range(0, len(texts), BATCH):
resp = client.models.embed_content(
model="gemini-embedding-2",
contents=[types.Content(parts=[types.Part(text=t)])
for t in texts[i:i + BATCH]],
config=types.EmbedContentConfig(output_dimensionality=768),
)
vecs += [e.values for e in resp.embeddings]
return np.array(vecs)
# 用途寫在文字前綴裡:知識庫的內容和使用者的問題,前綴不同
def doc_text(c: dict) -> str:
return f"title: {c['topic']}|{c['scenario']} | text: {c['text']}"
def query_text(q: str) -> str:
return f"task: search result | query: {q}"
chunk_vecs = embed([doc_text(c) for c in chunks]) # 建庫:算一次,存成檔案
np.save("kb/kb_vectors.npy", chunk_vecs)
output_dimensionality=768 是向量的長度。預設是 3072,768 是官方建議的選項之一,檔案比較小。這次只測了 768,沒有比較不同長度對檢索結果的影響。
實際執行時,我第一次設定一次送 10 段,收到 429(請求太頻繁)。改成一次 5 段,並在收到 429 時等 30 秒再試,29 塊順利算完。上面的程式為了好讀,省略了重試的部分。
def search(question: str, top_k: int = 3) -> list[dict]: # top_k:取最接近的前幾塊
qv = embed([query_text(question)])[0]
scores = chunk_vecs @ qv / (
np.linalg.norm(chunk_vecs, axis=1) * np.linalg.norm(qv))
order = np.argsort(scores)[::-1][:top_k]
# 把分數併進那一塊的資料裡一起回傳
return [chunks[i] | {"score": round(float(scores[i]), 3)} for i in order]
scores 算的是餘弦相似度:兩個向量的方向愈接近,分數愈接近 1。29 塊用 numpy 直接算就夠;資料量和功能需求增加之後,再評估要不要用專門的向量資料庫。
PROMPT = """你是臺灣防災速報 App 的防災知識助理。根據下面的「資料」回答問題。
規則:
- 只能根據資料回答,不得補充資料裡沒有的做法或數字。
- 資料沒有提到使用者問的情況時,明說「這部分我沒有可靠的資料」,並建議撥 119 或洽詢地方防災單位;不得用相近情境的資料硬湊答案。
- 回答簡短,用一般民眾看得懂的話,先講最重要的動作。
- 回答最後另起一行,寫「資料來源:」並列出實際用到的資料的來源名稱;回答「沒有可靠的資料」時不要列資料來源。
資料:
{context}
問題:{question}"""
MIN_SCORE = 0.75
NO_DATA = "這部分我沒有可靠的資料,建議撥 119 或洽詢地方防災單位。"
def answer(question: str) -> str:
good = [h for h in search(question) if h["score"] >= MIN_SCORE]
if not good: # 相似度都太低:不呼叫模型,直接回固定說法
return NO_DATA
context = "\n\n".join(f"[{h['source']}|{h['scenario']}]{h['text']}" for h in good)
resp = client.models.generate_content(
model="gemini-3.1-flash-lite",
contents=PROMPT.format(context=context, question=question))
return resp.text.strip()
MIN_SCORE = 0.75 和最後一條規則的後半句,都是實測之後才定下來的。
9/21 用九個問題測試。六個是知識庫裡有答案的,三個是刻意準備的:知識庫只有「人在電梯旁」的做法,沒有「受困在電梯裡」的;颱風和土石流則完全沒有資料。
第一輪我憑感覺把門檻設在 0.60。各題相似度最高的那一塊:
| 問題 | 最高分 | 找到的那一塊 |
|---|---|---|
| 地震時在浴室怎麼辦 | 0.865 | 浴室、洗澡中 |
| 開車開到一半遇到地震要怎麼做 | 0.850 | 開車中 |
| 規模跟震度有什麼不一樣 | 0.841 | 規模是什麼、和震度的差別 |
| 我阿嬤坐輪椅,地震的時候怎麼辦 | 0.820 | 行動不便者、輪椅、臥床 |
| 海嘯警報跟海嘯警訊差在哪 | 0.817 | 海嘯警報、警訊、消息、報告的差別 |
| 地震時被困在電梯裡面怎麼辦 | 0.807 | 電梯旁 |
| 地震後一直睡不著,很害怕 | 0.796 | 安靜能繫望五字訣 |
| 土石流警戒的時候要往哪裡跑 | 0.701 | 室內一般原則(不相干) |
| 颱風來之前要準備什麼 | 0.675 | 為什麼是趴下掩護穩住(不相干) |
知識庫完全沒有颱風和土石流的資料,最高分卻有 0.675 和 0.701。相似度不會因為「沒有相關內容」就掉到很低,它只是比有答案的題目低一截。我猜的 0.60 完全沒有作用,三塊不相干的資料都被送進了模型。
模型這一關守住了,三題都回答「這部分我沒有可靠的資料」。但回答的最後還是照規則列了來源:「資料來源:中央氣象署、內政部消防署消防防災館、衛生福利部」。一個說「沒有資料」的回答,後面卻掛著三個官方單位的名字,使用者會以為這句話是官方說的。
修正兩處:門檻依實測的分數改成 0.75,落在六個可以回答的問題的最低分(0.796),和兩個完全不相干的主題的最高分(0.701)之間。電梯那一題(0.807)不在這兩群裡,它的問題門檻解決不了,後面說明;規則補上「回答沒有可靠的資料時不要列資料來源」。
| 問題 | 結果 |
|---|---|
| 浴室 | 不要急著離開以免滑倒;泡澡時把頭壓到低於浴缸邊緣,用雙手或臉盆護住頭頸;留意鏡子和置物架。來源:消防防災館 |
| 開車中 | 安全地減速並開警示燈,靠路邊空曠處停車,避開建築物、樹木和高架橋下方,地震停止後再開。來源:消防防災館 |
| 輪椅 | 協助採取「固定、掩護、穩住」:鎖住車輪、維持坐姿壓低身體、用手臂或枕頭護住頭頸,搖晃時不要離開輪椅。來源:中央氣象署 |
| 睡不著、害怕 | 說明五字訣的五個方向;嚴重影響生活時找精神科、身心科醫師或心理師;可撥 1925。來源:衛生福利部(這一題的回答有問題,下一節說明) |
| 海嘯警報與警訊 | 兩者都是預估波高 0.3 公尺以上,差別在 3 小時內或 3 到 6 小時內抵達;另外寫出海嘯警報的第二個發布條件(近海規模 7.0 以上、深度淺於 35 公里)。來源:中央氣象署 |
| 規模與震度 | 規模是地震釋放的能量,只有一個數值;震度是各地搖晃的程度。規模不加「級」,震度才用「級」。來源:中央氣象署 |
| 受困電梯內 | 這部分我沒有可靠的資料,建議撥打 119 或洽詢地方防災單位。(沒有列來源) |
| 颱風、土石流 | 相似度低於門檻,沒有呼叫模型,直接回固定說法 |
九題的回答我逐句對照知識庫,這一次的輸出沒有發現知識庫以外的事實或做法。
電梯這一題值得多看一眼。它的相似度是 0.807,比「睡不著」那題還高,門檻擋不住。最後沒有答錯,靠的是兩件事:昨天整理時照實寫下「官方這一頁只說明人在電梯旁的情況」,以及規則裡那句「不得用相近情境的資料硬湊答案」。門檻只能擋掉完全不相干的問題,「很像但其實沒有答案」的問題要靠資料本身寫清楚。
「地震後一直睡不著,很害怕」這一題,第二輪的回答沒有錯,但順序不對:先講「安、靜、能、繫、望」這種抽象的口訣,接著就談什麼情況要找醫師。一個睡不著的人需要的是現在能做的事。
原因在檢索。知識庫裡有一塊「災後怎麼照顧自己」,內容正是呼吸練習、肌肉放鬆、找信任的人談話、維持作息這些具體做法,但它只靠相似度排第 6(0.703),前 3 名裡沒有它;排第 1 的是五字訣(0.796),因為那一塊短、關鍵字集中。
修正的做法是不讓相似度單獨決定。問題裡出現「害怕」「睡不著」「焦慮」這類情緒詞時,改由規則固定用哪幾塊、什麼順序:先「怎麼照顧自己」,再「什麼時候該找專業協助」,最後「1925 安心專線」。五字訣那一塊不放,因為實測時只要它在,模型就會把口訣當成具體動作。
排序和門檻是兩件事:排序決定先看哪幾塊,門檻決定哪幾塊真的會送進模型。規則選出來的三塊,不能再被 0.75 的門檻過濾,否則等於沒選。
EMOTION_WORDS = ["害怕", "恐懼", "不安", "焦慮", "緊張", "恐慌", "擔心", "難過",
"睡不著", "失眠", "做惡夢", "一直哭", "崩潰", "心情"]
EMOTION_ORDER = ["災後怎麼照顧自己", "什麼時候該找專業協助", "1925 安心專線"]
def is_emotional(question: str) -> bool:
return any(w in question for w in EMOTION_WORDS)
# search() 裡:問題含情緒詞時,不用相似度排序,改用固定的順序
if is_emotional(question):
by_scenario = {chunks[i]["scenario"]: i for i in range(len(chunks))}
order = [by_scenario[k] for k in EMOTION_ORDER if k in by_scenario]
# answer() 裡:規則選的三塊不再用門檻過濾
good = hits if is_emotional(question) else [h for h in hits if h["score"] >= MIN_SCORE]
prompt 也加了一條規則:情緒困擾的問題,先用一句話表示這是災後常見的反應,接著列出資料裡現在就能做的動作(至少三項、一定要包含呼吸練習和肌肉放鬆、只寫資料裡的名稱),再說明資料裡寫的哪些情況需要專業協助,最後附上心理求助電話 1925。
這一段改了六次才穩定,每一次都是實測發現的:
prompt 裡的規則文字,不是資料。回答看起來完全正確,但違反了「只能根據資料回答」這條最基本的規則。修正之後,每次執行都確認三塊真的送進了模型。最後一輪的回答(六處修正之後,整套九題重新執行時的紀錄):
這是災後常見的反應。以下是現在就能做的建議:
* 練習放鬆技巧:呼吸練習、肌肉放鬆、冥想。
* 找信任的人談話或相處。
* 維持正常作息、充分休息。
* 從事閱讀、嗜好等活動轉移注意力。
* 專注在當下做得到、能改善狀況的事。
若持續有明顯症狀(如困擾的回憶、失眠、憂鬱等),且已無法維持日常作息和正常生活,或是症狀嚴重持續 3 天以上,建議尋求精神科、身心科醫師或心理師的專業協助。
請撥打 1925 安心專線。
資料來源:衛生福利部|災後怎麼照顧自己、衛生福利部|什麼時候該找專業協助、衛生福利部|1925 安心專線
資料來源這一行現在列出三塊,門檻修正前只列得出一塊,這也是判斷模型「真的有拿到資料」最直接的方法。改完之後九題全部重新執行一次,其他八題的檢索結果和第二輪完全相同。
(本篇實測於 2026-09-21,情緒類規則的修正與最後一輪於 2026-09-24;embedding 模型 gemini-embedding-2(768 維),生成模型 gemini-3.1-flash-lite,溫度維持預設值。)
gemini-embedding-2 不支援 task_type,用途寫在文字前綴;一次傳多段文字要各自包成 Content,否則會被合併成一個向量。明天 Day 13 讓速報說四種語言:中、英、日、韓。