跳至主要内容

用 Embedding 做「相關文章」推薦

· 閱讀時間約 2 分鐘

最近幫部落格做了一個「相關文章」功能,用 AI 的語義向量(Embedding)來計算文章之間的相似度。

原文:《DIY 系列:來做個「相關文章」功能


原理

三個步驟:

  1. 把文章餵給 Embedding 模型,得到一組向量(一串浮點數)。這組向量代表文章的「語意」,語意越接近的文章,向量在空間中的距離也越近。

  2. 計算餘弦相似度,也就是兩個向量之間的夾角,越接近 1 代表越相似。

  3. 排序取前 K 名,就是「相關文章」了。


兩種 Embedding 方案

方案一:Gemini API(免費)

Google AI Studio 申請 API Key,呼叫 gemini-embedding-001 模型。

免費方案有速率限制,我的做法是每篇截取前 2000 字,每隔兩秒呼叫一次。

方案二:BGE-M3 本地端(也免費)

Ollama 在本機跑 BGE-M3,CPU 就能跑,完全離線。

ollama pull bge-m3
pip install ollama
import ollama

res = ollama.embeddings(model="bge-m3", prompt="文章內容...")
vector = res["embedding"]

快取機制

每次重新計算 Embedding 很耗時,所以用 Hash(標題 + 內文) 來判斷文章是否有改變,沒變就直接讀快取。

相似度計算目前是全部重跑(讓新舊文章可以互相連結),一百多篇大概三秒,還可以接受。


完整流程

  1. 載入快取,用 Hash 比對找出需要更新的文章。
  2. 清除已刪除文章的舊快取。
  3. 對需要更新的文章重新 Embedding,存入快取。
  4. 計算所有文章兩兩之間的相似度,排序取前 K 名。
  5. 輸出 related.json 或直接生成靜態 HTML。

如何偵測檔案變動

· 閱讀時間約 2 分鐘

前言

我自己在目前部落格上有三種實際應用場景:

  1. Preview 網頁自動 reload:這個場景是高頻檢查,例如每秒看一次檔案有沒有變。就算偶爾誤判,最多只是多 reload 一次,所以我用檔案修改時間來判斷。這邊我用 Polling 的方式來做,雖然 I/O 比較重一點,但實現起來簡單,也不用去安裝其他套件。

  2. Build 建立靜態網站:這邊的重點是正確性,要精準知道內容是否真的改變,避免不必要重建或漏建,所以我會直接比對全文內容,也就是比對「渲染後的文章」與「輸出資料夾的文章」。

  3. 檔案變動後要打 API:打 API 通常有副作用,不能亂觸發。這時候不一定要知道改了哪裡,只要知道內容真的不同即可,所以我會把 Hash 存起來,如果有比對到 Hash 變更再去打 API。這種做法很優雅,不需要多留一份原始檔案,只要保留原檔案的 Hash 值就好了。

三種方法的差異

方法核心概念速度準確度代價
時間戳比對檔案最後修改時間很快可能會誤判
全文比對逐字比較內容需要讀兩份完整檔案
Hash 比對比較最終內容 Hash需額外保存 Hash 值

1. 比較檔案時間

只要看檔案最後修改時間有沒有變,就能快速判斷「可能有變更」。

  • 優點:極快、實作簡單,適合高頻率輪詢。
  • 缺點:有誤判機會,像是改了 metadata、或某些同步工具重寫時間。

2. 全文比對

直接把新舊檔案內容拿來比,這是最準確的方法。

  • 優點:只要內容一樣,就一定判定「沒變」。
  • 缺點:每次都要讀檔和比對,檔案越大越花時間。

3. Hash 比對

做法是先計算檔案 Hash,把它存起來,下次再算一次做比對。

  • 優點:不需要知道改了哪裡,只要知道內容是否不同,速度與儲存成本都不錯。
  • 缺點:仍然要讀取內容計算 Hash;另外需要管理一份本地 Hash 資料。

實務上,若是有副作用的操作(例如打 API、觸發部署、通知、計費),Hash 很適合。

如何幫網站新增一個 Theme

· 閱讀時間約 2 分鐘

前言

今天想嘗試幫網站做一個 theme。

以前我也曾經做過幾次修改,比如說「聖誕節主題」、「抹茶主題」等等。

原本我都是直接去修改 styles.css,後來發現這樣要做「期間限定」的 Theme 不是很方便。

所以我就把一些常見的顏色改成參數,可以直接用參數改變顏色,直接幫網站換 Theme。但有時候是連結構都會改變,這時候只改變數就不太夠用了(搞得太複雜也不好維護)。

所以呢,以下整理介紹三種常見的 Theme 做法:

1. CSS 變數切換

先把顏色、字體、間距等抽成 CSS 變數(Custom Properties),切換 Theme 時只要替換一組變數值。

/* 預設主題 */
:root {
--color-bg: #fff;
--color-text: #333;
}

/* 另一個 Theme */
:root[data-theme="retro"] {
--color-bg: #f2f2f2;
--color-text: #111;
}
  • 優點: 最輕量,只需維護一份 CSS。
  • 缺點: 只能改到已經參數化的屬性。像 box-shadowborder-radiusorder 這類結構型樣式,通常不夠用。

2. 完整替換整份 CSS

直接維護兩份完整 CSS(例如 default.csstheme.css),切換時整份替換。

  • 優點: 兩份 CSS 完全獨立,不需要考慮覆蓋衝突。
  • 缺點: 維護成本最高,共用元件的修改要同步兩份。

3. CSS 覆蓋(Override)

保留原本的 styles.css,再額外新增一份 theme.css,只覆蓋需要改動的規則。

.post-card {
background: #f2f2f2;
border-radius: 0;
}
  • 優點: 可以覆蓋任意屬性,不受限於變數;原本 CSS 幾乎不用動,載入即套用、移除即還原。
  • 缺點: 需要注意 CSS 優先權(specificity)與載入順序,必要時要調整選擇器權重。

實作上,只要在 HTML 的 <head> 多載入一行:

<link rel="stylesheet" href="/assets/css/styles.css">
<link rel="stylesheet" href="/assets/css/theme.css"> <!-- 加這行 -->

theme.css 直接用相同選擇器覆蓋 styles.css。由於它在後面載入,在相同優先權下會覆蓋前者(CSS cascade)。

如果要做即時切換

可以用 JS 搭配 body class 做 runtime 切換:

document.body.classList.toggle('theme-alt');

這時候,Theme 規則就要加上 body.theme-alt 前綴:

body.theme-alt .post-card { ... }
body.theme-alt .post-title { ... }

如果你要的是「快速上線、可恢復、又能改結構樣式」,通常 CSS 覆蓋會是最平衡的做法。

CSS 圖層魔術

· 閱讀時間約 2 分鐘

今天做了一個很白爛的圖層魔術

按下按鈕後,帽子先往下蓋住,再掀開,裡面會冒出兔子、青蛙,或者乾脆什麼都沒有。

說明

  • position: relative:讓舞台變成絕對定位元素的參考點。
  • position: absolute:把每張圖放到指定座標。
  • z-index:控制遮擋順序。帽子要蓋住角色,所以層級要最高。
  • transition:當 top 被改變時,自動產生移動動畫,做出「蓋住 -> 掀開」的節奏。
  • display: none 雖然簡單,但不能直接做淡入淡出;如果需要更順的切換,可以改用 opacity 搭配 visibility
  • 舞台尺寸最好固定,不然不同圖片尺寸可能會讓圖層位置跑掉。

程式碼

注意

圖片請自備~(或者去文章內右鍵下載)

<div class="magic-stage">
<style>
.magic-stage { position: relative; width: 300px; height: 400px; margin: 0px auto; text-align: center; }
.magic-stage img { position: absolute; transition: top 0.6s ease-in-out; }
.magic-stage .magic-table { width: 280px; left: 10px; top: 250px; z-index: 1; }
.magic-stage .magic-hat { width: 160px; left: 70px; top: 0; z-index: 3; }
.magic-stage .magic-rabbit { width: 80px; left: 110px; top: 170px; z-index: 2; display: none; }
.magic-stage .magic-frog { width: 80px; left: 110px; top: 195px; z-index: 2; display: none; }
.magic-stage .active { display: block; }
.magic-stage button { margin-top: 340px; padding: 10px 30px; cursor: pointer; border: 2px solid #6200ee; border-radius: 4px; background: #6200ee; color: white; font-size: 16px; font-weight: bold; transition: 0.2s; }
.magic-stage button:disabled { background: #ccc; border-color: #ccc; cursor: not-allowed; }
</style>

<img src="table.png" class="magic-table" alt="magic-table">
<img src="hat.png" class="magic-hat" alt="magic-hat">
<img src="rabbit.png" class="magic-rabbit" alt="magic-rabbit">
<img src="frog.png" class="magic-frog" alt="magic-frog">
<button class="magic-button" onclick="performMagic()">Magic</button>

<script>
let magicStep = 0;

function performMagic() {
const magicStage = document.querySelector('.magic-stage');
const magicHat = magicStage.querySelector('.magic-hat');
const magicRabbit = magicStage.querySelector('.magic-rabbit');
const magicFrog = magicStage.querySelector('.magic-frog');
const magicButton = magicStage.querySelector('.magic-button');

magicButton.disabled = true;
magicHat.style.top = "140px";

setTimeout(() => {
magicRabbit.classList.remove('active');
magicFrog.classList.remove('active');

if (magicStep === 0) {
magicRabbit.classList.add('active');
} else if (magicStep === 1) {
magicFrog.classList.add('active');
}

magicStep = (magicStep + 1) % 3;

magicHat.style.top = "0px";
setTimeout(() => magicButton.disabled = false, 600);
}, 1250);
}
</script>
</div>

Cloudflare Pages:使用 Deploy Hook 觸發部署

· 閱讀時間約 1 分鐘

上週遇到一個狀況:

Cloudflare Pages 已經綁定 GitHub,但 git push 之後,沒有自動觸發新的部署(至今仍然不知道為啥)。

後來我又嘗試推了一個空的 commit 上去,結果還是沒觸發(很蠢,我知道)。

查了一下資料,改成使用 Cloudflare Pages 的 Deploy Hook,結果就成功觸發了。

問題

  • Cloudflare Pages 專案已綁定 GitHub。
  • Push 到指定分支後,沒有看到新的部署紀錄(但以前 git push 都有正常觸發)。
  • 網站內容維持舊版本。

處理

改成 Deploy Hook 後,部署有成功觸發,網站也有順利更新。

筆記

重新設計:極簡部落格

· 閱讀時間約 3 分鐘

靈感

昨天看到了「How to make a website in 5 minutes」這篇文章。

突然覺得可以重新設計一版架構,選擇用我喜歡的方式,設計一個非常、非常簡單,功能也完整的部落格架構。

極簡部落格(Demo)

網站架構

最基本的頁面就只有:

  • 首頁
  • 文章列表
    • 文章頁面
  • RSS Feed

架構如下:

blog/
├── index.html # 首頁
├── feed.xml # RSS Feed
├── assets/ # 放全站通用的資源
│ └── style.css # 網站樣式
└── posts/ # 文章目錄
├── index.html # 文章列表頁面
└── 2026-01-01-first-post/
├── index.html # 文章頁面
└── img.webp # 文章圖片

我最早的部落格

我最早的部落格更是離譜,就只有一個頁面:

  • 首頁(文章全部放在這)
blog/
├── index.html # 首頁
└── style.css # 網站樣式

為什麼當初要把文章都塞在 index.html?

我在這篇文章其實有提到過了。

我的「貼文」很短,通常就一兩行而已,感覺沒必要做成單獨頁面。而且如果首頁和內頁都要顯示全文,就等於要維護兩份一模一樣的內容。

而且如果共用區塊有改動,所有文章檔案都要跟著改,這樣維護起來也不太實際。再加上要取英文 slug、要管理檔案命名、要按日期排序...

種種理由加起來,我的藉口就是:「一開始只想做最簡單的可行方案,不想搞得太複雜」。

那麼...沒有做獨立頁面會有什麼問題?

最明顯的就是文章數量越來越多,首頁的長度真的會太長。我原本想用「年度分頁」來解決,但這樣一來 RSS 的文章連結就要每年換一次,不太合理。

而且沒有獨立頁面的話,要分享或引用單篇文章也很不方便,對讀者和搜尋引擎(SEO)都不太友善。


現在回頭想想,首頁根本不需要顯示全文,只要有文章列表就好,一般人都是用 RSS 訂閱,會來網站上的人也會從列表上挑感興趣的文章來讀。

我當初應該是想模仿社群平台一篇一篇貼文可以直接看的感覺才這樣做的。(原本讀者只有我自己)

至於「共用區塊」的部分,其實有很多方法可以解決:

  • 引用 JS 的方式去實現共用區塊(但我沒有很喜歡這個方式,個人偏好問題
  • 共用區塊只放一些確定幾乎不會改的,例如網站首頁的連結
  • 寫一個簡單的 Python 腳本去批次修改,或者文字編輯器其實也做得到
  • 就算真的沒改到,其實也不會怎樣,維持原樣也沒關係
  • 只放返回按鈕就好了,沒有共用部分

那我這次是怎麼解決這個問題的呢......?當然是「只放返回按鈕」啊,夠簡單吧!

教學示範

我可能會再重新寫一篇(或多篇)文章,詳細講一下我是怎麼一步步建立出來的(有需要嗎),以及這樣設計的想法是什麼。

我甚至已經在示範部落格裡面寫了幾篇教學文章,直接把專案抓下來,邊用邊參考也沒問題。

部落格新增 RSS 圖示、文章列表修改

· 閱讀時間約 2 分鐘

新增 RSS 圖示

今天在部落格網站的招牌上新增了 RSS 圖示。

以前曾經想過加在 Menu,但是總覺得有點突兀、不好看,而且長度太長的話,手機上看 Menu 會變成兩行,畫面很怪。

也想過加在 Footer,不過我 Footer 的年份是用 JS 自動產生的(XD),這樣就要改 JS 或是改結構,但最主要還是視覺上怪怪的,可能要全部重新設計過 Footer 才適合放吧。

另外一個點是,我的首頁目前是顯示 「10 篇全文」,這樣要拉到很下面才找得到 RSS,太不友善了。

固定位置 SVG

最後選擇的做法是,先找一個 RSS 的 SVG ,固定放在 Header 右下角的位置。

直接內嵌 SVG 有一個好處,可以透過 CSS 自動調整圖示的大小。

SVG 圖示

<svg width="18" height="18" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-label="RSS 訂閱">
<rect width="24" height="24" rx="4" fill="#E8730A"/>
<circle cx="6.5" cy="17.5" r="2.5" fill="white"/>
<path d="M4 11.5C8.14 11.5 11.5 14.86 11.5 19" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
<path d="M4 6C11.18 6 17 11.82 17 19" stroke="white" stroke-width="2.2" stroke-linecap="round" fill="none"/>
</svg>

CSS

.header-rss {
position: absolute;
bottom: 10px;
right: 10px;
line-height: 0;
}

.header-rss svg {
width: clamp(18px, 3vw, 20px);
height: clamp(18px, 3vw, 20px);
}

文章列表

文章列表的部分,最開始是改成年份做區隔,但我發現我發的文章還挺多的,有分跟沒分差不多。

最後受到 Shuyu Pixelart 部落格的排版啟發,決定改成年+月來分類。

截圖

2026-03-02-screenshot.png

Dual-25:基因演算法策略

· 閱讀時間約 2 分鐘

Dual 25 是 Wiwi 在 睡前想到的紙牌對戰遊戲。(好玩!)

遊戲介紹

好想贏電腦

我(跟 LLM 互動)寫了一個固定策略,嘗試去贏過這個 MCTS 電腦。

接著再測試了一下對戰,以下是模擬結果:

  • 對戰場次:10000 場
    • ✅ 獲勝:5902 (59.0%)
    • 🤝 平手:1217 (12.2%)
    • ❌ 戰敗:2881 (28.8%)

(這樣應該還算不錯吧?)

試試看


訓練:基因演算法(GA)

  • 每種牌型定義 3 個權重參數:

    • 基礎參數base):該牌型的基礎分數
    • 牌型參數v):點數的權重,決定大牌還是小牌更優先
    • 危險參數danger):生命值越低時的傾向調整 = (25 - 當前生命值) / 25,範圍是 0~1
  • 每張牌會根據以下公式計算分數,分數最高的牌就會被選出:

    • attack = atk_base + v × atk_v + danger × atk_danger
    • counter = ctr_base + v × ctr_v + danger × ctr_danger
    • heal = heal_base + v × heal_v + danger × heal_danger
  • 訓練參數設定:

    • 族群大小:150 個個體
    • 演化世代:1000 代
    • 評估局數:每個體每世代評估 800 局
    • 評分機制:勝利 = 1.0 分,平手 = 0.5 分,失敗 = 0 分
    • 訓練對手:採用「隨機出牌」策略,這樣跑比較快(應該也比較不會 Overfitting?),不然用 MCTS 會訓練太久

額外補強

我最後再額外再加上「斬殺(lethal)」的概念。

  • 如果「反擊」或「攻擊」的數值高於對手的血量,則優先選擇斬殺(反擊優先)。
  • 如果有多張牌能斬殺,優先選擇點數小的牌做斬殺。

browser-sync 重複 reload 的解法

· 閱讀時間約 1 分鐘

上次在 更新網站產生器 的時候,有提到預覽功能 browser-sync 變得緩慢的問題。

今天才發現變慢的主因不是 build 產生太多檔案,而是因為偵測到太多檔案變動而不穩定。

一開始我改成偵測到檔案變動之後就去 build,然後產生一個 .reload 的檔案,裡面放 timestamp,這樣 browser-sync 只要去偵測這個檔案就可以了。

但是我也有一些檔案是不需要 build 的阿,這樣又要把路徑區分開來寫在 browser-sync,有點麻煩。

最後決定還是讓 browser-sync 偵測整個 public 資料夾,再加上兩個參數就解決了:

  • --reload-debounce 300:等檔案變動安靜下來再 reload。
  • --reload-delay 100:多等一點時間再刷新,避免被連續事件打爆。

2026/02/25 微更新

還是限制一下範圍比較好,像是這樣:

--files "public/assets/**/*.css"
--files "public/assets/**/*.js"
--files "public/*.html"
--files "content/**/*"

筆記

更新:部落格網站產生器

· 閱讀時間約 3 分鐘

前幾天在調整部落格時,有幾個痛點一直放著沒動,想說乾脆趁這個機會改一改。


痛點一

在生成器出現之前,我編輯的檔案跟最後部署的檔案是同一份,所以編輯時都是用 browser-sync 套件來預覽,只要按下儲存,網頁就會自動更新。

但是在生成器出現之後,如果我想要預覽新文章,就只能選這兩種方法:

  1. 每次想要預覽就只要重新 build 整個網站。
  2. 暫時在 index.htmlpreview.html 寫新文章,等編輯到滿意之後再放到真正的文章檔案,然後再重新 build 一次。

其實使用方法二也沒有多不方便,就是有點繞路而已。

解法

  • 新增 watch.php,監聽 content/articlestemplates 目錄變動,自動觸發 build。
  • 接著再整合 browser-sync,只要有變動,就同步到瀏覽器上。

痛點二

生成器在執行 build 的時候,會把所有文章都重新生成一次,久了會導致 browser-sync 變得緩慢,而且還「沒有刪除功能」,需要自己檢查有沒有殘留的頁面。

解法

這個老實說應該要分開做,正常來說應該是:

  1. build 是正式環境要用的,應該刪除整個資料夾,然後全部重新建構一次,這樣最安全。
  2. dev 是當下預覽用的,第一次啟動要全部 build 一遍,接下來則是根據有變動的檔案去 build 就好。

但我為求簡單,把它整合到一起了,而且 build 的時間基本上只有 0.1 秒,體感上幾乎感受不到。

  • 新增 Smart Write 機制,僅在檔案內容變動時才寫入,避免不必要的 I/O。
  • 新增 cleanStaleFiles(),自動清理多餘的分頁資料夾與文章資料夾(透過 slug 比對)。
  • 新增 summary(),建置完成後顯示更新的檔案列表。

這樣的話,每次 build 雖然會掃過所有文章,但如果只有修改內文,實際上只會生成:

  1. 首頁(或分頁)的文章內容
  2. 文章檔案(含資料夾)
  3. search.json
  4. rss.xml

痛點三

RSS 的 lastBuildDate 欄位每次 build 都會改成最後建置時間,但其實 RSS 內容完全沒變的時候是不需要上版的。

解法

  • RSS 的 lastBuildDate 改為最新文章發布日期,避免每次 build 的時候都變動。

新的發文流程

  • 點兩下 new-article.bat,建立新文章模板,並且自動填入今天日期。
  • 點兩下 auto-sync.bat,自動打開 browser-syncwatch.php,編輯儲存的時候會同步顯示到瀏覽器上。
  • (Optional)保險一點,自己再 build 一次。
  • 最後直接 commit + push 就行了。