iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0
AI Engineering

知識圖譜 : 技能樹式學習歷程系列 第 12

Day 12 — 全站搜尋:不裝 Lunr,自己寫倒排索引

  • 分享至 

  • xImage
  •  

今天要解的問題

兩百多課的內容,我自己都找不到東西。「信賴區間的公式在哪一課?」現在只能靠記憶點進地圖再點課程再翻 sidebar。

需要搜尋。限制:

  • 沒有後端(Day 1),所以是純前端搜尋
  • 要能在 file:// 跑(不能 fetch 索引檔)。
  • 中文——這是最大的技術難點。

為什麼不裝 Lunr

Lunr.js 是最常見的答案,但對中文有個根本問題:它的 tokenizer 是按空白切詞

lunr.tokenizer("信賴區間的計算公式")
// → ["信賴區間的計算公式"]     整句變成一個 token

中文沒有空白。整句變一個 token 意味著只有輸入完全一樣的字串才會命中,搜「信賴區間」找不到「信賴區間的計算公式」。

要修就得自訂 tokenizer——那我還不如自己寫整個索引,反正核心邏輯只有幾十行,還能省掉一個依賴。(Lunr + 中文分詞函式庫 gzip 後大約 30 KB 起跳,而我自己的索引器是 0 KB 執行期程式碼,只有一個資料檔。)

中文分詞:bigram 就夠了

三個選項:

方式 說明 適用性
詞典分詞(jieba) 準確,但要載入幾 MB 詞典 太重
bigram(雙字切分) 「信賴區間」→ 信賴、賴區、區間
單字(unigram) 「信」「賴」「區」「間」 索引爆炸、雜訊太多

bigram 的原理很簡單:任何 ≥2 字的中文查詢,都能被切成一串相鄰雙字。只要索引裡有這些雙字的位置,就能用「全部 bigram 都命中」來判斷是不是子字串。

const bigrams = s => {
  const out = [];
  for (let i = 0; i < s.length - 1; i++) out.push(s.slice(i, i + 2));
  return out;
};
bigrams("信賴區間");   // ["信賴", "賴區", "區間"]

「信賴區間」的三個 bigram 全部出現在同一課 → 那課極可能真的含這個詞。這不是精確比對(「區間信賴」也會命中兩個 bigram),但搭配最後一步的原文驗證就完全準確——見下面的排序邏輯。

英文與數字則照常按空白/邊界切詞,兩種混用:

const tokenize = text => {
  const tokens = [];
  /* 英數詞:長度 ≥ 2,轉小寫 */
  for (const m of text.matchAll(/[a-zA-Z][a-zA-Z0-9]{1,}/g)) tokens.push(m[0].toLowerCase());
  /* 中文(含全形符號濾掉):連續 CJK 段落各自做 bigram */
  for (const m of text.matchAll(/[一-鿿]+/g)) tokens.push(...bigrams(m[0]));
  return tokens;
};

離線建索引

索引是建置期產出,不是執行期計算。這樣執行期零成本。

/* scripts/build-search-index.js */
const fs = require("fs"), path = require("path"), vm = require("vm");

/* 老朋友:用 vm 把瀏覽器 script 當資料讀(Day 3) */
const ctx = { window: {} }; vm.createContext(ctx);
const root = path.resolve(__dirname, "..");
vm.runInContext(fs.readFileSync(path.join(root, "js/models/curriculum.js"), "utf8"), ctx);
for (const f of fs.readdirSync(path.join(root, "js/lessons")).sort())
  vm.runInContext(fs.readFileSync(path.join(root, "js/lessons", f), "utf8"), ctx);

const COURSES = vm.runInContext("COURSES", ctx);
const LESSONS = ctx.window.LESSONS;

/* HTML → 純文字。順手把公式與程式碼區塊整段丟掉:
   LaTeX 原始碼當關鍵字搜不到東西,只會污染索引 */
const strip = html => html
  .replace(/<div class="formula">[\s\S]*?<\/div>/g, " ")
  .replace(/\\\([\s\S]*?\\\)/g, " ")
  .replace(/<[^>]*>/g, " ")
  .replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&")
  .replace(/\s+/g, " ").trim();

/* 文件清單:每課一筆 */
const docs = [];
for (const course of COURSES)
  for (const m of course.modules)
    for (const ch of m.chapters)
      for (const l of ch.lessons) {
        const key = `${ch.id}-${l.id}`;
        docs.push({
          k: key,                                    // 課文 key
          t: l.title,                                // 單元標題
          c: course.id, cn: course.title,            // 所屬課程
          ch: ch.id, chn: `${ch.num} ${ch.title}`,   // 所屬章
          x: strip(LESSONS[key] || "").slice(0, 3000),  // 內文(截斷,控制索引大小)
        });
      }

/* 倒排索引:token → [文件索引…](去重) */
const inv = {};
docs.forEach((d, i) => {
  /* 標題權重高:重複計入,讓標題命中排前面 */
  const text = `${d.t} ${d.t} ${d.chn} ${d.cn} ${d.x}`;
  for (const tk of new Set(tokenize(text))) (inv[tk] = inv[tk] || []).push(i);
});

const out = `/* 自動產生,勿手改:node scripts/build-search-index.js */
window.SEARCH_INDEX = ${JSON.stringify({ docs, inv })};`;
fs.writeFileSync(path.join(root, "js/data/search-index.js"), out);
console.log(`docs=${docs.length} tokens=${Object.keys(inv).length} size=${(out.length/1024).toFixed(0)}KB`);

三個關鍵決定:

  1. 輸出成 window.SEARCH_INDEX = {...} 的 JS 檔,不是 JSON。因為 file:// 不能 fetch(Day 6 同一個理由)。script src 永遠可以。
  2. 公式整段丟棄。LaTeX 原始碼 \frac{\sigma}{\sqrt{n}} 進索引只會產生 fracsigmasqrt 這種雜訊 token,沒人會這樣搜。
  3. 內文截斷 3000 字。索引大小與召回率的取捨——課文的前 3000 字幾乎涵蓋所有關鍵概念,而截斷讓索引檔小了將近一半。

實測輸出約 900 KB(未壓縮),gzip 後約 250 KB。可接受,但不能在每一頁都載入

執行期:搜尋與排序

/* js/views/search-view.js */
const Search = {
  query(q) {
    const idx = window.SEARCH_INDEX;
    if (!idx || !q || q.trim().length < 2) return [];
    const qq = q.trim().toLowerCase();
    const tokens = tokenize(qq);
    if (!tokens.length) return [];

    /* 1. 交集:所有 token 都命中的文件才算候選 */
    let cand = null;
    for (const tk of new Set(tokens)) {
      const posting = idx.inv[tk];
      if (!posting) return [];                       // 任一 token 找不到 → 無結果
      const s = new Set(posting);
      cand = cand ? new Set([...cand].filter(i => s.has(i))) : s;
      if (!cand.size) return [];
    }

    /* 2. 評分 + 原文驗證(消除 bigram 的假命中) */
    return [...cand].map(i => {
      const d = idx.docs[i];
      let score = 0;
      const inTitle = d.t.toLowerCase().includes(qq);
      const inBody  = d.x.toLowerCase().includes(qq);
      if (inTitle) score += 100;                     // 標題完全含查詢字串
      if (inBody)  score += 30;                      // 內文完全含查詢字串
      if (!inTitle && !inBody) score += 5;           // 只有 bigram 命中(可能是分散字)
      score += Math.min(20, (d.x.toLowerCase().split(qq).length - 1) * 3);   // 出現次數
      return { d, score, snippet: this.snippet(d.x, qq) };
    }).sort((a, b) => b.score - a.score).slice(0, 30);
  },

第 2 步是 bigram 準確度的解答。 倒排索引負責快速縮小候選(幾百課 → 幾課),然後用 includes(qq)原文精確驗證來評分。分散字的假命中不會被丟掉(有時候那正是使用者想要的模糊結果),但會排到最後面。

這是搜尋系統的通用架構:便宜的召回 + 精確的排序。自己寫也一樣適用。

摘要(snippet)與高亮:

  snippet(text, q, span = 60) {
    const i = text.toLowerCase().indexOf(q);
    if (i < 0) return text.slice(0, span * 2) + "…";
    const s = Math.max(0, i - span / 2);
    return (s > 0 ? "…" : "") + text.slice(s, s + span * 2) + "…";
  },

  /* 高亮必須跳脫,因為 snippet 來自課文、q 來自使用者輸入 */
  mark(text, q) {
    const esc = s => s.replace(/[&<>"]/g, c => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;" }[c]));
    const i = text.toLowerCase().indexOf(q.toLowerCase());
    if (i < 0) return esc(text);
    return esc(text.slice(0, i)) + "<mark>" + esc(text.slice(i, i + q.length)) + "</mark>"
         + esc(text.slice(i + q.length));
  },

mark() 的跳脫是必要的,不是潔癖。 使用者輸入 <img src=x onerror=alert(1)> 當查詢字串,如果直接組進 innerHTML,就是一個現成的 XSS。這是本專案第二次遇到同一類問題(Day 9 是 localStorage),模式一樣:任何非我撰寫的字串進 innerHTML 前都要跳脫。

UI:/ 聚焦、鍵盤選取

  init() {
    const input = document.getElementById("search-input");
    const panel = document.getElementById("search-results");
    if (!input || !panel) return;
    let items = [], sel = -1, timer = null;

    const run = () => {
      items = this.query(input.value);
      sel = -1;
      panel.innerHTML = items.length ? items.map((r, i) => `
        <a class="sr-item" href="chapter.html?ch=${r.d.ch}&l=${r.d.k.split("-")[1]}" data-i="${i}">
          <div class="sr-title">${this.mark(r.d.t, input.value)}</div>
          <div class="sr-path">${r.d.cn} · ${r.d.chn}</div>
          <div class="sr-snip">${this.mark(r.snippet, input.value)}</div>
        </a>`).join("")
        : `<div class="sr-empty">找不到「${this.mark(input.value, "")}」相關的內容</div>`;
      panel.classList.toggle("open", true);
    };

    /* debounce:中文輸入法組字期間會連續觸發 input */
    input.addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(run, 120); });

    input.addEventListener("keydown", e => {
      const els = [...panel.querySelectorAll(".sr-item")];
      if (e.key === "ArrowDown" || e.key === "ArrowUp") {
        e.preventDefault();
        sel = Math.max(0, Math.min(els.length - 1, sel + (e.key === "ArrowDown" ? 1 : -1)));
        els.forEach((el, i) => el.classList.toggle("cur", i === sel));
        els[sel]?.scrollIntoView({ block: "nearest" });
      } else if (e.key === "Enter" && sel >= 0) { els[sel].click(); }
      else if (e.key === "Escape") { panel.classList.remove("open"); input.blur(); }
    });

    /* 全站快捷鍵:/ 聚焦搜尋(在輸入框內不觸發) */
    document.addEventListener("keydown", e => {
      if (e.key === "/" && !/^(INPUT|TEXTAREA)$/.test(document.activeElement.tagName)) {
        e.preventDefault(); input.focus();
      }
    });
  },
};

debounce 120ms 對中文特別重要:注音/拼音輸入法在組字過程中會不斷觸發 input 事件(「ㄒㄧㄣ」→「新」→「信」…),每次都跑一遍搜尋會明顯卡頓。

Escape 關閉、/ 聚焦、方向鍵選取——這些是搜尋框的基本禮儀,成本很低但少了會很明顯。

索引什麼時候載入

900 KB 的檔案不能無條件塞進每一頁。三個選項:

策略 說明
每頁都載入 簡單,但首頁多 900 KB,不可接受
獨立搜尋頁 search.html 只有那頁載入,但要跳頁
點搜尋框才動態插入 script ✅ 首屏零成本
loadIndex() {
  if (this._loading) return this._loading;
  this._loading = new Promise((res, rej) => {
    if (window.SEARCH_INDEX) return res();
    const s = document.createElement("script");
    s.src = "js/data/search-index.js";
    s.onload = res; s.onerror = rej;
    document.head.appendChild(s);
  });
  return this._loading;
}

動態插入 script 不受 fetch 的 CORS 限制,file:// 下同樣可用。這是純靜態網站做「按需載入」的通用手法(Day 19 會再用一次)。

Promise 快取(this._loading)避免使用者快速點兩次就載入兩份。

踩到的雷

索引檔的體積被公式炸大。 第一版沒有濾掉 LaTeX,索引 1.8 MB。裡面有大量 alphabetafracsqrtsigma 這種 token,各自對應上百課——既沒用又佔空間。濾掉公式與程式碼區塊後直接砍半。索引裡不該有沒人會搜的東西。

new Set(tokenize(text)) 的去重不能省。 一課裡「統計」出現 30 次,倒排表就會有 30 個重複的文件 id。第一版忘記去重,索引大了三成,而且交集運算變慢。

CSP 又來了。 動態插入 script src="js/data/search-index.js" 是同源,script-src 'self' 允許。但如果你把索引改成 evalnew Function 解析(有人會這樣做來省檔案大小),會被 CSP 直接擋死。這是 CSP 的正確行為,不要為了省幾 KB 去放寬它。

驗證

node scripts/build-search-index.js
# docs=256 tokens=48213 size=912KB
node scripts/verify.js            # 確認沒動壞結構
python3 -m http.server 8901

功能檢查:

  • 搜「信賴區間」→ 相關課文在最前面,標題命中優先於內文命中。
  • 搜「confidence」→ 英文也能找到(英數 token)。
  • 搜「區間信賴」(顛倒)→ 有結果但排在後面(bigram 部分命中,原文驗證失敗)。
  • 搜一個字(「統」)→ 無結果(length < 2 的守衛)。
  • / 聚焦、方向鍵選取、Enter 進入、Escape 關閉。
  • <img src=x onerror=alert(1)>不能跳 alert,畫面顯示跳脫後的純文字。
  • 首頁 Network 面板:未點搜尋框前不應有 search-index.js 請求

順手加進 smoke test:

d.get(BASE + "/index.html")
d.find_element(By.ID, "search-input").send_keys("信賴區間")
time.sleep(0.5)
if not d.find_elements(By.CSS_SELECTOR, ".sr-item"):
    fails.append("search: 無結果")

小結與明天預告

今天的重點:

  1. 中文搜尋用 bigram,不用詞典,不用引入分詞函式庫。
  2. 便宜的召回(倒排交集)+ 精確的排序(原文驗證),這是搜尋系統的通用架構。
  3. 索引是建置期產出,輸出成 window.X = {...} 的 JS 檔以支援 file://
  4. 動態插入 script 做按需載入,首屏零成本。
  5. 使用者輸入進 innerHTML 前一律跳脫。

明天做一個很「統計專用」但技術上很有趣的功能:統計數值表。傳統做法是查附表,我要直接算——自己實作 erf、不完全 gamma 與不完全 beta 函數,做出 z/t/χ²/F 的雙向查詢。


上一篇
Day 11 — 深色模式:設計 token 的回報
下一篇
Day 13 — 統計數值表:不查表,直接算
系列文
知識圖譜 : 技能樹式學習歷程19
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言