iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0
Build on Google AI

30 天用 Google AI 打造台灣防災速報 App系列 第 12 篇

Day 12|知識庫沒有颱風的資料,相似度卻有 0.675:用實測決定 RAG 的門檻(RAG 下)

  • 分享至 

  • xImage
  •  

系列:30 天用 Google AI 打造臺灣防災速報 App(Day 12/30)

昨天整理好 29 塊防災知識。今天讓它可以被搜尋,並且讓有資料的回答都附上所根據的來源。流程分三步:把文字轉成向量、用問題找出最接近的幾塊、請模型只根據這幾塊回答。

Embedding:把文字變成向量

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 塊順利算完。上面的程式為了好讀,省略了重試的部分。

檢索: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。

這一段改了六次才穩定,每一次都是實測發現的:

  1. 只把「照顧自己」排到第一位,模型三次都還是拿五字訣當動作,所以五字訣改成不放。
  2. 順序對了,但三次裡有一次結尾寫成「請撥打 119 或洽詢地方防災單位」。這句是規則裡「查無資料」那一條的文字,模型把它借來用了,和 Day 8 遇到的情況一樣。規則改成明寫「心理求助電話 1925,不寫 119」。
  3. 五次都正確用 1925 了,但五次裡只有一次提到呼吸或放鬆。規則加上「至少三項、一定要包含呼吸練習和肌肉放鬆」。
  4. 有呼吸練習了,但模型順手補了資料裡沒有的做法細節,寫成「緩慢且深長地呼吸,感受氣體進入與排出身體」。規則加上「只寫資料裡的名稱,不補做法細節」。
  5. 三次都通過。我以為做完了。
  6. 送審之後才發現最嚴重的問題:前面六組試驗時,規則選出的三塊還是經過 0.75 的門檻,「照顧自己」(0.703)和「1925」(0.711)都被剔除,模型實際上只拿到「什麼時候該找專業協助」這一塊。它寫出的呼吸練習和 1925,來源是 prompt 裡的規則文字,不是資料。回答看起來完全正確,但違反了「只能根據資料回答」這條最基本的規則。修正之後,每次執行都確認三塊真的送進了模型。

最後一輪的回答(六處修正之後,整套九題重新執行時的紀錄):

這是災後常見的反應。以下是現在就能做的建議:

*   練習放鬆技巧:呼吸練習、肌肉放鬆、冥想。
*   找信任的人談話或相處。
*   維持正常作息、充分休息。
*   從事閱讀、嗜好等活動轉移注意力。
*   專注在當下做得到、能改善狀況的事。

若持續有明顯症狀(如困擾的回憶、失眠、憂鬱等),且已無法維持日常作息和正常生活,或是症狀嚴重持續 3 天以上,建議尋求精神科、身心科醫師或心理師的專業協助。

請撥打 1925 安心專線。

資料來源:衛生福利部|災後怎麼照顧自己、衛生福利部|什麼時候該找專業協助、衛生福利部|1925 安心專線

資料來源這一行現在列出三塊,門檻修正前只列得出一塊,這也是判斷模型「真的有拿到資料」最直接的方法。改完之後九題全部重新執行一次,其他八題的檢索結果和第二輪完全相同。

還沒解決的事

  • 門檻只靠九個問題決定。 0.75 是依這九題的分數定的,樣本很少。之後做正式評估時要準備更多問題重新檢查,尤其是問法很口語、很短的問題。
  • 回答有時會漏掉條件。 海嘯那一題,第一輪的回答只講了抵達時間的差別,漏了海嘯警報的第二個發布條件;第二輪有寫到。同一個問題、同一份資料,兩次的完整程度不同。
  • 知識庫的範圍。 颱風、豪雨、土石流、火災的資料還沒有整理,這幾類問題目前一律回答沒有資料。受困在電梯裡的做法(包含聯絡電梯廠商)、輪椅使用者更完整的指引,也要另外找有寫這些的官方頁面補上。
  • 情緒詞的清單是手寫的。 十幾個詞只涵蓋常見的說法,「心裡毛毛的」這種問法目前對不到;反過來,「我已經不害怕了」也會被當成情緒類問題。之後做評估時要用更多問法檢查。

(本篇實測於 2026-09-21,情緒類規則的修正與最後一輪於 2026-09-24;embedding 模型 gemini-embedding-2(768 維),生成模型 gemini-3.1-flash-lite,溫度維持預設值。)

今日小結

  • gemini-embedding-2 不支援 task_type,用途寫在文字前綴;一次傳多段文字要各自包成 Content,否則會被合併成一個向量。
  • 知識庫沒有相關資料時,相似度仍然有 0.675 到 0.701。門檻要看實測的分數來定,這次定在 0.75。
  • 回答「沒有可靠的資料」時不列資料來源,避免使用者誤以為是官方的說法。
  • 「很像但其實沒有答案」的問題門檻擋不住,要靠知識庫照實寫出官方沒說明的部分,加上「不得硬湊答案」的規則。
  • 情緒類的問題不能只靠相似度排序:具體的自我照顧那一塊排第 6,抽象的口訣排第 1。改由規則固定順序,先給現在能做的事,再談求助。規則選的資料不能再被門檻過濾;模型拿不到資料時,會直接用規則裡的例句和電話號碼把回答寫得像是有根據。

明天 Day 13 讓速報說四種語言:中、英、日、韓。


上一篇
Day 11|防災問答的答案從哪裡來:整理官方指引當知識庫(RAG 上)
下一篇
Day 13|「震度 5 弱」怎麼翻成英文:讓速報說中、英、日、韓四種語言
系列文
30 天用 Google AI 打造台灣防災速報 App 共 15 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言