9.1 KiB
9.1 KiB
Wiki 扩容方案
默认模式(grep + index.md)适用于 < 500 页。超过后启用 SQLite 索引层。 零额外依赖——Python 3 自带 sqlite3 + FTS5。
三档检索策略
| 档位 | 页面数 | 检索方式 | 触发条件 |
|---|---|---|---|
| L0 | < 50 | 读 index.md + 直接读页面 | 默认 |
| L1 | 50-500 | 分层 index + grep 关键词 | 页面数 > 50 时自动切换 |
| L2 | 500+ | SQLite FTS5 + BM25 排序 | 页面数 > 500 或用户手动启用 |
L1: 分层索引(50-500 页)
当页面数超过 50,index.md 拆分为分层结构:
.wiki/{主题}/
├── index.md # 顶层索引(只有分类摘要 + 链接到子索引)
├── entities/
│ └── _index.md # 实体子索引(该目录下所有页面的标题+一句话)
├── concepts/
│ └── _index.md # 概念子索引
├── sources/
│ └── _index.md # 源文件子索引
└── analyses/
└── _index.md # 分析子索引
顶层 index.md 变为导航页:
# {主题} Wiki Index
> 287 pages | Last updated: 2026-04-06
## Overview
- Sources: 45 篇 → [sources/_index.md]
- Entities: 120 个 → [entities/_index.md]
- Concepts: 87 个 → [concepts/_index.md]
- Analyses: 35 篇 → [analyses/_index.md]
## 最近 ingest(最近 10 条)
- 2026-04-06: hrss-policy → 更新 8 页
- 2026-04-05: annual-report → 更新 5 页
...
## 高频实体 Top 10(被引用最多)
- [[alpha-corp]] (引用 23 次)
- [[national-council-ssf]] (引用 18 次)
...
Agent 查询时先读顶层 index 定位分类,再读对应子索引定位具体页面,避免一次加载全部。
L2: SQLite FTS5 索引(500+ 页)
原理
Wiki 页面(markdown)仍然是数据源头。SQLite 只是索引——丢了可以从页面重建。
.wiki/{主题}/
├── search.db # SQLite 索引文件(自动生成,可重建)
├── index.md # 保留(给人浏览用)
├── meta.yaml
└── ...
Schema
-- 页面表
CREATE TABLE pages (
slug TEXT PRIMARY KEY, -- 文件名(去 .md)
type TEXT NOT NULL, -- source/entity/concept/analysis
title TEXT NOT NULL,
content TEXT NOT NULL, -- 正文全文
confidence TEXT DEFAULT 'high',
created TEXT,
updated TEXT,
sources TEXT -- JSON array of source slugs
);
-- FTS5 全文索引(内置 BM25 排名)
CREATE VIRTUAL TABLE pages_fts USING fts5(
title,
content,
content='pages',
content_rowid='rowid',
tokenize='unicode61' -- 支持中文分词
);
-- Wikilink 关系表
CREATE TABLE links (
from_slug TEXT NOT NULL,
to_slug TEXT NOT NULL,
PRIMARY KEY (from_slug, to_slug)
);
-- 触发器:pages 变化时自动更新 FTS 索引
CREATE TRIGGER pages_ai AFTER INSERT ON pages BEGIN
INSERT INTO pages_fts(rowid, title, content)
VALUES (new.rowid, new.title, new.content);
END;
建索引脚本
Agent 在 ingest 完成后自动执行(如果 search.db 存在):
#!/usr/bin/env python3
"""从 wiki markdown 文件重建 SQLite FTS5 索引。"""
import sqlite3, os, re, json, yaml
from pathlib import Path
def parse_page(path):
"""解析 markdown 页面,提取 frontmatter 和正文。"""
text = path.read_text(encoding='utf-8')
if text.startswith('---'):
_, fm, body = text.split('---', 2)
meta = yaml.safe_load(fm)
return meta, body.strip()
return {}, text
def extract_links(content):
"""提取 [[wikilink]]。"""
return re.findall(r'\[\[([^\]]+)\]\]', content)
def build_index(wiki_dir):
db_path = wiki_dir / 'search.db'
conn = sqlite3.connect(str(db_path))
c = conn.cursor()
# 建表(如果不存在)
c.executescript('''
CREATE TABLE IF NOT EXISTS pages (
slug TEXT PRIMARY KEY, type TEXT, title TEXT,
content TEXT, confidence TEXT, created TEXT,
updated TEXT, sources TEXT
);
CREATE VIRTUAL TABLE IF NOT EXISTS pages_fts USING fts5(
title, content, content='pages', content_rowid='rowid',
tokenize='unicode61'
);
CREATE TABLE IF NOT EXISTS links (
from_slug TEXT, to_slug TEXT,
PRIMARY KEY (from_slug, to_slug)
);
''')
# 清空重建
c.execute('DELETE FROM pages')
c.execute('DELETE FROM links')
c.execute("INSERT INTO pages_fts(pages_fts) VALUES('delete-all')")
# 遍历所有 md 文件
for subdir in ['sources', 'entities', 'concepts', 'analyses']:
dir_path = wiki_dir / subdir
if not dir_path.exists():
continue
for f in dir_path.glob('*.md'):
if f.name.startswith('_'):
continue
slug = f.stem
meta, body = parse_page(f)
c.execute(
'INSERT OR REPLACE INTO pages VALUES (?,?,?,?,?,?,?,?)',
(slug, meta.get('type',''), meta.get('title',''),
body, meta.get('confidence','high'),
meta.get('created',''), meta.get('updated',''),
json.dumps(meta.get('sources',[])))
)
for link in extract_links(body):
c.execute('INSERT OR IGNORE INTO links VALUES (?,?)', (slug, link))
# 重建 FTS
c.execute("INSERT INTO pages_fts(pages_fts) VALUES('rebuild')")
conn.commit()
count = c.execute('SELECT COUNT(*) FROM pages').fetchone()[0]
conn.close()
return count
if __name__ == '__main__':
import sys
wiki_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('.')
n = build_index(wiki_dir)
print(f'Indexed {n} pages → {wiki_dir}/search.db')
查询方式
Agent 在 query 操作中,用 Python 查询 SQLite:
#!/usr/bin/env python3
"""BM25 搜索 wiki 页面。"""
import sqlite3, sys, json
def search(db_path, query, limit=10):
conn = sqlite3.connect(db_path)
c = conn.cursor()
# BM25 排序:FTS5 内置,rank 越小越相关
results = c.execute('''
SELECT p.slug, p.type, p.title, p.confidence,
snippet(pages_fts, 1, '>>>', '<<<', '...', 30) as snippet,
rank
FROM pages_fts
JOIN pages p ON pages_fts.rowid = p.rowid
WHERE pages_fts MATCH ?
ORDER BY rank
LIMIT ?
''', (query, limit)).fetchall()
conn.close()
return results
if __name__ == '__main__':
db = sys.argv[1]
q = sys.argv[2]
for slug, type_, title, conf, snippet, rank in search(db, q):
print(f'[{type_}] {title} ({slug}) confidence={conf} rank={rank:.2f}')
print(f' {snippet}')
print()
使用示例:
# 建索引
python3 build_index.py .wiki/enterprise-annuity/
# BM25 搜索
python3 search.py .wiki/enterprise-annuity/search.db "受托人 市场份额"
# 输出
# [entity] Alpha Corp 养老金业务 (alpha-corp) confidence=high rank=-3.42
# ...受托人>>>市场份额<<<约 15%...
# [analysis] 受托人市场格局对比 (trustee-market-comparison) confidence=high rank=-2.87
# ...各>>>受托人<<<的>>>市场份额<<<变化...
反向链接查询
-- 谁引用了 alpha-corp?
SELECT from_slug FROM links WHERE to_slug = 'alpha-corp';
-- alpha-corp 引用了谁?
SELECT to_slug FROM links WHERE from_slug = 'alpha-corp';
-- 最孤立的页面(入链最少)
SELECT p.slug, p.title, COUNT(l.from_slug) as inlinks
FROM pages p
LEFT JOIN links l ON l.to_slug = p.slug
GROUP BY p.slug
ORDER BY inlinks ASC
LIMIT 10;
-- 最核心的页面(被引用最多)
SELECT p.slug, p.title, COUNT(l.from_slug) as inlinks
FROM pages p
LEFT JOIN links l ON l.to_slug = p.slug
GROUP BY p.slug
ORDER BY inlinks DESC
LIMIT 10;
lint 增强
L2 模式下,lint 可以用 SQL 高效执行:
-- 找 contested 页面
SELECT slug, title FROM pages WHERE confidence = 'contested';
-- 找孤页(无入链 + 非 source 类型)
SELECT p.slug, p.title FROM pages p
LEFT JOIN links l ON l.to_slug = p.slug
WHERE l.from_slug IS NULL AND p.type != 'source';
-- 找断链(wikilink 目标不存在)
SELECT l.from_slug, l.to_slug FROM links l
LEFT JOIN pages p ON p.slug = l.to_slug
WHERE p.slug IS NULL;
-- 找过时页面(6 个月未更新 + 低置信度)
SELECT slug, title, updated, confidence FROM pages
WHERE updated < date('now', '-6 months')
AND confidence IN ('low', 'medium');
-- 统计 coverage
SELECT type, COUNT(*) as count,
SUM(CASE WHEN confidence = 'contested' THEN 1 ELSE 0 END) as contested
FROM pages GROUP BY type;
何时升级
| 信号 | 建议 |
|---|---|
| index.md 超过 200 行 | 启用 L1 分层索引 |
| grep 搜索 > 5 秒 | 启用 L2 SQLite 索引 |
| 页面数 > 500 | 必须启用 L2 |
| 需要反向链接查询 | 启用 L2(links 表) |
| 需要 BM25 排序 | 启用 L2(FTS5) |
| 多用户协作 / 向量检索 | 超出 Skill 范围 → 迁移到外部平台 |
升级是非破坏性的——wiki 页面(markdown)不变,只是旁边多了一个 search.db。删掉 search.db,wiki 完整可用,只是退回 grep 检索。