view 並不會存資料,可以把 view 看成是一種「查資料的方式」。

把 replay 的 query 存成一個 view:

1
2
3
4
5
6
CREATE VIEW order_status_view AS
SELECT
payload->>'order_id' AS order_id,
bool_or(type = 'payment_confirmed') AS is_paid
FROM events
GROUP BY payload->>'order_id';

以後要查 order status 只需要:

1
SELECT * FROM order_state_view

要測試一些效能差異前需要 insert 大量資料的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
INSERT INTO events (id, type, payload, created_at)
SELECT
gen_random_uuid(),
'order_created',
jsonb_build_object(
'order_id', 'order-' || gs,
'total_amount', (random() * 1000)::int
),
now() - (gs || ' seconds')::interval
FROM generate_series(1, 10000) AS gs;

INSERT INTO events (id, type, payload)
SELECT
gen_random_uuid(),
'payment_confirmed',
jsonb_build_object(
'order_id', payload->>'order_id'
)
FROM events
WHERE type = 'order_created'
AND random() < 0.5;

  • 用 event log 的方式記錄「發生了什麼事」,例如發生了 order_createdpayment_confirmedorder_completed ,就能計算出現在 order 的狀態是 completed
  • event log 只會寫,不會改。

events table schema

  • type:發生了什麼事
  • payload:細節(json)
  • created_at
1
2
3
4
5
6
CREATE TABLE events (
id UUID PRIMARY KEY,
type TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

Insert 幾筆 event

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
INSERT INTO events (id, type, payload)
VALUES (
gen_random_uuid(),
'order_created',
jsonb_build_object(
'order_id', 'order-123',
'amount', 100,
'currency', 'TWD'
)
);

INSERT INTO events (id, type, payload)
VALUES (
gen_random_uuid(),
'payment_confirmed',
jsonb_build_object(
'order_id', 'order-123',
'payment_type', 'credit_card',
'amount', 100,
'currency', 'TWD'
)
);

INSERT INTO events (id, type, payload)
VALUES (
gen_random_uuid(),
'order_created',
jsonb_build_object(
'order_id', 'order-456',
'amount', 150,
'currency', 'TWD'
)
);

Replay

用計算的方式算出想要的東西,例如訂單狀態。

1
2
3
4
5
6
7
8
-- 算出所有 order 是否已付款
-- bool_or(...) 是 aggregate function, 只要有一筆資料是 true, 結果就是 true
-- 因為 bool_or(...) 是 aggregate function, 其他要的欄位要出現在 GROUP BY 裡或者也是 aggregate
SELECT
payload->>'order_id' AS order_id,
bool_or(type = 'payment_confirmed') AS is_paid
FROM events
GROUP BY payload->>'order_id';

Insert 大量資料

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
INSERT INTO events (id, type, payload, created_at)
SELECT
gen_random_uuid(),
'order_created',
jsonb_build_object(
'order_id', 'order-' || gs,
'total_amount', (random() * 1000)::int
),
now() - (gs || ' seconds')::interval
FROM generate_series(1, 10000) AS gs;

INSERT INTO events (id, type, payload)
SELECT
gen_random_uuid(),
'payment_confirmed',
jsonb_build_object(
'order_id', payload->>'order_id'
)
FROM events
WHERE type = 'order_created'
AND random() < 0.5;

Heptabase 更新很快,在 NixOS 上安裝最新版的 Heptabase 最好下載 AppImage 後自己寫 nix 安裝。

假設資料夾結構有:

1
2
3
4
home-common.nix
hosts/
pc/
configuration.nix

下載的 AppImage 放在 root directory。

增加 heptabase.nix

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
{
lib,
appimageTools,
fetchurl,
}:
let
pname = "heptabase";
version = "1.82.4";

src = builtins.path {
path = ./Heptabase-${version}.AppImage;
name = "heptabase";
};

appimageContents = appimageTools.extractType2 { inherit pname version src; };
in
appimageTools.wrapType2 {
inherit pname version src;

extraInstallCommands = ''
install -Dm444 ${appimageContents}/project-meta.desktop -T $out/share/applications/heptabase.desktop
install -m 444 -D ${appimageContents}/usr/share/icons/hicolor/0x0/apps/project-meta.png $out/share/icons/hicolor/512x512/apps/${pname}.png

substituteInPlace $out/share/applications/heptabase.desktop \
--replace-fail 'Exec=AppRun --no-sandbox %U' 'Exec=heptabase %U' \
--replace-fail 'Icon=project-meta' 'Icon=${pname}'

'';

meta = {
changelog = "https://github.com/heptameta/project-meta/releases/tag/v${version}";
description = "Visual note-taking tool for learning complex topics";
homepage = "https://heptabase.com/";
license = lib.licenses.unfree;
maintainers = with lib.maintainers; [ luftmensch-luftmensch ];
mainProgram = "heptabase";
platforms = [ "x86_64-linux" ];
sourceProvenance = with lib.sourceTypes; [ binaryNativeCode ];
};
}

hosts/pc/ 底下增加 overlay.nix

1
2
3
self: super: {
heptabase = super.callPackage ../../heptabase.nix { };
}

configuration.nix 增加 overlays

1
2
3
4
5
6
7
8
9
10
{
config,
pkgs,
...
}:
{
nixpkgs.overlays = [
(import ./overlay.nix)
];
}

Home Manager 安裝 heptabase:

1
2
3
4
5
6
in
{
home.packages = with pkgs; [
heptabase
];
}

在已經有 NixOS 設定的情況下,裝新的電腦。

  1. 用 nano 修改 /etc/nixos/configuration.nix,裝好 vim、wget 跟 git
  2. setup 並啟用 flakes
  3. git clone NixOS 設定檔到 home directory 底下
  4. rename /etc/nixos
  5. 建立 soft link /etc/nixos 到 step 3 clone 的 repo
  6. 複製原本的 hardware-configuration.nix 到對應位置

什麼是 ADR?

  • Architecture Decision Records(ADR,架構決策紀錄)是 ThoughtWorks 推廣的一種輕量文件,用來記錄:「我們為什麼在當時做了這個架構決定」
  • 是決策的歷史紀錄
  • ADR 記的是「選擇」,不是「結果」。

ADR 解決什麼問題?

  • 記錄當下是如何做決策的,供未來參考
  • 避免未來的自己心中冒出:「當初是在想什麼?」的疑問
  • 一個決策一頁,10 分鐘寫完

一份 ADR 的基本結構

  1. Title
    用一句話說決定是什麼
  2. Status
    • Proposed(提案)
    • Accepted
    • Deprecated
    • Superseded(被取代)
  3. Context(背景)
    問題是什麼?限制是什麼?
  4. Decision
    決定怎麼做
  5. Consequences
    好處、壞處、風險、未來影響

Markdown Template

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# System ADR-N Title

## Status

Proposed(提案)/ Accepted / Deprecated / Superseded(被取代)

## Context



## Decision



## Consequences



## Related Resources (optional)



---


什麼時候值得寫 ADR?

有以下情況之一就值得寫:

  • 有取捨
  • 有爭議
  • 未來可能後悔
  • 未來的自己可能會問「為什麼」

Refs

  • PostgreSQL 有 FTS 的功能
  • tsvector 資料 type 來存被處理過、可搜尋的文字內容,通常將原始文字經過拆詞、正規化在加上位置資訊變成可以高效比對的搜尋 index。
  • 做搜尋時就用 tsvector 來搜尋,不是去找原始資料
  • 要產生有中文斷詞的 tsvector 欄位需要額外裝 extension,例如 [zhparser](https://github.com/amutu/zhparser)
  • 用 docker 啟動 postgresql 的話,需要自己 build 含有 zhparser 的 image
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    FROM postgres:17.0-bookworm

    RUN apt-get update && apt-get install -y \
    git \
    wget \
    build-essential \
    postgresql-server-dev-17 \
    && rm -rf /var/lib/apt/lists/*

    RUN wget -q -O - http://www.xunsearch.com/scws/down/scws-1.2.3.tar.bz2 | tar xjf - \
    && cd /scws-1.2.3 \
    && ./configure \
    && make install

    RUN git clone https://github.com/amutu/zhparser.git /zhparser \
    && cd /zhparser \
    && make \
    && make install
  • 安裝 extension
    1
    CREATE EXTENSION IF NOT EXISTS zhparser;
  • 設定中文 text search configuration
    1
    2
    3
    4
    5
    6
    7
    8
    # 建立一個叫 zh 的 text search configuration,並指定 parser 用 zhparser
    CREATE TEXT SEARCH CONFIGURATION zh (PARSER = zhparser);

    # 設定斷出來的詞要用哪個 dictionary 處理
    # n,v,a,i,e,l 代表詞性(part of speech),來自 zhparser 分別表示名詞、動詞、形容詞、成語、嘆詞、習慣用語,這個設定的意思是「這些詞性都要拿來搜尋」
    # simple 是 PostgreSQL 內建的 dictionary,它不做 stemming(不改詞形)、不過濾 stop words、看到什麼詞就存什麼。用 simple 是因為中文不需要像英文字尾變化,我們要的就是「詞」本身
    ALTER TEXT SEARCH CONFIGURATION zh
    ADD MAPPING FOR n,v,a,i,e,l WITH simple;
  • 在要做 search 的 table 加入 tsvector 欄位
    1
    ALTER TABLE documents ADD COLUMN fts tsvector;
  • 更新中英文混合的 tsvector 欄位內容
    1
    2
    3
    4
    5
    6
    7
    8
    9
    UPDATE documents
    SET fts =
    setweight(to_tsvector('zh', coalesce(title, '')), 'A') ||
    setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
    setweight(to_tsvector('zh', coalesce(content, '')), 'B') ||
    setweight(to_tsvector('english', coalesce(content, '')), 'B') ||
    -- simple:保險用(防繁中切不好)
    setweight(to_tsvector('simple', coalesce(title, '')), 'C') ||
    setweight(to_tsvector('simple', coalesce(content, '')), 'D');
    • 把 documents 裡的 title + content 分別用中文跟英文斷詞,設定不同權重,合併成 tsvector 存進 fts 這個欄位
    • coalesce(title, '') 表示如果 title 是 NULL 就當成空字串,避免 to_tsvector(NULL) 直接變成 NULL
    • setweight(to_tsvector('zh', coalesce(title, '')), 'A')zh config 切欄位 title 的中文、產生 tsvector、權重設為 A(最高)
    • setweight(to_tsvector('zh', coalesce(content, '')), 'B') 跟上面差在權重是 B(比 A 低)
    • setweight(to_tsvector('english', coalesce(title, '')), 'A') 就是切英文
    • || 是用來合併 tsvector 的
    • 權重會影響搜尋 SQL ORDER BY ts_rank(fts, q) DESC ,權重高的結果會自動排前面
  • 建立 GIN index
    1
    2
    3
    CREATE INDEX documents_fts_idx
    ON documents
    USING GIN (fts);
    • 建個 index 加快搜尋,不然搜尋會慢到死
    • GIN = Generalized Inverted Index
      • 反向索引
      • 不是存一列有什麼文字,而是存「每個詞出現在哪些 rows」
  • 搜尋 query
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    SELECT uuid, title,
    ts_headline(
    'zh',
    content,
    plainto_tsquery('zh', 'Docker 中文搜尋')
    || plainto_tsquery('english', 'Docker 中文搜尋'),
    'MaxWords=100, MinWords=50'
    ) AS snippet
    FROM documents
    WHERE fts @@ (
    plainto_tsquery('zh', 'Docker 中文搜尋')
    || plainto_tsquery('english', 'Docker 中文搜尋')
    )
    ORDER BY ts_rank(
    fts,
    plainto_tsquery('zh', 'Docker 中文搜尋')
    || plainto_tsquery('english', 'Docker 中文搜尋')
    ) DESC
    LIMIT 20
    OFFSET 10;
    • ts_headline 會從 content 內擷取「命中關鍵字附近」的一小段文字
    • fts @@ ( ... ) 是全文搜尋的比對 operator,意思是「這筆文件的索引內容是否符合搜尋條件?」
    • plainto_tsquery('zh', 'Docker 中文搜尋')zh config 把輸入轉成 tsquery
      • 會自動處理空白
    • plainto_tsquery('english', 'Docker 中文搜尋') 同一段輸入用英文規則再解析一次
    • 兩個 plainto_tsquery|| 連接表示 OR
    • ts_rank(fts, tsquery) 是 PostgreSQL 算「相關度分數」,會考量命中幾次、權重跟詞出現的位置。
      • ORDER BY … DESC 排序就能讓相關度高的結果排前面。
      • tsquery 在這裡要再寫一次,因為 WHERE 跟 ORDER BY 是同一層,不能 reuse expression

Ref

OpenWebUI 設定:

在對話把 Heptabase MCP 打開的時候,第一次會 redirect 到 Heptabase 做 authentication,但回到 OpenWebUI 會出現這個 error:

1
OAuth callback failed: invalid_request: 'client_id' and 'client_secret' parameters must not be provided twice

看了下 OpenWebUI 的 code,把 backend/open_webui/utils/oauth.py line 739、742~748 註解起來就能動了~

跑自己編的 OpenWebUI 的方式:

1
bash ./run.sh

這個 script 會 build docker image 並且把 container 跑起來。

目前(2025-12-21)gpt 5.2 model 實驗起來,可以在 Heptabase Journal 加內容、建立新卡片、使用關鍵字提問,但 whiteboard 相關的互動好像不 work。

Open WebUI v0.6.43 可以正常接 Heptabase 的 MCP 了!(2025-12-28 updated)

Ref

去了六個點、找到了四個 cache。一開始是最靠近圓山捷運站的、上次我一眼就看到寶盒但在辦活動、又有工作人員在旁邊就沒拿,這次也有人在旁邊發傳單,暫時先不輕舉妄動XD

接下來就是悠哉的在圓山散步,在昨日世界發現了個蠻棒的平台,平台上有人在拍照,我在附近一直晃來晃去找 cache,最後被蚊子咬得滿頭包……

alt text

接著晃到明日世界,到座標點附近很快就找到啦~這顆非常明顯~經過摩天輪進到自行車道,依著提示也很快找到 Good Old Days

alt text

看地圖在中山橋底下、有個需要攀爬的 cache,不曉得需要爬多高,還是好奇的先去看看了。看一看覺得,嗯,我還是找人一起來在底下幫我掩護(?)好了,我實在有點無法自己那麼顯眼的在那邊爬…… 😅

最後走到福壽宮,雖說困難度是 4,但依照提示的方式找還是蠻快就找到的~

gps

樹德公園是這天最後一個點,走到座標位置看了下提示,四處張望之後心想「一定是這裡!」旁邊有個阿北背對我坐在長椅上,我只好靠近藏寶點、輕手輕腳開手電筒照著照的看半天,繞了一圈又一圈就是沒看到,東看看看西看看,看到阿北都回家了還是沒看到。

照往例,找不到就會翻前面獵友的 log,看著看著覺得一開始認為的藏寶點可能不對。但今天已經找到幾個 cache,有點累了,在想要放棄時,心想「再找最後一次好了!」依照線索看看附近,手電筒照阿照。欸嘿!這裡怎麼有個可疑的小東西咧~輕輕一拉,找到啦!

好吧提示說得沒有錯,是我找錯地方 . _ .

俗話說,獵友的 log 要小心看(俗話並沒有說過這句話)

alt text