與 Claude 3.5 Sonnet 協作開發「相似文章推薦」
動機
上次【與 Claude 3.5 Sonnet 協作完成開發並部署 RSS Filter 到 Netlify Functions 上】的體驗良好,對於用 AI 來協作開發蠻感興趣的,想藉由這樣的模式做更多事,擴大自己能夠開發的項目範圍。
最近對中文文字處理蠻感興趣的,做了短暫的研究後,發現這幾年自然語言處理在 Transformer 模型出現之後快速的起飛。從五年前的 Bidirectional Encoder Representations from Transformers (BERT) 到最近兩年很紅的的 Generative Pre-Training (GPT),利用預訓練好的語言模型,在大多數的自然語言處理問題就已經有一定的水準。
一直以來,都想在自己的部落格裡做相似文章的推薦,不過之前會卡在不知道怎麼處理中文最適合,所以遲遲沒有下手。在 2024 大型語言模型很流行的今天,感覺直接把整篇文章轉成語意向量(Embedding)就能達到一定的水準,如此簡單直接的方式,也很適合當作一個小練習。
作法
簡單拆解這個任務會有以下步驟:
- 算出每篇文章的語意向量
- 對於每兩篇文章計算向量的距離,距離越小表示越相似
- 對每篇文章選出最相似的 N 筆文章
- 在每篇文章的結尾用 Card View 放最相似的文章
- 最後設定 Github Actions 在文章更新時自動更新語意向量和最相似的文章
Anthropic 本身沒有出 Embedding API,最後在 OpenAI 和 Gemini 選擇文件比較詳細的 OpenAI。
流程上甚至可以直接參考 OpenAI 提供的【Recommendation using embeddings and nearest neighbor search】範例,很接近我們設定的目標,在這個範例還有提供對 Embeddings 做快取的步驟,減少 API 的使用量,減少計算成本。
把現有大概 130 幾篇的文章都用 Text-embedding-3-large
計算語意向量,大概會用到 20 萬的 Token 數,大概花台幣 1 元,單純做這件事的話相當便宜。
過程中有遇到處理 Zola 的 Markdown 的問題,需要額外處理 Front matter,不過只要進一步提供 AI 一個範例和你想取出的部分,就能快速生成堪用的程式碼。
而第二步和第三步的交叉計算與取 K Nearest Neighbor (KNN) 的運算也都是很常見的程式,在遇到效能瓶頸之前生成的代碼基本上都沒問題。
生成上最困難的部分是與 Zola 的整合,一方面因為比較小眾,AI 的答案較容易出現幻覺,給出無法用的代碼;一方面測試上與除錯也不太容易。
之前在做 Google Analytics View Count 的整合時,有發現可以利用 load_data 的方法,去讀取預先算好的檔案。這次也是直接把算好的最相似網頁儲存成一個 Map,用 page.path
當索引鍵,用來取得最相近的網頁的檔案路徑,因為 get_page 需要用檔案路徑當作參數。
1 2 3 4 5 }
當有多個檔案路徑時,我目前用冒號做分隔。
最後就可以在 template 中讀取檔案、分割字串、用 get_page
得到 page
的物件:
1 {% set top_3_nn = load_data(path="static/data/top_3_nn.json") %}
2 {% if top_3_nn[page.path] %}
3 {% set top_3_nn_paths = top_3_nn[page.path] | split(pat=":") %}
4 {% for path in top_3_nn_paths %}
5 {% set page = get_page(path=path) %}
6 {{ macro::card(page=page) }}
7 {% endfor %}
8 {% endif %}
接著是整合進網頁,這邊就是 AI 比我擅長的部分,方法是提供一個類似樣式的圖片,要求 AI 生成 HTML 和 CSS 樣式,而最難的部分也還是 zola 的整合,這次的做法是寫一個 macro,能利用上一步傳入的 page 物件組合出 card view 需要的資訊。
1 {% macro card(page) %}
2 {% if page.extra.image %}
3 {% set image = config.base_url ~ page.path ~ page.extra.image %}
4 {% else %}
5 {% set image = get_url(path="images/default-og-image.webp") %}
6 {% endif %}
7 {% set ancestors = page.ancestors %}
8 {% set breadcrumb = "" %}
9 {% for ancestor in ancestors %}
10 {% set section = get_section(path=ancestor) %}
11 {% if loop.first %}
12 {% set_global breadcrumb = ' ' ~ section.title ~ ' ' %}
13 {% else %}
14 {% set_global breadcrumb = breadcrumb ~ ' > ' ~ ' ' ~ section.title ~ ' ' %}
15 {% endif %}
16 {% endfor %}
17
18
19
20
21
22
23 {{ breadcrumb | safe }}
24 {{ page.date }}
25
26
27 {{ page.title }}
28
29
30
31 {% endmacro card %}
過程中有踩到一個雷是在 Tera 的迴圈裡,如果用 set
去改變變數的值,變動的影響範圍只有在迴圈內(如上圖 highlight 處)。要影響到外面的變數,要使用 set_global
,詳見 Assignments。
最後生成 Github Actions 的 Template 也是很常見的任務,只是有可能會用到比較舊版的 Action 或者是不會自動幫你設定程式語言的版本或是對 Dependency 或結果做快取,這些細節還是需要根據需求額外看文件做調整。
心得
之前阻止自己去開發的原因,主要有三:
- 缺乏先備知識
- 程式開發的初始摩擦力
- 與美觀和使用者介面相關的調整,如 HTML 和 CSS
與生成式 AI 協作可以用很短的時間突破後兩項。在研究先備知識上,只要是夠普遍的知識,跟 AI 做討論出現幻覺的機率也會比較低。在生成式 AI 時代,多嘗試創造擴大能力邊界真的是蠻重要的事。