自建 LLM 引用率監測 — 3 層架構 Layer 1:採集 Layer 2:儲存 Layer 3:呈現 collector.py SQLite + schema dashboard.html • OpenAI API • Anthropic API • Perplexity API • queries 表 • runs 表 • results 表 • 出現率趨勢線 • 平均位次 • 平台別分群 cron 每天跑 時序紀錄 純 HTML / Plot.ly

為什麼需要第一手數據

GEO 監測工具市場上有兩條路:

路 1:商業 SaaS(Profound、Otterly、Peec 等)幫你跑通用查詢、給你看趨勢、提供 dashboard。優點是上手快、不用工程資源;缺點是它給你的查詢清單是它認為你該追的——不是你目標客戶實際在問的。

路 2:自建第一手量測——你自己決定查哪些問題、用哪個模型、怎麼解析。

兩條路不互斥,但對「真正想理解 AI 怎麼推薦自家品牌」的團隊,第一手訊號不可省。原因:

  • 查詢清單客製化:你的目標客戶的實際提問方式(語氣、長度、上下文)只有你最懂;通用 SaaS 用的是泛化模板
  • 模型版本即時對照:每代新模型上線時,你想知道的是「新模型對我的引用變化」,不是 SaaS 廠商的整合時間表
  • 查詢 + 回應全文留底:之後要追根究柢「為什麼這個月引用率掉」時,有原始 response text 可以分析才追得回去
  • 跨產品資料串接:你的 GA4、表單、CRM 資料要跟 LLM 引用資料 join 分析時,第一手數據才能直接 SQL JOIN

這篇給的是「自己跑、自己存、自己看」的最小架構。不是要你取代商業 SaaS——多數團隊兩條路並行最務實:SaaS 看大盤趨勢,自建看深度問題。

自建會給你什麼 vs 不會給你什麼

自建會給你 自建不會給你
完全可控的查詢清單 跨產業 benchmark(你只看到自家)
完整 response 原文 競爭者的訓練語料覆蓋深度
跟自家其他資料源 join 的能力 解讀 = 知道哪些訊號該追、哪些該忽略
可追蹤的歷史時序 把監測結果轉成 GEO 行動策略

換言之:自建解決資料採集,不解決資料解讀與策略。後者仍需要 GEO 領域知識——這也是顧問服務的核心價值(見文末)。

下面是最小實作架構。


系統架構

┌─────────────────┐
│ collector.py    │ ← 每天 03:00 執行(cron / Task Scheduler)
│ • 讀 queries    │
│ • 呼叫 3 API    │
│ • 解析回應      │
└────────┬────────┘
         │ 寫入
         ▼
┌─────────────────┐
│ monitoring.db   │ ← SQLite,3 張表
│ • queries       │
│ • runs          │
│ • results       │
└────────┬────────┘
         │ 讀取
         ▼
┌─────────────────┐
│ dashboard.py    │ ← 隨時跑(或定期)
│ • 產 HTML       │ ← 用 Plotly 畫趨勢
│ • 開瀏覽器      │
└─────────────────┘

Step 1:環境準備

# Python 3.10+
pip install openai anthropic httpx plotly

.env 檔(永遠不要 commit 進 git):

OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
PERPLEXITY_API_KEY=pplx-...
BRAND_NAME=您的公司名
BRAND_DOMAIN=example.com

.gitignore

.env
*.db
__pycache__/

Step 2:定義你的 20 個目標查詢

queries.yaml

queries:
  - id: 1
    text: "中小企業適用的客戶管理系統推薦"
    category: "product_recommend"
  - id: 2
    text: "2026 年最值得用的 CRM 軟體"
    category: "product_recommend"
  - id: 3
    text: "B2B 行銷自動化工具比較"
    category: "comparison"
  - id: 4
    text: "如何選擇 CRM 廠商"
    category: "how_to"
  # ... 共 20 個

選查詢的原則:

  • 使用者真的會問:避開「我希望被推薦」但實際沒人查的
  • 有業務轉化潛力:問題對應你的產品 / 服務
  • 跨類型分布:產品推薦 / 比較 / 教學 / 趨勢分析等

Step 3:DB schema

schema.sql

-- 查詢清單(穩定)
CREATE TABLE IF NOT EXISTS queries (
    id INTEGER PRIMARY KEY,
    text TEXT NOT NULL,
    category TEXT,
    active INTEGER DEFAULT 1
);

-- 每次執行的 metadata
CREATE TABLE IF NOT EXISTS runs (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    started_at TEXT NOT NULL,
    completed_at TEXT,
    queries_count INTEGER,
    platforms TEXT,
    status TEXT  -- ok / partial / failed
);

-- 個別查詢結果
CREATE TABLE IF NOT EXISTS results (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    run_id INTEGER NOT NULL,
    query_id INTEGER NOT NULL,
    platform TEXT NOT NULL,  -- openai / anthropic / perplexity
    response_text TEXT,      -- 完整回應原文(debug 用)
    brand_mentioned INTEGER, -- 0/1
    brand_position INTEGER,  -- 出現第幾個(沒出現 NULL)
    citation_url INTEGER,    -- 是否含我網域 URL(Perplexity 特有)
    error TEXT,              -- API 錯誤訊息(如有)
    created_at TEXT,
    FOREIGN KEY (run_id) REFERENCES runs(id),
    FOREIGN KEY (query_id) REFERENCES queries(id)
);

CREATE INDEX IF NOT EXISTS idx_results_run ON results(run_id);
CREATE INDEX IF NOT EXISTS idx_results_query ON results(query_id);

Step 4:collector.py — 採集腳本

"""每日跑:對每個 query × 每個 platform 執行查詢,存入 DB。"""

import os
import re
import sqlite3
import time
from datetime import datetime, timezone

import httpx
import yaml
from anthropic import Anthropic
from dotenv import load_dotenv
from openai import OpenAI

load_dotenv()

BRAND_NAME = os.environ["BRAND_NAME"]
BRAND_DOMAIN = os.environ["BRAND_DOMAIN"]
DB_PATH = "monitoring.db"

openai_client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
anthropic_client = Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
perplexity_key = os.environ["PERPLEXITY_API_KEY"]


def init_db() -> sqlite3.Connection:
    conn = sqlite3.connect(DB_PATH)
    with open("schema.sql") as f:
        conn.executescript(f.read())
    return conn


def load_queries(conn: sqlite3.Connection) -> list[dict]:
    """從 yaml 同步到 DB,回傳 active queries。"""
    with open("queries.yaml") as f:
        config = yaml.safe_load(f)
    for q in config["queries"]:
        conn.execute(
            "INSERT OR REPLACE INTO queries (id, text, category, active) VALUES (?, ?, ?, 1)",
            (q["id"], q["text"], q.get("category", "")),
        )
    conn.commit()
    rows = conn.execute(
        "SELECT id, text FROM queries WHERE active = 1"
    ).fetchall()
    return [{"id": r[0], "text": r[1]} for r in rows]


def parse_brand_appearance(text: str) -> tuple[bool, int | None]:
    """從回應文字中找品牌名出現位置。
    回傳 (mentioned, position)。position 是「第幾個列出的選項」。"""
    if not text or BRAND_NAME not in text:
        return False, None

    # 簡化:找出所有條列項,看品牌在第幾項出現
    # 真實情境可改用 LLM 自己解析(成本高但更準)
    items = re.findall(r"^\s*[\d•\-\*]+[\.\)]\s*(.+)$", text, re.MULTILINE)
    for idx, item in enumerate(items, start=1):
        if BRAND_NAME in item:
            return True, idx

    # 沒在條列中但提到 → 視為提及但無排序
    return True, None


def query_openai(query_text: str) -> dict:
    try:
        resp = openai_client.chat.completions.create(
            model="gpt-4o",
            messages=[
                {
                    "role": "user",
                    "content": query_text,
                }
            ],
            temperature=0,
        )
        text = resp.choices[0].message.content or ""
        mentioned, pos = parse_brand_appearance(text)
        return {
            "response_text": text,
            "brand_mentioned": int(mentioned),
            "brand_position": pos,
            "error": None,
        }
    except Exception as exc:
        return {
            "response_text": None, "brand_mentioned": 0,
            "brand_position": None, "error": str(exc),
        }


def query_anthropic(query_text: str) -> dict:
    try:
        resp = anthropic_client.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=1024,
            messages=[{"role": "user", "content": query_text}],
        )
        text = resp.content[0].text if resp.content else ""
        mentioned, pos = parse_brand_appearance(text)
        return {
            "response_text": text,
            "brand_mentioned": int(mentioned),
            "brand_position": pos,
            "error": None,
        }
    except Exception as exc:
        return {
            "response_text": None, "brand_mentioned": 0,
            "brand_position": None, "error": str(exc),
        }


def query_perplexity(query_text: str) -> dict:
    """Perplexity API 與 OpenAI compatible,但會回傳 citation URLs。"""
    try:
        with httpx.Client(timeout=30.0) as client:
            r = client.post(
                "https://api.perplexity.ai/chat/completions",
                headers={"Authorization": f"Bearer {perplexity_key}"},
                json={
                    "model": "llama-3.1-sonar-large-128k-online",
                    "messages": [{"role": "user", "content": query_text}],
                },
            )
            r.raise_for_status()
            data = r.json()
            text = data["choices"][0]["message"]["content"] or ""
            citations = data.get("citations", [])
            mentioned, pos = parse_brand_appearance(text)
            cited_url = int(any(BRAND_DOMAIN in c for c in citations))
            return {
                "response_text": text,
                "brand_mentioned": int(mentioned),
                "brand_position": pos,
                "citation_url": cited_url,
                "error": None,
            }
    except Exception as exc:
        return {
            "response_text": None, "brand_mentioned": 0,
            "brand_position": None, "citation_url": 0, "error": str(exc),
        }


def main() -> None:
    conn = init_db()
    queries = load_queries(conn)

    cur = conn.execute(
        "INSERT INTO runs (started_at, queries_count, platforms, status) "
        "VALUES (?, ?, ?, 'in_progress')",
        (datetime.now(timezone.utc).isoformat(), len(queries),
         "openai,anthropic,perplexity"),
    )
    run_id = cur.lastrowid
    conn.commit()

    fail_count = 0
    for q in queries:
        for platform_name, query_fn in [
            ("openai", query_openai),
            ("anthropic", query_anthropic),
            ("perplexity", query_perplexity),
        ]:
            print(f"[{platform_name}] q={q['id']}: {q['text'][:40]}...")
            r = query_fn(q["text"])
            if r.get("error"):
                fail_count += 1
            conn.execute(
                "INSERT INTO results "
                "(run_id, query_id, platform, response_text, brand_mentioned, "
                " brand_position, citation_url, error, created_at) "
                "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
                (
                    run_id, q["id"], platform_name,
                    r["response_text"], r["brand_mentioned"], r["brand_position"],
                    r.get("citation_url", 0), r.get("error"),
                    datetime.now(timezone.utc).isoformat(),
                ),
            )
            conn.commit()
            time.sleep(1)  # rate limit 友善

    status = "ok" if fail_count == 0 else ("partial" if fail_count < 10 else "failed")
    conn.execute(
        "UPDATE runs SET completed_at = ?, status = ? WHERE id = ?",
        (datetime.now(timezone.utc).isoformat(), status, run_id),
    )
    conn.commit()
    print(f"Run {run_id} done: {status} ({fail_count} errors)")


if __name__ == "__main__":
    main()

Step 5:dashboard.py — 視覺化

"""讀 monitoring.db,產出 HTML dashboard 開瀏覽器。"""

import sqlite3
import webbrowser
from pathlib import Path

import plotly.graph_objects as go

DB_PATH = "monitoring.db"
OUTPUT = "dashboard.html"


def main() -> None:
    conn = sqlite3.connect(DB_PATH)

    # 1. 每日整體出現率(across all platforms + queries)
    rows = conn.execute("""
        SELECT
            DATE(r.started_at) as day,
            res.platform,
            AVG(res.brand_mentioned) * 100 as appearance_rate,
            AVG(CASE WHEN res.brand_position IS NOT NULL
                THEN res.brand_position ELSE NULL END) as avg_position
        FROM runs r
        JOIN results res ON res.run_id = r.id
        WHERE r.status IN ('ok', 'partial')
        GROUP BY day, res.platform
        ORDER BY day
    """).fetchall()

    # 重整成 platform → [(day, rate)]
    platform_data: dict[str, dict] = {}
    for day, plat, rate, avg_pos in rows:
        platform_data.setdefault(plat, {"days": [], "rates": [], "positions": []})
        platform_data[plat]["days"].append(day)
        platform_data[plat]["rates"].append(rate)
        platform_data[plat]["positions"].append(avg_pos)

    # 2. 出現率趨勢圖
    fig1 = go.Figure()
    for plat, data in platform_data.items():
        fig1.add_trace(go.Scatter(
            x=data["days"], y=data["rates"],
            mode="lines+markers", name=plat,
        ))
    fig1.update_layout(
        title="每日品牌出現率 (%) — 各平台",
        xaxis_title="日期", yaxis_title="出現率 (%)",
        height=400,
    )

    # 3. 平均位次趨勢
    fig2 = go.Figure()
    for plat, data in platform_data.items():
        fig2.add_trace(go.Scatter(
            x=data["days"], y=data["positions"],
            mode="lines+markers", name=plat,
        ))
    fig2.update_layout(
        title="平均推薦位次 — 各平台(越低越好)",
        xaxis_title="日期", yaxis_title="平均位次",
        yaxis=dict(autorange="reversed"),  # 1 在上面
        height=400,
    )

    # 4. 最近 7 天每查詢的命中熱圖
    rows = conn.execute("""
        SELECT q.text, res.platform,
               AVG(res.brand_mentioned) * 100 as rate
        FROM results res
        JOIN queries q ON q.id = res.query_id
        JOIN runs r ON r.id = res.run_id
        WHERE r.started_at >= datetime('now', '-7 days')
        GROUP BY q.text, res.platform
    """).fetchall()

    queries_seen = sorted(set(r[0] for r in rows))
    platforms_seen = sorted(set(r[1] for r in rows))
    matrix = [[0] * len(platforms_seen) for _ in queries_seen]
    for q_text, plat, rate in rows:
        i = queries_seen.index(q_text)
        j = platforms_seen.index(plat)
        matrix[i][j] = rate

    fig3 = go.Figure(data=go.Heatmap(
        z=matrix, x=platforms_seen, y=queries_seen,
        colorscale="RdYlGn", zmin=0, zmax=100,
    ))
    fig3.update_layout(
        title="最近 7 天命中率熱圖",
        height=600,
    )

    # 5. 寫 HTML
    html = f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>LLM Citation Monitor</title>
<style>
body {{ font-family: system-ui; max-width: 1200px; margin: 2em auto; padding: 0 1em; }}
h1 {{ color: #1a56db; }}
section {{ margin: 2em 0; }}
</style></head>
<body>
<h1>LLM 引用率監測 dashboard</h1>
<p>更新時間:<code>{__import__('datetime').datetime.now().isoformat()}</code></p>
<section>{fig1.to_html(full_html=False, include_plotlyjs="cdn")}</section>
<section>{fig2.to_html(full_html=False, include_plotlyjs=False)}</section>
<section>{fig3.to_html(full_html=False, include_plotlyjs=False)}</section>
</body></html>"""

    Path(OUTPUT).write_text(html, encoding="utf-8")
    print(f"Dashboard 已產出:{OUTPUT}")
    webbrowser.open(f"file://{Path(OUTPUT).absolute()}")


if __name__ == "__main__":
    main()

Step 6:排程

Linux / macOS(cron)

# 每天凌晨 03:00 跑採集
0 3 * * * cd /path/to/monitor && /path/to/python collector.py >> collector.log 2>&1

Windows(Task Scheduler)

# 透過 schtasks 註冊
schtasks /Create /SC DAILY /ST 03:00 /TN "LLM Monitor" `
  /TR "C:\Python312\python.exe C:\monitor\collector.py"

成本估算

每天 20 query × 3 platform = 60 API 呼叫

平台 模型 約成本 / 月
OpenAI gpt-4o 600 tokens in + 800 out ~$3–5
Anthropic Claude 600 in + 800 out ~$2–3
Perplexity sonar 600 in + 800 out ~$2–3
合計 $7–11/月

加上若用 cloud function 每日跑一次、SQLite 用本地:總成本約 $7–15/月

vs 商業 SaaS $200–500/月,省 95%+


進階改良方向

改良 1:用 LLM 自己解析回應品牌位置

parse_brand_appearance 用 regex 對中文 + 不規則格式 LLM 回應準確度有限。改用一個輕模型(gpt-4o-mini / claude-haiku)讀回應問「品牌 X 在第幾個被提到」,每次成本 + $0.001 但準確度大幅提升。

改良 2:多品牌(含競爭者)追蹤

BRAND_NAME 改成 list(自家 + 5 個主要競爭者),同時追蹤每家在每查詢的出現率與位次。競爭者比對是 GEO 量測中最有說服力的數據。

改良 3:警示通知

當:

  • 某平台出現率連續 3 天下降 > 20%
  • 某高優先 query 連續 7 天 0 出現
  • run 狀態為 failed

→ 寄 email / 推 Slack。

改良 4:歷史回溯與週月報表

  • 週報:每週六自動寄出本週趨勢 + top 改善 / 倒退 query
  • 月報:自動產 PDF(matplotlib 或 weasyprint)寄給老闆

第一步:先做 Step 1–4(最小可行版本)

把 collector.py + queries.yaml + schema.sql 跑起來,連續 7 天有資料。第一週的數字會作為「基線」。

👉 GeoWeb 健檢 提供站內訊號的量化評估,但站外即時引用率需要這種主動量測腳本才看得到。

如果你想做完整客製化監測系統(含多品牌追蹤、警示通知、自動週月報、團隊 dashboard 整合),這是 GEO 顧問服務的範圍:[email protected]


GEO 深度系列。前一篇:「子網域 vs 子目錄 vs 多站架構 — 對 GEO 的影響與決策框架」