Library
这是我的个人知识库,用于整理读过的论文、笔记和项目资料。
内容导航
也可以使用左侧目录或顶部搜索快速查找内容。
Frontmatter Render Test
Frontmatter
- aigc
- false
- created
- updated
- version
- v0.1.0
- docs@example.com
- website
- https://example.com/library
- github
- OpenAI/codex
- huggingface
- openai/gpt-oss-20b
- progress
- 72%
- theme_color
- #7c3aed
- aliases
-
- Frontmatter integration
- 属性卡测试
- description
- 这是一页用于验证 mdbook-frontmatter-render 与现有 obsidian-format
预处理器可以共同工作的真实书库集成测试。
- related
- metadata
-
- owner
- quaternijkon
- verified
- true
- features
-
- safe HTML
- typed values
- root WikiLink
本页用于在真实书库中验证 Frontmatter 属性卡、类型推断、内置图标、嵌套值和根路径 WikiLink。
普通正文应保持不变,且页面不依赖 JavaScript、外部字体或远程图标。
ANNS
Disentangling Graph Dependencies for Efficient Billion-Scale GPU Vector Search
Frontmatter
- aigc
- true
可以。先不要一上来陷进 multi-level LPA、BAR mapping 这些实现细节。**这篇论文真正重要的地方,其实是把图检索里的一个“看似不可打破的串行依赖”重新解释了。**论文是 OSDI 2026 的 Disentangling Graph Dependencies for Efficient Billion-Scale GPU Vector Search,系统名叫 FlowANN。
我建议先建立下面这个整体认识,再逐层深入。
1. 这篇论文一句话在做什么?
让一个放不进 GPU 显存的十亿规模 ANNS 图,仍然能够接近“整个图都在 GPU 上”的速度运行。
它解决的矛盾是:
-
Graph-based ANNS 很快;
-
GPU 做 graph ANNS 更快;
-
但 十亿级 KNN graph 本身太大,GPU 放不下;
-
把 graph 放 CPU、需要时再搬到 GPU,又会因为 graph search 的数据依赖导致 GPU 一直等 PCIe。
FlowANN 的核心方法不是简单地“让 PCIe 更快”,而是:
发现很多 CPU→GPU 数据,其实根本没必要立即拿到。
因此可以:
当前正在计算
↓
GPU -------- compute -------- compute -------- compute
CPU fetch edge A fetch edge B
↑
等真正要用时,数据已经来了
这就是整篇论文最核心的思想。
2. 为什么 graph ANNS 特别难 offload?
先回忆普通的 best-first graph search。
假设一个节点的邻居就是 graph edges:
query
entry → A → B → C → ...
↘ D
↘ E
搜索维护一个按距离排序的 candidate pool:
候选池:
[B: 0.20]
[D: 0.26]
[E: 0.31]
...
每一步:
-
从 pool 中找最近的、尚未展开的节点;
-
把它作为 parent;
-
获取 parent 的 neighbor IDs;
-
计算这些 neighbors 到 query 的距离;
-
插回 candidate pool;
-
选下一个 parent。
论文把节点生命周期定义为两个关键事件:
-
Discovery:这个节点第一次作为别人的 neighbor 被发现;
-
Expansion:这个节点后来自己被选成 parent,开始访问它的 neighbors。
这是理解 FlowANN 的第一个关键概念。论文的 best-first search 正是按照这个方式定义 discovery 和 expansion 的。
3. 传统观点:每一步似乎都必须等 CPU
假设 graph 在 CPU。
展开 A:
GPU: 我要 A 的 neighbors
CPU: A → B,C,D
↓ PCIe
GPU: 算 B,C,D 距离
然后 GPU 才知道:
下一步到底应该 expand B 还是 C?
于是自然会得到一个结论:
Step 1
fetch → compute → sort
↓
Step 2
fetch → compute → sort
↓
Step 3
fetch → compute → sort
也就是说:
step n+1 依赖 step n 全部完成。
论文称之为:
step-level dependency
这也是 BANG 一类 CPU-GPU tiering 方法遇到的根本问题。
因为一次 neighbor list 很小,一般几十到几百 bytes,PCIe 带宽其实不是最主要的问题。
真正的问题是:
你每走一步,都必须等一次小 I/O。
于是 GPU 会大量 stall。
4. FlowANN 最重要的 Insight:这个依赖其实被人为放大了
这是整篇论文最漂亮的部分。
作者问:
一个节点在被 discovered 后,真的马上就会被 expanded 吗?
答案是:
绝大多数不会。
例如:
Step 10:
discover X
Step 11:
expand A
Step 12:
expand B
Step 13:
expand C
Step 14:
expand D
Step 15:
expand X
那么:
X 被发现 X 真正需要自己的 edges
↓ ↓
Step 10 ------------ Step 15
这里有 5 个 step 的空窗
作者把它称为:
Discovery–Expansion Window
定义直观上就是:
W(v)=texpand(v)−tdiscover(v)−1
论文 trace 了大量真实 query 后发现:
大约 95.6% 的搜索步骤中,平均 discovery-expansion window > 5 steps。
而一步 GPU graph search 大约是 6–14 μs。
这就非常关键了。
5. 为什么这个 window 可以拿来隐藏 PCIe latency?
假设:
A → X
传统算法:
expand A
↓
马上拿到 X
↓
discover X
FlowANN 说:
如果我们预计 X 很久以后才会 expand,那么:
expand A
↓
发起异步 fetch
↓
继续:
expand B
expand C
expand D
↓
X 的数据传回来
↓
discover X
↓
后来才 expand X
只要:
Tfetch<W(X)
那么 从最终搜索执行角度看,这个 I/O 几乎是免费的。
这就是论文所谓的:
把 step-level dependency 拆成 node-level dependency
真正的依赖其实只是:
discover(v)→expand(v)
而不是:
整个 Stepi→整个 Stepi+1
这是 FlowANN 最值得记住的一句话。
6. 第二个 Insight:哪些 edges 可以放 CPU?
这里又出现一个很漂亮的 observation。
考虑:
parent P
/ \
A B
距离 P 很近 距离 P 较远
short edge long edge
如果 P 离 query 很近,那么 A 通常也离 query 很近。
于是:
discover A
↓
很可能马上就轮到 expand A
也就是说:
short edge⇒small discovery-expansion window
反过来:
long edge⇒通常更大的 window
论文的 trace 很明显:
-
最短 edge bucket 中,约 17.7% 对应 window 不足;
-
最长 edge bucket 只有 0.63%;
-
总体约 90.6% discovery 都有 >5 steps 的 window。
因此得出第二个核心思想:
短边放 GPU,长边放 CPU。
论文明确指出 long edges 更适合 offload,而 short edges 应保留在 GPU 以保证 timely discovery。
到这里,其实已经理解论文大约 60% 的思想贡献了。
7. 所以 FlowANN 的总体结构是什么?
可以把它理解成:
┌─────────────┐
│ Quantized │
│ vectors GPU │
└─────────────┘
GPU
┌─────────────────────┐
│ short / important │
│ graph edges │
└─────────────────────┘
│
async fetch
│
CPU
┌─────────────────────┐
│ long / deferrable │
│ graph edges │
└─────────────────────┘
每次 expand 一个 parent:
1. select parent
2. GPU resident neighbors
↓
立即计算距离
3. CPU resident neighbors
↓
xCopier 异步 fetch
4. 顺便处理之前已经 fetch 完的 neighbors
5. update candidate pool
论文 Algorithm 1 基本就是:
parent ← nearest unexpanded candidate
async fetch(parent.cpu_neighbors)
ready =
parent.gpu_neighbors
+ previously_fetched_neighbors
必要时等待 overdue neighbors
compute distance(ready)
update candidate pool
所以 FlowANN 不是把 CPU graph 当普通 cache miss 来处理。
它实际上在做:
一个跨 search steps 的 producer-consumer pipeline。
8. 接下来论文为什么还有这么多设计?
因为有了这个核心 idea 后,会立刻遇到三个工程问题。
作者在 Introduction 里也明确把它们列成三个 Challenge:graph tiering imbalance、edge fetching latency,以及 uncoordinated pipelining。
对应就是:
| 问题 | FlowANN 方案 |
|---|---|
| 哪些边放 GPU? | Multi-level LPA graph tiering |
| 小数据 PCIe copy 太慢 | xCopier + BAR/MMIO |
| 延迟 discovery 会不会走错路? | Adaptive synchronization |
| 每 step 工作量忽多忽少 | Cross-step balancing |
| search 开始阶段 window 太小 | better entry points |
理解这个映射后,Section 5/6 就不乱了。
9. Design 1:为什么不能直接按 edge length 排序?
直觉是:
最短的 30% edges 放 GPU。
但作者说这样不行。
因为数据空间密度不同。
例如:
Dense region:
o-o-o-o
|\|/|/
o-o-o
几乎全部是短边
而:
Sparse region:
o -------- o
\
------- o
如果统一设阈值:
∥e∥<d
dense region 可能绝大部分 edges 都 cached;
sparse region 几乎没有 cached edge。
于是不同 query 的 CPU traffic 会严重不均衡。
所以作者不是直接 cache short edge,而是:
grouping
把空间上接近、图结构紧密的节点放入同一 group。
然后规定:
intra-group edge → GPU
10. Multi-level LPA 在干什么?
这里其实可以不用陷入算法细节。
目标只有两个:
目标 A
尽可能让:
short edge⇒same group
目标 B
group size 不能严重失衡。
作者使用 weighted Label Propagation:
短 edge weight 更大:
w(e)=f(length(e))
一个节点倾向加入其 neighbors 总权重最大的 group。
然后做:
原图
↓
LPA coarsening
↓
super nodes
↓
再 coarsen
↓
更小的 graph
↓
partition
↓
uncoarsen
↓
LPA refine
这就是 multi-level LPA。
论文的目的不是提出一个新的通用图划分算法,而是:
建立一个近似的“window-aware cache placement”。
因为真正想知道的是:
P(W(e)<Tfetch)
但这个东西 query-dependent,离线没办法直接预测。
于是用:
edge locality
作为 proxy。
这一点对于读 systems paper 很重要:
LPA 是 mechanism,不是 paper 的核心 insight。
11. 它还顺便压缩了 graph
每个节点原本使用 global ID:
32 bit
如果节点已经分 group,那么 intra-group neighbor 可以使用:
20∼24 bit local ID
所以 GPU 可以塞更多 edges。
此外,grouping 之后每个节点 GPU resident neighbors 数目不同:
node 1: 30
node 2: 25
node 3: 8
node 4: 3
普通固定矩阵会大量 padding。
作者把“neighbor 多”和“neighbor 少”的 node 配成一对,形成 complementary layout。
结果 padding 只有约 0.506%,相比原布局减少约 98.5% 的 padding waste。
这属于很典型的 OSDI engineering:
insight → 引入新结构 → 发现内存碎片 → 再补一个 layout optimization。
12. Design 2:xCopier 可能是这篇论文第二值得看的部分
这里需要先理解一个反直觉现象:
PCIe bandwidth 其实不是问题。
一次 edge list:
64∼256 B
实在太小了。
问题是:
cudaMemcpy()
↓
runtime
↓
driver
↓
DMA setup
↓
PCIe transaction
setup latency 远大于真正传 128B 的时间。
论文测到:
64B∼1KB
cudaMemcpy 需要约:
21∼22μs
这甚至比一个 graph-search step 还长。
13. xCopier 怎么解决?
FlowANN 的 GPU kernel 是一个 persistent / monolithic kernel:
一个 kernel 内部执行很多 search steps
GPU kernel 自己又没办法方便地发起真正的:
async host → device memcpy
于是作者建立:
GPU
┌─────────────────────┐
│ search kernel │
│ │
│ xCopy(...) │
│ ↓ │
│ xQueue │
└────────┬────────────┘
│
│ CPU polling
↓
┌─────────────────────┐
│ xThread │
│ │
│ perform copy │
└─────────────────────┘
GPU 调:
ticket = xCopy(dst, src, size)
CPU thread 看到 queue:
copy...
GPU 后面:
xCheck(ticket)
看看完成没有。
因此搜索 kernel 不需要退出 GPU。
这就是作者所说的:
GPU-initiated, trap-less asynchronous transfer
14. 为什么还不用 DMA,而用 BAR + MMIO?
这是 xCopier 更有意思的部分。
作者把 GPU memory 通过 PCIe BAR 映射进 CPU address space。
于是 CPU 可以:
store [GPU_BAR_address]
直接产生 MMIO write。
不再:
cudaMemcpy
→ runtime
→ driver
→ DMA setup
→ ...
因此小 copy 变成:
CPU SIMD store
↓
PCIe MMIO
↓
HBM
Figure 12 显示,对小数据:
BAR mapping copy 比 cudaMemcpy 最多快约 40×。
完整 xCopier 的 end-to-end edge fetch latency 也降低了约 78–80%。
这个 design 很有 systems 味道:
算法层创造 asynchronous opportunity,OS/hardware 层把它真正实现出来。
15. 但延迟 discovery 会不会把 search path 搞坏?
这是最自然的质疑。
例如正确同步搜索应该:
A
↓
discover X
↓
X 是最近
↓
expand X
↓
找到答案
但 FlowANN:
A
↓
X 还在 PCIe 上
于是:
expand B
expand C
expand D
X 才回来
那么搜索路径显然改变了。
作者的回答不是:
“不会改变。”
而是:
会改变,但不会丢掉候选节点,而且额外 detour 是 bounded 的。
Appendix A 做了三个证明:
-
eventual candidate availability
-
search convergence step bound
-
candidate pool size bound
论文的基本前提是 host-side neighbor 最终都会返回;在 sufficient search budget 下,deferred discovery 只是延迟真正好 candidate 的插入时间,不会永久丢掉它。
论文进一步给出:
∣PFlowANN∣≤T+∑imax(0,τ−W(ei))
其中:
-
T:同步 baseline 的 search steps;
-
τ:fetch latency,以 step 为单位;
-
W(ei):对应 discovery-expansion window。
这个式子非常直观:
如果:
W(ei)≥τ
那么:
max(0,τ−W(ei))=0
即:
I/O 完全被隐藏。
只有:
W(ei)<τ
才产生额外 search detour。
这基本就是这篇论文所有数学推导背后的物理意义。
16. Adaptive Synchronization 是做什么?
虽然理论上 eventually correct,但你不希望:
正确节点迟迟没回来
↓
GPU 疯狂 expand 一堆无用节点
所以系统还是得判断:
什么时候不能继续拖了?
作者观察 candidate pool 中被 expand node 的位置 Pe 与 window 强相关:
corr(Pe,W)=0.77
于是:
W≈αPe
其中 α 通过 offline profiling 得到。
直觉上:
Approach phase
刚开始向 query 靠近:
每次新发现的节点通常更好
于是它很快就被 expand:
Pe≈0W≈0
不能 defer 太多。
Converge phase
已经来到 query 周围:
candidate pool 积累很多近邻
刚 discovery 的节点不一定马上 expand:
Pe↑W↑
就可以大胆 pipeline。
如果 Pe 突然大幅下降,说明:
刚刚突然发现了一个非常好的节点。
那么 FlowANN 就强制等它相关的 CPU edges。
这种 fluctuation 发生概率不到约 3%。
17. Cross-step balancing 又是什么?
Deferred discovery 会带来另一个问题。
以前每一步:
expand 1 parent
↓
固定大约 degree=32 neighbors
现在可能:
Step 1: 12 ready
Step 2: 57 ready
Step 3: 18 ready
Step 4: 73 ready
GPU kernel 的 resources 却是固定配置的。
于是 FlowANN 把一些:
“已经传完,但是还不急着处理”
的节点再次延迟到下一 step。
比如有 32 个 thread teams:
当前 51 个 work items
本 step 做 32
19 个留到下 step
所以这里的 deferred discovery 同时承担了一个功能:
它不仅 hides I/O,还变成 workload shaping 的自由度。
这是一个值得注意的次级 insight。
18. 为什么还需要更好的 Entry Point?
前面说过:
Approach phase
window 很小。
所以 FlowANN 的 pipeline 在 search 刚开始时效果最差。
解决方法非常直接:
不要从随机节点开始。
先找一批 representative nodes,例如 K-means centroid 附近的 representative points:
query
↓
GPU brute force 少量 entry candidates
↓
直接跳到 query 附近
这样把 approach phase 压缩到总 search steps 的约 5%。
这本质是:
用少量 cluster-style routing 辅助 graph traversal。
19. 整个 FlowANN 可以压缩成这一张逻辑图
你现在可以把整篇论文记成:
Billion-scale KNN graph
│
graph 太大,GPU 放不下
│
▼
CPU-GPU graph tiering
│
┌──────────────────┴──────────────────┐
▼ ▼
short/local edges long edges
GPU CPU
│ │
│ asynchronous
│ fetch
│ │
└───────────────┬─────────────────────┘
▼
GPU graph search
│
discovery != expansion
│
exploit discovery-expansion
window
│
┌────────────────┼────────────────┐
▼ ▼ ▼
xCopier Adaptive Sync Cross-step
BAR/MMIO balancing
│ │ │
└────────────────┼────────────────┘
▼
hide PCIe latency
│
▼
billion-scale ANNS / 1 GPU
20. 实验结果到底有多强?
论文主要用:
-
SIFT1B:1B vectors
-
DEEP1B:1B
-
SPACEV1B:1.4B
GPU search 使用 quantized vectors,graph degree 默认 32。
与:
-
cuVS cluster
-
Faiss
-
FusionANNS
-
Rummy
-
BANG
-
FlashANNS
-
CAGRA-UM
-
GGNN multi-GPU
比较。
最值得记的几个数字是:
相比 CPU/GPU-tiered graph system
FlowANN 相比 BANG:
-
batch=2048:平均约 9.52×
-
batch=16:平均约 78.8×
相比 FlashANNS:
- 约 3.71× / 21.8×
论文实验还显示约 96% 的配置下,deferred discovery 根本没有增加 search steps;剩余情况也只增加约 0.7–2.1%。
延迟
batch size 16 时:
0.962 ms/query
P99 latency 相比 baselines 平均降低约:
-
batch 2048:75.4%
-
batch 16:86.1%
单 GPU vs 8 GPU
一个很吸睛的实验是:
单 GPU FlowANN 在 batch 8–1024 时,比 8-GPU GGNN 仍快约 2.22–15.3×。
直到 batch=2048,8-GPU GGNN 才略微超过它。
21. 一个很重要的 sanity check:它离“全图在 GPU”还有多远?
这是我认为论文里非常有价值的实验。
作者拿较小数据集,让 CAGRA:
100% graph → GPU
这是理想 upper bound。
FlowANN 只 cache 大约:
50% edges
结果 throughput 达到全 GPU CAGRA 的:
-
small batch:67.9%
-
large batch:85.4%
这个数字比单纯“快 BANG 78×”更有意义。
因为它说明:
FlowANN 确实成功隐藏了相当大比例的 CPU-GPU 数据传输,而不只是 baseline 很差。
22. 我认为这篇论文真正的贡献应该怎么排序?
如果以论文思想价值来排序,我会这样看:
第一层:最重要
Discovery–Expansion Window
即:
best-first search 的 step-level dependency 其实可以细化成 node-level dependency
这是整篇 paper 的核心 intellectual contribution。
第二层:非常重要
利用 window 对 graph 做 heterogeneous-memory tiering
即:
short edge GPUlong edge CPU
但最终不是直接 edge-length threshold,而用 grouping 实现。
第三层:很强的 systems contribution
xCopier
尤其:
GPU request
→ CPU polling thread
→ BAR/MMIO small-copy
解决 fine-grained CPU-GPU async transfer。
第四层:把 idea 真正变成高性能系统
-
adaptive synchronization
-
cross-step balancing
-
entry-point optimization
-
compact graph layout
这些都是重要 engineering,但不是最根本的新思想。
23. 这篇论文有什么值得质疑的地方?
这里读论文不能只看作者叙事。
我会重点关注下面几个地方。
① 它并不是“完全不牺牲算法行为”
作者说 accuracy 不受影响,这要准确理解。
理论证明依赖:
-
connected graph;
-
finite fetch latency;
-
sufficient search budget;
-
candidate pool 足够大。
Appendix 甚至明确证明 FlowANN 所需 candidate pool 最坏情况下满足:
Usys≤Ubase(1+τ)
论文实际系统中称 τ≈2。
所以并不是说:
任意 candidate pool 下,任意 defer 都严格与同步搜索完全一致。
而是:
额外 detour 和额外 budget 是 bounded 的。
两者有区别。
② 它非常依赖 CPU DRAM
论文环境是:
-
2× Xeon Platinum 8457C
-
2 TB DRAM
-
H20 GPU
因此 FlowANN 解决的是:
HBM capacity bottleneck
不是:
整个系统 memory capacity bottleneck
换句话说:
它是 GPU-memory oversubscription 方案,而不是 DiskANN 式 SSD-scale 方案。
论文自己也明确认为 SSD latency ~100 μs,相比 graph step 6–14 μs 太高,不适合作为这一设计中的 second tier。
③ xCopier 有硬件/驱动依赖
BAR mapping / MMIO 是非常 low-level 的机制。
虽然论文称 AMD 等也支持类似 BAR mapping,且不可用时可以 fallback DMA,但:
真正达到论文性能,需要比较深的 system-stack control。
因此部署复杂度明显高于:
cudaMalloc
cudaMemcpyAsync
④ 论文主要针对 graph memory 是 dominant footprint 的情况
例如 SIFT1B:
graph 占整个索引的大约 93%。
但如果未来是:
1B × 4096-dim embedding
那么 vector 本身可能重新成为 major bottleneck。
作者在 Discussion 中承认这一点,并提出未来可以同时 offload graph 和 vector。
不过这个 scenario 并不是当前实验的主要验证对象。
24. 它和 HNSW 是什么关系?
这点也容易误解。
FlowANN 实现基于 NVIDIA CAGRA / NN-Descent-style KNN graph。
不是一个 HNSW-specific 系统。
但是它的核心前提来自:
best-first graph search
所以理论上适用于其他 proximity graphs。
作者也专门用 FAISS HNSW 验证过:
discovery-expansion window 同样存在。
因此这篇论文真正优化的不是:
CAGRA graph structure
而是:
best-first traversal execution model
这也是为什么作者称其为 disentangling graph dependencies。
25. 如果你只记住这篇论文三个式子
我建议记这三个。
式子一:window
W(v)=texpand(v)−tdiscover(v)
它代表可隐藏 I/O 的 slack。
式子二:什么时候 offload 是免费的
W(v)≥Tfetch
此时 CPU→GPU latency 理论上可以完全 overlap。
式子三:FlowANN 的额外 search work
∣PFlowANN∣≤T+∑imax(0,τ−W(ei))
这揭示整篇论文真正优化的量:
不是降低 PCIe latency 本身,而是让大多数 edge 的有效暴露 latency 变成 0。
26. 用 systems research 的视角看,这是一篇很典型的 OSDI 论文
它的论证链非常完整:
Observation
传统 graph search 看成 step-level dependency
↓
Measurement
95.6% steps 有明显 discovery-expansion window
↓
Insight
dependency 实际可以拆成 node-level
↓
Opportunity
利用 window overlap CPU-GPU transfer
↓
Problem
哪些 edges 可以 delay?
↓
Observation
short edge ↔ small window
↓
Design
graph tiering
↓
New bottleneck
fine-grained cudaMemcpy 太慢
↓
Design
xCopier + BAR/MMIO
↓
New problem
delay 会破坏 search path
↓
Design
adaptive synchronization
↓
Proof
accuracy / convergence / search budget bounded
↓
Evaluation
1 GPU billion-scale
这条 story 本身也很值得学习。
我建议你接下来重点精读 4 个地方
不需要按论文从头看到尾。
-
§3 Disentangling Dependency in Graph Search
这是整篇论文最重要的部分,特别看 Figure 4–6。 -
Figure 7 + Algorithm 1
把 FlowANN 的 runtime execution 真正看懂。 -
§6.1 xCopier
特别看 Figure 10–12,理解为什么普通 DMA 对 64–256B 小传输很差。 -
Appendix A
不要被证明细节吓到,核心只是在回答:
“delay 一个好节点以后,为什么不会把 graph search 永久带偏?”
如果把这四部分吃透,这篇论文基本就真正读懂了。最关键的 conceptual takeaway 是:**FlowANN 把 graph search 中“什么时候发现节点”和“什么时候真正需要该节点”之间的时间差,变成了一种可调度的系统资源。**这比“CPU/GPU 分层存 graph”本身要重要得多。
The Clustering Strikes Back: Building Cost-Effective and High-Performance ANNS at Scale with Helmsman
Frontmatter
- aigc
- true
这篇论文的核心不是提出一种全新的 ANN 索引,而是提出一个很明确的系统观点:
在现代高带宽 NVMe SSD 上,继续把“图索引”搬到 SSD 上可能走错了方向;对于大 top-k 的工业负载,传统上被认为吞吐不足的 clustering-based ANNS,反而更适合 SSD。
论文据此构建了 HELMSMAN:以 SPANN 一类的聚类索引为基础,通过 SPDK 用户态 I/O + Learned Pruning + GPU/分布式构建,试图用一台 all-flash 机器取代大量 DRAM HNSW 机器。
1. 先把论文放到 ANNS 技术谱系里
你可以先这样理解现在几条路线:
| 路线 | 典型工作 | 数据主要在哪里 | 核心搜索模式 |
|---|---|---|---|
| 内存图 | HNSW | DRAM | 图遍历 |
| SSD 图 | DiskANN / Starling / PipeANN | DRAM + SSD | 图遍历 + SSD fetch |
| 聚类 | IVF / SPANN | centroid 在 DRAM,posting list 在 SSD | 找 cluster → 批量读取 |
| 本文 | HELMSMAN | DRAM + 多块 NVMe | SPANN-like clustering + 极致 SSD 优化 |
所以这篇论文真正挑战的是过去一个很常见的认知:
图索引随机访问少,所以图索引更适合 SSD。
HELMSMAN 说:
不一定。
因为现代 SSD 的问题已经从:
“带宽太低”
逐渐转变为:
“带宽其实非常高,但你能不能把它喂满?”
论文给出的例子是:
-
12-channel DDR5:约 456 GB/s;
-
12 × Gen5 SSD:约 144 GB/s;
-
已经达到 DRAM 带宽约 32%;
-
SSD 单位容量价格却只有 DRAM 的约 1/40。
这就改变了 ANNS 的设计权衡。
2. 为什么他们要放弃 HNSW?
小红书原来在线业务大量使用 in-DRAM HNSW。
原因很简单:
-
Search:约 10 ms SLA;
-
Recommendation / Ads:约 5 ms SLA;
-
QPS 达数十万到数百万;
-
top-k 还非常大,经常是 100~3000。
问题在于数据规模已经很夸张:
-
单 search 就可能有约 20B vectors;
-
全部 ANNS infrastructure 已经达到 PB 级 DRAM。
而论文观察到一个有意思的问题:
HNSW 集群并不是因为吞吐需要那么多 DRAM,而是因为必须把整个 index 放进内存。
实际只用了大约 32–43% 的最大吞吐能力。
剩下的大量机器,本质上是:
为了容量,而不是为了算力。
于是他们就想:
能不能把绝大多数 vectors/index 放到便宜 SSD?
3. 为什么 DiskANN / Starling / PipeANN 不行?
这是理解整篇论文最关键的一段。
他们测试了:
-
DiskANN
-
Starling
-
PipeANN
结果图 4 显示:
-
DiskANN / Starling latency 很难进入 5–10 ms SLA;
-
PipeANN 延迟好一些;
-
但吞吐和 HNSW 仍差 10–25×。
论文认为根因不是:
SSD 带宽不够。
而是:
graph traversal 本身具有串行 dependency。
例如:
读 node A
↓
知道下一步去 B
↓
读 node B
↓
知道下一步去 C
↓
读 node C
也就是:
I/Oi+1
依赖
I/Oi
的结果。
因此即使你有:
SSD bandwidth = 144 GB/s
也不意味着你能把 144 GB/s 真正用起来。
4. 这里和 PipeANN 的关系特别重要
从技术思想上看,PipeANN 和 HELMSMAN 的分歧非常鲜明。
PipeANN 的思路
PipeANN 基本接受:
graph traversal 是 dependency-chain。
然后尝试:
把不同阶段 pipeline 起来,把 SSD latency 藏起来。
也就是:
graph search
↓
SSD I/O
↓
distance computation
通过 pipeline overlap。
HELMSMAN 的思路
HELMSMAN 更激进:
既然 dependency-chain 是图算法本身造成的,那为什么还要坚持 disk graph?
直接换成 clustering:
query
↓
一次找到 nprobe 个 clusters
↓
┌──────┬──────┬──────┬──────┐
↓ ↓ ↓ ↓
C1 C2 C3 C4
↓ ↓ ↓ ↓
SSD SSD SSD SSD
这些 I/O:
基本彼此 independent。
所以可以一口气批量发出去。
论文图 5 把这个称作:
Batched and Dependency-Free I/O
这实际上是全文最核心的系统 insight。
5. 为什么以前 clustering 不行,现在又行了?
HELMSMAN 标题叫:
The Clustering Strikes Back
就是这个意思。
SPANN 这样的设计本来就存在:
DRAM
│
centroid graph
│
query ──→ nearest centroids
│
nprobe clusters
↓ ↓ ↓ ↓ ↓
SSD posting lists
│
distance calculation
│
top-k
过去认为它的问题是:
一次 query 要扫描很多 vector,所以 I/O 太多。
但 HELMSMAN 认为现代 NVMe 改变了这个条件:
现在 SSD bandwidth 足够大,扫描多不一定是致命问题。
真正关键变成:
这些 I/O 是否可以并行发出去。
于是论文 Figure 6 有一个非常重要的实验:
随着 SSD:
1→2→4→8→12
SPANN throughput 基本近似线性增长。
12 块 SSD:
≈12×
说明它已经逐渐变成一个:
bandwidth-scalable workload
而不是 latency-bound workload。
6. 但原生 SPANN 还有三个问题
所以 HELMSMAN 并不是“直接拿 SPANN”。
论文找到三个 bottleneck:
问题 1:I/O 软件栈太慢
SPANN:
Application
↓
syscall
↓
Filesystem
↓
Block layer
↓
Device Mapper
↓
NVMe driver
↓
SSD
在论文测试中,传统 Linux I/O 软件栈:
最多占整个 I/O path 约 58%。
甚至比真正访问 SSD 的时间还高。
问题 2:nprobe 选得不好
SPANN 使用类似:
Dist(q,cj)≤(1+ϵ)Dist(q,c1)
这种 fixed pruning。
问题在于:
easy query:
实际需要 100 clusters
却读了 300
hard query:
实际需要 500
却只读了 300
前者浪费 throughput,后者降低 recall。
问题 3:建索引太慢
SPANN 的 clustering:
-
hierarchical k-means
-
balancing
-
boundary replication
-
centroid graph
主要依赖 CPU。
到了 10B 规模可能需要:
数十小时甚至几天。
而小红书的 embedding model 会频繁更新:
-
recommendation:一天可能重建成千上万次 index;
-
search:希望 daily rebuild。
所以这不够快。
7. HELMSMAN = 三个核心模块
因此整篇论文其实非常容易记:
HELMSMAN=SPDK+LLSP+GPU/ElasticBuild
也就是 Figure 8 的整体架构。
8. 第一项技术:ANNS-oriented Storage Stack
这是我认为论文里最“systems”的部分。
他们直接使用:
SPDK
绕过 Linux kernel。
变成:
Search Thread
│
↓
NVMe Submission Queue
│
↓
SSD hardware
│
↓
NVMe Completion Queue
应用直接:
-
submit NVMe command;
-
poll completion。
数据怎么放?
他们把数据分两类。
Metadata
比如:
-
index name
-
centroid graph
-
pruning model
-
cluster → SSD/LBA mapping
比较小。
正常放 filesystem。
Posting Lists
这是热点路径:
直接写 raw NVMe logical blocks。
例如:
SSD 0
cluster 0 → LBA 100~102
cluster 3 → LBA 103~105
SSD 1
cluster 1 → LBA 100~102
cluster 5 → LBA 103~105
每个 cluster:
-
连续布局;
-
不跨 SSD;
-
一次 I/O 读完。
Figure 10 就是在画这个结构。
9. 为什么这种布局特别适合 SPANN?
因为 SPANN 本身有一个很方便的性质:
cluster 会被 balance 到大致固定大小。
例如论文举 SIFT:
cluster size ≈ 12 KB
所以:
每个 I/O ≈ fixed-size
这对 NVMe 非常友好:
-
地址容易算;
-
allocator 简单;
-
每个 cluster 一条 command;
-
batch submission 很方便。
他们甚至自己做了:
64 MB chunk-based allocator。
10. 一个很细但很重要的优化:Doorbell batching
正常情况下:
command 1 → doorbell
command 2 → doorbell
command 3 → doorbell
...
HELMSMAN:
enqueue cmd1
enqueue cmd2
enqueue cmd3
...
↓
knock once
也就是 Figure 10 中:
Batched Enqueue → Single Knock
这样减少 PCIe MMIO / doorbell 开销。
这是典型的 storage-system 优化,而不是 ANN 算法优化。
11. 第二项技术:Leveling-Learned Search Pruning
这部分名字比较复杂,实际上思想非常直观。
它要解决:
每条 query 到底应该读多少 clusters?
也就是预测:
nprobe
12. 为什么不能直接用 Early Termination?
已经有不少工作:
-
LAET
-
Quake
-
Auncel
可以:
scan cluster
↓
看看结果够不够
↓
不够
↓
scan next
但 HELMSMAN 明确说:
这对 SSD clustering 是不好的。
因为会重新引入 dependency:
I/O
↓
compute
↓
decision
↓
I/O
↓
compute
↓
decision
于是又破坏了:
batched dependency-free I/O。
这个设计约束很关键。
HELMSMAN 要做到:
I/O 发出去之前就决定好到底读哪些 cluster。
13. LLSP 实际上是两级预测
Figure 11 非常值得看。
第一层:Router model
输入:
(query, top−k)
预测一个粗粒度 level。
例如:
L0 = 64
L1 = 128
L2 = 192
L3 = 256
...
L15 = 1024
假设预测:
L3
意味着:
nprobe≤256
14. 第二层:Pruning Model
系统先找到 top-256 centroids。
然后构造特征:
query vector
top-k
d0
d1/d0
d2/d0
...
d255/d0
也就是 centroid-distance distribution。
然后对应的:
L3 pruning model
进一步预测:
nprobe = 193
最终:
一次性 batch read 193 个 clusters。
所以:
query
↓
Router
↓
256 upper bound
↓
centroid search
↓
distance-distribution features
↓
pruning model
↓
193 clusters
↓
一次性批量 I/O
15. 为什么还要两级,而不是直接预测 nprobe?
这里虽然论文没有把它形式化成理论,但设计逻辑比较清楚。
Router 解决的是:
query 大概有多难。
Pruner 解决:
在这个 difficulty range 内,具体应该读多少。
这样:
-
模型更容易训练;
-
每个 level 范围有限;
-
feature dimension 也更受控制。
他们用的是:
GBDT / LightGBM-style model。
而不是神经网络。
原因也很现实:
-
inference:约 10–30 µs;
-
model:数百 KB;
-
training:百万样本约分钟级。
所以非常适合在线 path。
16. LLSP 是怎么训练的?
这里值得稍微认真理解。
他们并没有真的 brute-force 计算 ground truth。
而是先:
nprobe=4096
跑一次大范围搜索,把它当近似 reference。
然后对每个 query:
Router label
找:
最小的 level,使得 recall ≥ target recall。
例如:
64 → 70%
128 → 84%
192 → 89%
256 → 91%
target = 90%。
于是 label:
L3 = 256
Pruner label
再从 256 开始往下降:
256 → 91.2%
240 → 91.1%
220 → 90.7%
200 → 90.2%
193 → 90.0%
192 → 89.9%
于是:
label=193
模型学习的实际上就是:
满足 recall 约束的 minimum nprobe。
这是一种比较干净的 supervised formulation。
17. 第三项技术:GPU + Elastic Index Construction
HELMSMAN 的 offline build 也分三阶段。
Stage 1:GPU coarse clustering
使用 GPU 做 k-means。
论文当前实现基于:
Faiss。
但他们没有完全在 GPU 上完成所有 clustering。
原因是对于很小的 cluster:
GPU kernel + CPU↔GPU transfer 的 overhead 反而不划算。
所以只是 coarse clustering。
18. Stage 2:CPU fine clustering + padding
完成:
-
cluster balancing;
-
further split;
-
boundary duplication;
-
RNG checking。
这里分两种情况。
小规模
例如:
N<0.1B
直接使用 GPU server 的 local CPU。
这样避免:
-
network transfer;
-
distributed scheduling。
超大规模
比如 search 的 10B:
使用在线 CPU 集群在 off-peak 时段的闲置 CPU。
也就是所谓:
elastic CPU workers
例如晚上机器比较闲:
online serving CPU
+
idle CPU cores
↓
index construction
如果在线流量突然来了:
offline build 会被 preempt。
所以在线 SLA 优先。
19. Stage 3:Final merge
最后多核 CPU server:
-
merge posting lists;
-
构建 centroid graph;
-
训练 pruning model;
-
写 metadata;
-
生成最终 deployable index。
因此完整 pipeline 是:
Raw Vectors
│
▼
GPU coarse K-means
│
▼
CPU fine split / balance
│
▼
boundary padding
│
▼
merge
│
├─ posting lists
├─ centroid graph
├─ metadata
└─ pruning models
20. 最重要的性能结果
论文报告的 headline 是:
相比现有 DRAM-SSD ANNS,HELMSMAN throughput 提升 2–16×。
并且在满足 latency SLA 时:
可以达到 in-DRAM HNSW 最高约 85% throughput。
不过这个 85% 要看具体场景。
21. 和 HNSW 的比较尤其值得注意
Figure 17。
对于较小 dataset:
20M ~ 0.5B
HNSW 可以放进同一台 96-core machine。
HELMSMAN 大约达到:
25%−70%
HNSW throughput。
所以:
HELMSMAN 本身并没有比 DRAM HNSW 快。
这个要非常明确。
但是到:
10B
HNSW 需要:
10 shards
320 CPU cores
2.5 TB DRAM
而 HELMSMAN:
1 machine
96 cores
160–330 GB DRAM
SSD
却可以达到:
47%−85%
distributed HNSW throughput。
这时候 economics 就完全不一样了。
22. SSD 利用率是论文非常关键的 evidence
Figure 18。
其他系统 SSD bandwidth utilization 大约:
graph systems < 20%
SPANN ~55%
HELMSMAN Gen4 ~85%
HELMSMAN Gen5 ~70%
而升级:
Gen4 → Gen5
之后:
-
DiskANN / Starling / PipeANN:只有 10–30% improvement;
-
SPANN:约 40%;
-
HELMSMAN:55–87%。
这实际上验证了他们最核心的论点:
HELMSMAN 已经更接近真正的 bandwidth-bound system。
23. Learned pruning 到底有没有用?
有,但没有 storage stack 那么革命性。
Figure 19:
相比完全不 pruning:
1.1−1.6×
throughput improvement。
相比 SPANN fixed pruning:
大约再提升 5–25%。
更有意思的是 recall stability。
固定 pruning 时:
超过 40% queries 可能没有达到目标 90% recall。
LLSP:
超过 80% queries 达到 target recall。
也就是说它优化的不只是平均 recall,而是:
per-query recall distribution。
24. Index build 性能
原始 CPU:
0.1B
需要:
约 9–12 小时。
4 × NVIDIA L20:
降到 1 小时以内。
10B:
借助大约:
1024→104
CPU cores 的 elastic scaling:
整体约 4–7 小时。
这个数字对他们 daily rebuild 的 production model 非常重要。
25. Cost 才是这篇论文最终真正想证明的东西
看 RedSrch10B。
HNSW
DRAM: 2.5 TB
Throughput: 23 KQPS
cost efficiency: 1.2 QPS/$
HELMSMAN
DRAM: 0.16 TB
SSD: 3.2 TB
Throughput: 19 KQPS
cost efficiency: 10 QPS/$
也就是:
8.3×
cost efficiency。
DRAM:
减少约 90%。
26. 更夸张的是 production deployment
论文声称当前已经:
大约 40 台 all-flash machines
承载了原来需要:
-
35,000 CPU cores
-
约 0.35 PB DRAM
的 online workload。
未来希望大规模替换原来的 in-DRAM deployment。
这也是这篇工作价值比较高的地方:
它不是只在 SIFT 上展示 benchmark,而是有真实 production deployment。
27. 这篇论文最值得你记住的技术观点
我会把全文浓缩成下面这个变化:
旧时代
SSD:
low latency? no
high bandwidth? no
所以目标:
尽量少做 I/O。
因此 graph ANNS 有优势。
现代 Gen5 SSD
SSD:
latency 仍然远差于 DRAM
但是 bandwidth 已经很高
于是新的目标变成:
不要串行地做少量 I/O,而要并行地做大量 I/O。
这恰好是:
Graph
serialized
dependent I/O
和
Clustering
batched
independent I/O
的根本区别。
所以 HELMSMAN 的真正 thesis 可以写成:
Modern SSD ANNS should optimize for bandwidth, not merely minimize I/O count
这比它具体用了 SPDK 或 GBDT 更值得记。
28. 我怎么看它的“创新量”
这篇论文不是一种新的 ANN algorithm。
如果只看算法:
-
clustering:SPANN 已有;
-
centroid graph:已有;
-
boundary replication:已有;
-
GBDT:已有;
-
GPU K-means:已有;
-
SPDK:已有。
它真正的贡献是:
重新判断硬件趋势,然后把这些已有组件按照新的 bottleneck 组合成一个 production system。
所以它是一篇很典型的 systems paper:
workload characterization
↓
发现旧 assumption 已失效
↓
重新选择 algorithmic primitive
↓
找 bottleneck
↓
storage / ML / GPU co-design
↓
production validation
29. 但论文也主动暴露了几个局限
这一节其实很值得读。
① SSD hotspot
某些 query 会集中访问相同 clusters。
即使 SSD 总 bandwidth 没满,也可能出现:
NAND die-level conflict。
他们最后通过:
给热门 cluster list 做少量 replica
把 throughput ceiling 提高了:
1.5−2×
这说明“dependency-free”不等于完全没有 storage contention。
② Memory bandwidth 又成了瓶颈
12 × Gen5:
≈140GB/s
但只能实际利用约 70%。
为什么?
因为 DRAM bandwidth 还要同时服务:
SSD → DRAM
DRAM → CPU
centroid search
distance calculation
最终 12-channel DDR5 的有效 300–350 GB/s 先满了。
所以他们测试 20 块 Gen5 SSD 时:
再加 SSD 已经没什么意义。
这其实暗示了下一代设计可能会转向:
SSD → GPU / cache direct path
论文也明确提到了这一方向。
③ Dynamic update 仍然没有解决
HELMSMAN 本质仍是:
rebuild-based system
目前更新方案:
main SSD index
+
small in-memory HNSW/IVF delta index
+
tombstone bitmap
query 同时搜索二者然后 merge。
所以如果你研究 dynamic ANNS:
这篇并没有真正解决 update。
论文自己也承认这是 future work。
30. 如果你只想记住一张图,就记 Figure 8
可以把整个系统记成:
HELMSMAN
│
┌──────────┴──────────┐
│ │
Online Offline
│ │
│ GPU coarse KMeans
│ │
Router model Elastic CPU
│ balance / padding
centroid graph │
│ final merge
Pruning model │
│ pruning training
│
batch cluster IDs
│
▼
SPDK
│
┌──────┼───────┐
SSD0 SSD1 ... SSD11
│
▼
distance calculation
│
▼
top-k
这基本就是整篇论文。
最后给你一个一句话版本
HELMSMAN 不是在 SSD 上把 HNSW 做得更快,而是认为现代 SSD 已经使 ANNS 的最佳算法形态发生变化:与其费尽心思隐藏 graph traversal 的串行 SSD latency,不如回到 clustering,让大量 posting-list I/O 天然并行,再利用 SPDK 把 SSD bandwidth 真正吃满。
我认为接下来阅读这篇论文时,最值得进一步深挖的是 Figure 4→6 这段论证:它决定了“为什么 clustering suddenly wins”,也是整篇论文是否站得住脚的根基。之后再看 LLSP 和 GPU build,就会容易很多。
High-Throughput, Cost-Effective Billion-Scale Vector Search with a Single GPU
可以。先不要陷进 CUDA kernel 和实验参数里,这篇论文真正需要先抓住的是它的系统观念变化:
GustANN 认为:在“追求极高吞吐量”的 SSD 图 ANN 场景里,瓶颈不一定是 SSD,而可能是 CPU 根本产生不了足够快的图搜索计算与 I/O 请求。于是它把 graph traversal 整体搬到 GPU,只让 CPU 做 I/O 搬运辅助。
论文是 GustANN: High-Throughput, Cost-Effective Billion-Scale Vector Search with a Single GPU,SIGMOD 2025。
1. 一句话理解 GustANN
它的硬件结构可以概括成:
┌──────── GPU ────────┐
│ PQ vectors │
Query ──> Pivot ──> │ graph traversal │
│ distance / sort │
│ dedup / next node │
└─────────┬───────────┘
│ 需要 node u
↓
SSD ──4KB page──> CPU DRAM ──record──> GPU
~60 GB/s ~<1KB
论文做的不是:
CPU 搜索 + GPU 算距离
而是:
GPU 搜索 + CPU 帮 GPU 搬 SSD 数据
作者称其为:
GPU-centric, CPU-assisted architecture。
这是整篇论文最关键的设计定位。
2. 为什么作者觉得 DiskANN 这一类系统不够好?
建议你重点看论文 §3.1,PDF 第 5–6 页。
传统认识通常是:
DiskANN
CPU computation 很快
↓
SSD random I/O 很慢
↓
SSD 是瓶颈
所以 DiskANN 等工作大量关注:
-
减少 SSD I/O;
-
beam search;
-
latency hiding;
-
cache;
-
更好的图结构。
但 GustANN 说:
这个结论主要适用于 latency-critical workload。
如果目标变成:
几十万 QPS
问题发生变化了。
论文测得 DiskANN:
-
单 worker 大约:
-
1/6时间 computing -
5/6时间等 SSD;
-
-
乍一看明显是 SSD-bound。
但是作者做了一个很重要的思想实验:
假设 SSD I/O latency 完全可以隐藏。
那么 CPU 可以把原来 1/6 的计算时间扩展到全部时间:
DiskANN 实际:
CPU compute : 1
SSD wait : 5
perfect overlap 后:
CPU 可以做约 6× computation
→ 约 6× I/O throughput
他们实际 DiskANN 只有大约:
6 GB/s
那么理想情况下也只有:
6 × 6 GB/s ≈ 36 GB/s
但 6 块 SSD 的理论带宽是:
6 × 10 GB/s = 60 GB/s
于是即使 I/O 完全不等待:
CPU 依然喂不满 6 块 SSD。
作者进一步估算,他们的 56-core CPU,大约只能支撑 3.6 块 SSD 的计算需求。
3. 这其实是论文最重要的 insight
这里非常值得和你之前讨论 DiskANN 的问题联系起来。
GustANN 并没有说 SSD 随机访问不重要。
它说的是:
latency-oriented
单 query:
compute → SSD read → compute → SSD read
↑
latency 大
所以 SSD latency 是核心问题。
throughput-oriented
同时跑:
40,000 queries
大量 query 可以覆盖 I/O latency:
Q1: compute ---- IO ----
Q2: compute ---- IO ----
Q3: compute ---- IO ----
...
此时问题变成:
你的 CPU 能不能足够快地执行图遍历、距离计算、candidate management,从而不断生成 SSD I/O?
GustANN 的答案是:
CPU 不够。
所以需要 GPU。
这就是这篇论文与很多 DiskANN-style 工作最大的视角区别。
4. 那为什么不能直接让 GPU 搜索 SSD?
这就是论文的第二层问题。
已有两种架构。
A. CPU-centric, GPU-assisted
例如:
CPU:
graph traversal
candidate management
SSD access
│
↓
GPU:
distance calculation
论文认为这不解决问题,因为:
graph traversal 仍然在 CPU。
CPU 仍然是 throughput bottleneck。
B. GPU-only
变成:
GPU
├ graph traversal
├ distance
├ candidate management
└ direct SSD access
例如 BaM / GDS 这类 GPU direct storage。
这解决 CPU 计算瓶颈,却引出了两个新瓶颈:
-
GPU memory
-
SSD → GPU PCIe bandwidth
所以 GustANN 的三个核心设计,正好是一一解决这些问题。
5. Design 1:Memory-Efficient GPU Graph Traversal
这是 §4.2,也是算法层面最有技术含量的一部分。
传统 ANN 搜索都会维护:
visited set
例如:
hash table
Bloom filter
bitmap
原因很简单:
假设展开节点 u:
neighbors:
A B C D E
其中 C 之前已经访问过:
visited(C) = true
那么:
不要再算 PQ-distance(C, q)
CPU 上这是很合理的:
用 memory 换 computation。
但 GPU 上作者反过来了
论文提出一个非常 GPU-oriented 的思想:
GPU 上少做几个 distance computation 未必能让程序更快。
因为 GPU 是 SIMT:
warp = 32 threads
如果:
thread 0:发现 duplicate,不计算
thread 1~31:都计算
thread 0 实际上也得等。
所以作者干脆:
先把 duplicate 也算了。
即:
传统:
deduplicate
↓
distance
↓
insert
GustANN:
distance
↓
sort
↓
deduplicate
也就是论文非常核心的一句话:
Sort first, then deduplicate.
6. 为什么这能省这么多显存?
传统 visited table 至少需要:
L×R
假设:
L = 300
R = 128
节点 ID 4 bytes:
300×128×4≈150KB
考虑 hash table/Bloom filter load factor 等,实际可能:
>300 KB/query
而 GustANN 目标是:
~40,000 concurrent queries
于是:
300KB×40000≈12GB
光 visited table 就差不多 12 GB。
A100-40GB 上还有:
1B vectors × 32 B PQ
≈ 30 GB
所以根本放不下。
GustANN 怎么 dedup?
先:
new neighbors
↓
PQ distance
↓
sort
然后 candidate array 已经按照距离排序。
对于 duplicate ID,它们会被检测出来,再通过 GPU warp primitives:
compare
↓
ballot / broadcast
↓
bitcount
↓
prefix-sum-like position calculation
↓
compact
完成并行去重。
论文 Figure 5(PDF 第 11 页)就是这个过程。
最终:
BANG ≈ 480 KB/query
GustANN ≈ 40.22 KB/query
大约:
11.93× 更省 per-query GPU memory。
这使 40K concurrent queries 成为可能。
7. Design 2:CPU-Assisted Transfer
我认为这是整篇论文中最漂亮的系统设计。
先看硬件。
论文机器:
6 × PCIe 5.0 SSD
↓
aggregate ≈ 60 GB/s
A100 PCIe 4.0 ×16
↓
GPU inbound ≈ 24 GB/s
于是如果:
SSD → GPU
直接搬:
60 GB/s
↓
24 GB/s bottleneck
你有 6 块 SSD 也没意义。
论文 Figure 6 专门画了这个瓶颈。
8. 关键观察:GPU 根本不需要整个 SSD page
SSD 最小读取单位:
4 KB
但 graph node record 大约:
vector
+
neighbor IDs
例如 DEEP1B:
vector:
96 × float
= 384B
neighbors:
128 × int
= 512B
total:
896B
也就是说:
SSD read = 4096B
实际需要 = 896B
只需要:
896/4096≈22%
所以 GustANN 做:
4096 B
SSD ───────────────→ DRAM
│
│ 896 B
↓
GPU
而不是:
4096 B
SSD ───────────────→ GPU
这就是所谓:
CPU-assisted selective transfer
DRAM 在这里本质上就是:
bounce buffer
不是用来存整个 index。
9. 为什么绕 CPU 一圈反而更快?
表面看:
SSD → DRAM → GPU
明显比:
SSD → GPU
多了一跳。
但问题在于瓶颈的位置不同。
Direct:
SSD 60GB/s
↓
GPU PCIe 24GB/s
↓
只能使用 ~24GB/s
GustANN:
SSD
│ 60GB/s
↓
DRAM
│
│ selective ~25%
↓
GPU
≈15GB/s
满足:
15<24GB/s
于是 GPU inbound PCIe 不再成为瓶颈。
所以这里是典型的:
多走一步,但少搬 75% 数据。
结果实验中:
GustANN vs BaM-ANN
≈2.50–2.71× throughput
这部分对应 PDF 第 12–13、19–20 页。
10. Design 3:Pivot Search
第三个问题很容易被低估:
为什么 RAID-0 已经把 page 均匀条带化到 6 块 SSD,仍然会负载不均?
作者认为原因来自:
single entry graph search
DiskANN-style graph:
entry
●
/ | \
● ● ●
...
所有 query 都从:
同一个 entry point
开始。
于是前几轮都会访问:
entry 周围的一小片 graph region
而这一片 region 的 node:
可能并没有均匀落在 SSD1~SSD6 上。
于是:
SSD1: ███████████
SSD2: ███
SSD3: █████
SSD4: ██
...
形成作者所谓的:
micro-scale load imbalance。
11. Pivot graph 是怎么解决的?
GPU 上额外放一个非常小的 graph:
完整 dataset
1 billion vectors
↓ random sample
~1 million vectors
↓
pivot graph
query 先:
Query
↓
pivot graph search
↓
找到一个 query-specific pivot
↓
从这个节点进入 SSD graph
于是不同 query:
Q1 → region A
Q2 → region F
Q3 → region C
Q4 → region A
...
不再全部拥挤在:
single entry region
它带来两个效果:
-
SSD load balance;
-
入口本身离 query 更近。
论文实验:
throughput:最多 +1.50×
SSD bandwidth:最多 +1.23×
而且 6 个 SSD 的 tail latency divergence 明显减小。
对应 Figure 12、13,PDF 第 20 页。
12. 不过 Pivot Search 本身并不是完全新的
作者自己也承认:
Starling 有:
navigation graph
也是:
small graph
→ 找一个更好的 SSD search entry
区别是论文宣称:
Starling
主要目的:
减少 SSD I/O
GustANN
主要目的:
multi-SSD load balance
而且:
Starling:CPU search navigation graph
GustANN:GPU search pivot graph
所以这里更像是:
已有 navigation idea 在 GPU + multi-SSD throughput 问题上的重新定位。
这是阅读这篇论文时需要保持清醒的一点。
13. 整个 Query 到底怎么跑?
理解这一段后,整篇论文基本就通了。
单个 query:
① GPU pivot search
↓
找到 entry node
② GPU:
选择当前 exploring node u
↓
③ CPU:
向对应 SSD 发 4KB read
↓
SSD → DRAM
↓
从 page 提取 node record
↓
DRAM → GPU
④ GPU:
full-distance(u)
neighbor PQ-distance
sort
dedup
update candidate set
选择 next exploring node
↓
⑤ 回到③
注意:
CPU 并不负责 graph traversal。
CPU 基本只是:
I/O scheduling
SSD queue management
memory copy
batch dispatch
所以论文声称只需要:
8 CPU cores
其中:
2 threads:query / GPU / SSD dispatch
6 threads:6 SSD I/O queues
14. 单 query 还是串行,那吞吐量怎么上去?
这里有一个重要设计:
inter-query pipelining
单 query:
SSD → GPU → SSD → GPU → SSD → GPU
有严格 dependency:
不知道 next node
→ 就不知道下一次读哪个 SSD page
所以无法把单 query 变成完全流水。
GustANN 改为许多 mini-batch:
time →
Batch A:
SSD ███
GPU ███
SSD ███
Batch B:
SSD ███
GPU ███
Batch C:
SSD ███
GPU ███
于是:
单 query 内不能 pipeline,queries 之间可以 pipeline。
这和你之前讨论 PipeANN / FlowANN 时其实是一个很值得注意的共同模式:
用不同 query / batch 的独立性去填满不同硬件阶段。
但 GustANN 的阶段是:
SSD/CPU transfer
↕
GPU graph search
而不是简单的 CPU ↔ SSD compute-I/O pipeline。
15. 实验结果怎么看?
论文最核心数字不用全记,只记四个。
SSD utilization
最高:
86.7%
6 块 SSD × 10 GB/s:
理论 ~60GB/s
实际 ~52GB/s
vs DiskANN
billion-scale:
SIFT1B : 7.81×
DEEP1B : 6.98×
vs GPU systems
论文报告至少:
2.50× throughput
Cost-effectiveness
以 $ / QPS:
vs CPU+GPU:2.62× better
论文摘要还给出了单 GPU + 6 SSD:
247K billion-scale vector searches/s
的代表性结果。
16. 但这篇论文有一个非常重要的代价
latency
GustANN 是非常明确的:
Throughput-first
要到最大 throughput:
latency ≈ 140 ms
如果限制:
latency < 25ms
那么只能获得:
约 1/5 peak throughput
所以它不是要取代传统低延迟 DiskANN。
作者的 argument 是:
RAG:
ANN retrieval ~100ms
LLM prefill/generation
~100ms–1s
所以 ANN 多几十毫秒没那么致命。
换句话说:
GustANN 本质上是在用 batching latency 换 hardware utilization。
17. 这篇论文真正的三个贡献,我会这样评价
如果去掉作者的包装,我认为可以压缩成:
| 设计 | 本质 |
|---|---|
| Sort-then-dedup | 用 GPU 的冗余计算换显存 |
| CPU-assisted transfer | 用 DRAM 做 page → record 过滤,减少 GPU PCIe 流量 |
| Pivot search | 把 query 从单入口热点区域打散到整个 graph |
其中我认为 novelty / systems insight 强度大致是:
CPU-assisted selective transfer
>
GPU memory-efficient traversal
>
pivot search
Pivot graph 的概念本身并不新;它主要是找到了一个新的系统问题——multi-SSD tail load imbalance——并把 navigation graph 用到了这里。
18. 它与你最近在看的 FlowANN / PipeANN 有什么区别?
这里先给你一个粗定位,后续我们可以专门比较。
PipeANN / 类似设计
主要问题通常是:
CPU computation
↔
SSD I/O
怎么 overlap。
GustANN
更进一步认为:
CPU computation 本身就不够
所以直接变成:
GPU traversal
↔
SSD
然后发现新的:
SSD aggregate bandwidth
>
GPU inbound PCIe bandwidth
再引入:
SSD → CPU DRAM → GPU
所以有一种很有意思的演化:
DiskANN
SSD latency bottleneck
↓
pipeline
↓
CPU throughput bottleneck
↓
GPU
↓
GPU PCIe bottleneck
↓
CPU-assisted selective transfer
每解决一层瓶颈,下一层瓶颈就冒出来。
这其实就是这篇论文最值得学习的系统研究思路。
19. 我建议你按这个顺序读
不要从第一页顺读到第 27 页。最高效的顺序是:
-
§3.1–3.3:Motivation
先理解为什么 CPU 变成 bottleneck。 -
Figure 4 + §4.1
把 GustANN 总架构彻底搞明白。 -
§4.3 CPU-Assisted Transfer
这是最关键、最好理解的设计。 -
§4.2 GPU Graph Traversal
再深入 sort-before-dedup 和 warp-level dedup。 -
§4.4 Pivot Search
理解 single-entry → micro-scale imbalance。 -
§5.3 Breakdown
看三个设计到底各贡献多少,而不是先看 Overall Performance。 -
最后看 §5.4–5.6
重点寻找适用边界和 weakness。
如果把这篇论文压成一张“脑内结构图”,就是:
High-throughput SSD ANN
│
CPU 算力不够
│
▼
GPU graph search
│
┌───────────────┼────────────────┐
│ │ │
GPU memory PCIe inbound SSD imbalance
│ │ │
▼ ▼ ▼
sort then dedup SSD→DRAM→GPU pivot search
│ │ │
└───────────────┼────────────────┘
▼
40K concurrent queries
│
inter-query pipeline
│
▼
~87% SSD utilization
下一步最值得做的是逐行拆 §3.1 的“CPU 为什么会成为瓶颈”,因为这恰好会触及你之前对 DiskANN“到底应该减少 SSD I/O,还是应该高度并行 I/O”的那个问题;GustANN 给出的答案比表面上的“GPU 比 CPU 快”要深得多。
LLM
Quant
股票、基金与量化投资:零基础核心名词手册
适用对象:刚开始接触 A 股、基金与 ETF,希望后续进一步学习量化投资的初学者。
更新日期:2026-09-04
说明:交易规则、税费和基金费率可能调整;实际交易前应以交易所、券商、基金招募说明书和最新监管文件为准。本文用于学习,不构成投资建议。
目录
- 先建立一个总框架
- 市场最基础的概念
- 股票、基金、ETF
- 沪深300、中证500、中证1000
- A股交易时间
- 市价单、限价单
- 买一 / 卖一
- 成交价、收盘价
- K线、成交量
- 涨跌停
- T+1
- 分红、除权除息
- 手续费、印花税
- 基金与 ETF 的核心概念
- 主动基金 vs 被动基金
- 指数基金
- ETF
- ETF联接基金
- A类 / C类
- 场内 / 场外
- 基金净值
- 跟踪误差
- 管理费、托管费
- 最大回撤
- 波动率
- 夏普比率
- 定投
- 资产配置
- 个股基本面核心概念
- PE
- PB
- ROE
- 营收
- 净利润
- 现金流
- 资产负债表
- 行业周期
- 公司竞争力
- 估值
- 从股票进入量化:价格与因子概念
- 动量
- 均值回归
- 波动率
- 因子
- Alpha
- Beta
- 推荐学习顺序
- 新手最容易混淆的 12 组概念
- 官方资料来源
1. 先建立一个总框架
如果只记住一张图,可以记住下面这张:
上市公司
│
├── 发行股票 ───────────────→ 你直接买股票
│
└── 股票被纳入各种指数
│
├── 沪深300
├── 中证500
└── 中证1000
│
↓
指数基金
│
┌─────────┴─────────┐
│ │
普通场外指数基金 ETF
│
可像股票一样
在交易所买卖
从投资方式上看,可以先把市场理解成三层:
| 层次 | 你买的是什么 | 典型问题 |
|---|---|---|
| 个股 | 一家公司的股权 | “这家公司值多少钱?” |
| 基金 | 一篮子资产的基金份额 | “这个基金经理/策略靠谱吗?” |
| 指数 / ETF | 按规则构造的一篮子证券 | “我要暴露在哪一种市场风险上?” |
对于新手,ETF 是很好的观察对象,因为它既具有股票的实时交易特征,又能通过一篮子股票降低单家公司事件造成的噪声。
2. 市场最基础的概念
2.1 股票、基金、ETF 分别是什么?
股票(Stock)
股票代表一家股份公司的部分所有权。
假设某公司总共有 1 亿股股票,你持有 1 万股,那么从经济含义上说,你持有这家公司很小的一部分权益。
股票投资者的收益主要来自:
- 股价上涨产生的资本利得
- 公司分红
- 某些特殊情况下的其他权益,例如配股、送股等
股票的核心问题是:
这家公司未来能赚多少钱?这些未来利润今天应该值多少钱?
基金(Fund)
基金可以理解为:
很多人把钱放在一起,由基金按照约定的策略去投资。
例如:
投资者A:1万元
投资者B:5万元
投资者C:2万元
↓
基金
↓
股票 + 债券 + 现金 + 其他资产
你拥有的不是这些股票本身,而是基金份额。
基金只是一个大类,其中可以继续分成:
- 股票基金
- 债券基金
- 混合基金
- 货币基金
- 指数基金
- ETF
- QDII 基金
- FOF 等
ETF(Exchange Traded Fund)
ETF 中文通常称为交易型开放式指数基金或交易所交易基金。
它同时具备三个特征:
- Fund:本质上是基金
- Index:很多 ETF 追踪某个指数
- Exchange Traded:可以在交易所里像股票一样实时买卖
例如沪深300 ETF:
你买入 1 只沪深300 ETF
↓
基金按照规则持有沪深300的一篮子股票
↓
你的收益大体跟随沪深300指数
三者最核心的区别
| 项目 | 股票 | 普通场外基金 | ETF |
|---|---|---|---|
| 本质 | 公司股权 | 基金份额 | 基金份额 |
| 投资对象 | 单家公司 | 一篮子资产 | 通常是一篮子指数成分资产 |
| 价格 | 盘中实时变化 | 通常按每日净值申赎 | 盘中实时变化 |
| 买卖地点 | 证券交易所 | 基金公司/代销平台 | 证券交易所 |
| 个股风险 | 高 | 分散 | 通常较分散 |
| 是否适合新手观察 | 可以,但噪声较多 | 可以 | 非常适合 |
一句话:
股票是在买公司,基金是在买投资组合,ETF 是一种可以像股票一样交易的基金。
2.2 沪深300、中证500、中证1000是什么?
它们都是股票指数。
指数本身不是一只股票,也不是一只基金。
指数更像一个“规则化的统计组合”:
一批股票
↓
按照某套选样和加权规则
↓
得到一个指数点位
基金公司可以再发行 ETF 去跟踪这个指数。
沪深300
沪深300指数从沪深市场中选择规模大、流动性好的 300 只代表性证券。
你可以把它粗略理解成:
A 股大盘核心资产的代表之一。
“沪深”的意思是:
- 上海证券交易所
- 深圳证券交易所
“300”表示样本数量约为 300 只。
注意:
沪深300并不是简单机械地挑“市值最大的300家公司”。
实际编制还会考虑上市时间、流动性、异常情况等规则。
中证500
中证500主要用于代表沪深300之外的一批中等市值、流动性较好的股票。
可以粗略理解成:
沪深300:偏大盘
中证500:偏中盘
但不要机械理解为:
“市值排名第301~800名就是中证500。”
实际指数编制存在样本空间、流动性筛选、定期调整等规则。
中证1000
中证1000主要选择中证800样本以外,规模相对更小、流动性较好的 1000 只证券。
可以粗略理解为:
沪深300 → 大盘
中证500 → 中盘
中证1000 → 小盘倾向更明显
三者放在一起看
公司规模大
↑
│ 沪深300
│
│ 中证500
│
│ 中证1000
↓
公司规模相对小
这里说的是总体风格倾向,不是每只成分股都严格满足某个市值排名区间。
为什么量化投资特别关心这些指数?
因为它们经常被用作:
- 回测基准(Benchmark)
- 大盘 / 中盘 / 小盘风格代理
- 指数增强策略的母指数
- 衡量 Alpha 的参照物
例如你的量化策略一年赚了 15%,不能立刻说“策略很好”。
如果同一年中证1000涨了 30%,那么:
你的策略虽然赚钱了,但可能大幅跑输基准。
2.3 A股交易时间
A 股通常每周一至周五交易,法定节假日及交易所公告休市日除外。
新手首先记住:
09:15–09:25 开盘集合竞价
09:30–11:30 上午交易
11:30–13:00 午间休市
13:00–14:57 下午连续竞价
14:57–15:00 收盘集合竞价
什么叫集合竞价?
不是每来一张订单就立即成交,而是:
先收集一段时间的买卖订单,然后按照规则集中确定一个成交价格。
开盘集合竞价主要用于形成开盘价。
收盘集合竞价主要用于形成收盘价。
什么叫连续竞价?
连续竞价期间,新的买卖订单进入订单簿后,交易系统按照规则持续撮合。
最核心的撮合原则可以记为:
价格优先,时间优先。
例如有两个买单:
A:10.01 元买1000股
B:10.00 元买1000股
A 的价格更高,所以 A 优先。
如果:
A:10.00 元买1000股,10:00:01提交
B:10.00 元买1000股,10:00:03提交
价格相同时,A 更早,所以 A 优先。
2026 年规则提示
上交所自 2026 年 7 月 6 日起实施修订后的交易规则,并扩大了盘后固定价格交易的适用范围。对初学者来说,先掌握上面的标准竞价时段即可;盘后固定价格交易可以等熟悉常规交易后再学。
2.4 市价单、限价单
限价单(Limit Order)
限价单的核心是:
价格优先。
你明确告诉系统:
- 买入:我最高愿意出多少钱
- 卖出:我最低愿意接受多少钱
例如股票当前大约 10 元。
你下:
10.00 元买入100股
意味着:
高于 10.00 元我不接受。
优点:
- 能控制成交价格
缺点:
- 可能根本成交不了
市价单(Market Order)
市价类申报的核心是:
优先争取成交,而不是死守某个固定价格。
在中国证券市场,市价申报并不是简单的“无条件随便什么价格都买”,交易所存在不同市价申报方式以及保护限价等约束。
你作为新手可以先理解为:
限价单:价格确定性更重要
市价类申报:成交确定性更重要
举例
订单簿卖盘:
卖一 10.01 100股
卖二 10.02 500股
卖三 10.03 1000股
如果你急着买 600 股:
- 可能先吃掉卖一的 100 股
- 再吃掉卖二的一部分或全部
- 实际成交均价可能高于 10.01
这就是为什么:
“当前价格”并不保证是你大额订单的最终平均成交价格。
这个问题以后会进入量化里的:
- Slippage(滑点)
- Market Impact(市场冲击)
- Execution Cost(执行成本)
2.5 买一 / 卖一
这是理解盘口最关键的两个词。
买一
当前尚未成交的买单中,报价最高的一档。
例如:
买三 9.98
买二 9.99
买一 10.00
----------------
卖一 10.01
卖二 10.02
卖三 10.03
买一 = 10.00。
卖一
当前尚未成交的卖单中,报价最低的一档。
上例中:
卖一 = 10.01。
买卖价差(Bid-Ask Spread)
Spread = 卖一 - 买一
= 10.01 - 10.00
= 0.01 元
这个差值就是买卖价差。
一般来说,在其他条件相近时:
- 成交活跃 → 价差往往更小
- 流动性差 → 价差可能更大
这对 ETF 特别重要。
两只都跟踪沪深300的 ETF,不一定交易体验一样。
如果:
ETF A:买一 4.000,卖一 4.001
ETF B:买一 4.000,卖一 4.020
ETF B 的隐性交易成本明显更大。
2.6 成交价、收盘价
成交价
成交价是:
一笔买单和一笔卖单真正撮合成功时产生的价格。
例如:
卖一:10.01
你提交可以立即成交的买单
如果在 10.01 元成交,那么这笔交易的成交价就是 10.01 元。
最新价
“最新价”通常指:
最近一笔已经完成的成交价格。
它不是:
- 买一
- 卖一
- 你一定能成交的价格
收盘价
收盘价是交易日结束时按照交易所规则形成的当日正式收盘价格。
收盘价很重要,因为大量统计指标都使用它,例如:
日收益率
移动平均线
历史波动率
动量
最大回撤
回测净值
为什么量化常用收盘价?
因为每天都有统一、规则化的时间截面。
例如日收益率常写为:
r_t = P_t / P_(t-1) - 1
其中:
P_t:今天的收盘价P_(t-1):昨天的收盘价
2.7 K线、成交量
K线(Candlestick)
一根日 K 线通常包含四个价格:
- Open:开盘价
- High:最高价
- Low:最低价
- Close:收盘价
简称:
OHLC
例如:
开盘 10.00
最高 10.80
最低 9.90
收盘 10.60
这四个数字就是一根日 K 线的核心信息。
K线不是“神秘预测符号”
对量化学习而言,更适合把 K 线理解成:
对一段时间价格轨迹的压缩表示。
不要一开始沉迷:
- 十字星
- 乌云盖顶
- 红三兵
- 早晨之星
这些形态如果要使用,最终都应该转化成可以严格定义、可以回测的条件。
成交量(Volume)
成交量表示某段时间内实际成交了多少证券。
成交量反映的是:
市场参与和交易活跃程度。
价格与成交量可以组合观察,例如:
价格大涨 + 成交量大增
价格大涨 + 成交量很低
这两种市场状态并不完全相同。
但同样要避免:
“放量必涨”“缩量必跌”
这种未经统计验证的绝对规律。
量化的思路是:
把它定义出来 → 用历史数据检验 → 做样本外测试。
2.8 涨跌停
涨跌停是交易所对证券单日价格变化设置的价格涨跌幅限制。
例如某股票前收盘价为 10 元,若当日涨跌幅限制为 ±10%,则粗略理解:
涨停价 ≈ 11 元
跌停价 ≈ 9 元
实际计算还涉及价格最小变动单位和交易所规则。
常见幅度
初学者先记:
- 沪深主板多数普通 A 股:通常 ±10%
- 科创板、创业板普通股票:通常 ±20%
- 新股上市初期、部分特殊证券可能采用不同规则
不要记成:
“所有 A 股都是 10%。”
这是错误的。
涨停 ≠ 一定不能卖
“涨停”意味着价格达到当日允许的最高价格,不代表交易系统停止。
如果还有大量买单:
- 持有人仍可能卖出
- 买入者可能排队等成交
同理:
跌停也不代表系统停止交易。
真正的问题往往是:
有没有对手盘。
这与量化中的流动性风险直接相关。
2.9 T+1
T 表示交易日。
A 股普通股票通常实行:
当天买入,下一交易日才能卖出。
例如:
周一买入
↓
最快周二卖出
如果周五买入:
周五买入
↓
周末不是交易日
↓
通常最快周一卖出
这就是常说的 T+1。
为什么需要特别注意 ETF?
不要简单记成:
“ETF 全部 T+1。”
不同类型 ETF 的回转交易规则可能不同。
例如按现行上交所规则,部分债券 ETF、交易型货币基金、黄金 ETF、商品期货 ETF 等可以实行当日回转交易。
所以正确思路是:
普通A股股票:通常 T+1
ETF:根据具体品种确认交易规则
对量化有什么影响?
非常大。
如果你设计:
上午买入
下午卖出
对 T+1 的标的来说,策略可能根本无法实盘。
因此:
交易制度是回测约束,而不是回测结束后才考虑的细节。
2.10 分红、除权除息
分红
公司赚到利润以后,可以选择:
- 留在公司继续经营
- 向股东分配一部分
其中最直观的是现金分红。
例如你有:
1000 股
公司宣布:
每股现金分红 0.50 元
则税前现金红利为:
1000 × 0.50 = 500 元
除息
假设:
股票昨天收盘:10 元
每股现金分红:1 元
如果其他条件都不变,分红以后公司的资产少了约 1 元/股的现金。
因此除息参考价格会相应调整。
非常粗略地看:
10 元股票
+ 收到 1 元现金
↓
除息后参考价格约 9 元
所以:
分红不是凭空多出的钱。
投资者的总财富不会仅因为执行一次现金分红就机械增加。
除权
除权主要涉及:
- 送股
- 转增股本
- 配股
- 其他引起股本变化的权益事项
例如:
原来:
100股 × 10元 ≈ 1000元
10送10后:
200股 × 约5元 ≈ 1000元
股数变多不等于财富凭空翻倍。
为什么量化特别关心除权除息?
因为历史价格必须正确处理。
例如股票:
昨天 10 元
今天因为现金分红除息到 9 元
如果你的程序直接算:
9 / 10 - 1 = -10%
就可能错误地认为股票真实亏了 10%。
因此量化数据通常区分:
- 原始价格
- 前复权价格
- 后复权价格
- 总收益价格
这是以后做回测必须认真处理的问题。
2.11 手续费、印花税
投资收益不是:
卖出价 - 买入价
而更接近:
净收益
=
价差收益
+ 分红
- 佣金
- 税费
- 滑点
- 市场冲击
- 其他成本
券商佣金
证券交易通常需要支付券商佣金。
佣金率:
- 因券商而异
- 因客户协议而异
- 不要把网上看到的某个“万几”当成统一法定费率
你需要以自己的券商实际费率为准。
证券交易印花税
截至本文更新时,A 股股票证券交易印花税对出让方单边征收,现行标准为成交金额的 0.5‰(0.05%)。
例如卖出股票:
成交金额 = 100,000 元
印花税 ≈ 100,000 × 0.05%
= 50 元
买入股票时不按这一项征收证券交易印花税。
ETF 的重要区别
ETF 二级市场交易通常不征收股票证券交易印花税,但仍可能产生券商佣金等成本。
这也是 ETF 相对个股在高频换手时的一个成本差异。
为什么量化必须研究费用?
假设一个策略:
每次交易理论盈利:0.08%
但:
佣金 + 价差 + 滑点 + 冲击成本 = 0.10%
那么:
回测看起来在赚钱,实盘可能越做越亏。
3. 基金与 ETF 的核心概念
3.1 主动基金 vs 被动基金
主动基金
主动基金的基金经理主动决定:
- 买什么
- 买多少
- 什么时候买
- 什么时候卖
目标通常是:
战胜某个业绩比较基准。
例如基金经理认为:
A公司被低估 → 多买
B公司前景差 → 不买
核心变量是:
基金经理与投资团队的判断能力。
被动基金
被动基金通常不试图主动预测哪家公司最好,而是:
尽可能按照某个指数进行复制和跟踪。
例如沪深300指数基金:
指数有什么
↓
基金尽量持有什么
指数权重变化
↓
基金跟着调整
目标通常不是“击败沪深300”,而是:
尽可能准确地获得沪深300的收益。
对比
| 项目 | 主动基金 | 被动基金 |
|---|---|---|
| 谁决定持仓 | 基金经理 | 指数规则为主 |
| 目标 | 通常争取超越基准 | 通常追踪基准 |
| 人的影响 | 大 | 相对小 |
| 费率 | 往往较高 | 往往较低 |
| 研究重点 | 基金经理能力 | 指数、费率、跟踪误差 |
3.2 指数基金
指数基金是:
以追踪某个指数表现为主要目标的基金。
例如:
沪深300指数
↓
沪深300指数基金
指数只是一个数字和一套规则。
指数基金才是你真正可以买的金融产品。
一个非常重要的区分
沪深300指数
≠
沪深300 ETF
≠
沪深300 ETF联接基金
它们的关系是:
沪深300指数:规则和基准
↑
│ 跟踪
沪深300 ETF:场内基金产品
↑
│ ETF联接基金主要投资目标ETF
沪深300 ETF联接基金:场外基金产品
3.3 ETF
ETF 值得单独再学一次,因为它是连接基金和股票交易的关键产品。
ETF 的二级市场
普通投资者最常用的方式:
证券账户
↓
输入 ETF 代码
↓
像股票一样挂单
↓
与其他投资者成交
因此你会看到:
- 买一
- 卖一
- 成交量
- K线
- 实时价格
ETF 的一级市场
ETF 还存在申购赎回机制,通常涉及一篮子证券或现金。
这套机制非常重要,因为它帮助 ETF 的二级市场价格靠近基金资产价值。
例如:
ETF理论价值:1.00
市场价格:1.10
如果价差足够大,套利机制会吸引专业参与者进行操作。
结果通常会推动:
ETF价格重新靠近其资产价值。
这也是理解 ETF 折价 / 溢价的基础。
买 ETF 时至少看四件事
- 跟踪什么指数
- 基金规模
- 成交量和买卖价差
- 跟踪误差与费率
不是看到名称里有:
“沪深300ETF”
就认为所有产品完全一样。
3.4 ETF联接基金
ETF 联接基金可以理解成:
一个主要投资目标 ETF 的场外基金。
结构大致是:
你
↓
ETF联接基金
↓
目标ETF
↓
指数成分证券
为什么需要它?
因为很多投资者:
- 没有证券账户
- 不想盘中挂单
- 更喜欢自动定投
- 希望按照精确金额申购
例如:
每月自动投入 1000 元
场外联接基金做起来很方便。
ETF vs ETF联接基金
| 项目 | ETF | ETF联接基金 |
|---|---|---|
| 交易位置 | 证券交易所 | 场外平台 |
| 价格 | 盘中实时价格 | 通常按基金净值申赎 |
| 是否挂单 | 是 | 通常不需要 |
| 自动定投 | 视券商功能 | 通常很方便 |
| 交易理解门槛 | 稍高 | 较低 |
| 投资链条 | 更直接 | 多一层联接结构 |
3.5 A类 / C类
同一个场外基金可能出现:
某某指数基金 A
某某指数基金 C
它们通常不是两套完全不同的投资组合,而是:
同一基金采用不同收费方式的份额类别。
常见模式
A类
通常:
- 申购时可能收申购费
- 通常不按 C 类的方式持续收销售服务费
- 更适合什么持有期,取决于具体基金费率
C类
通常:
- 申购费可能较低或为零
- 但持续收取销售服务费
- 不同持有期赎回费也可能不同
不要机械记:
“短期 C,长期 A。”
虽然很多产品中可能有这种倾向,但最终必须算具体费率。
正确做法:
总成本
=
申购费
+ 持有期销售服务费
+ 管理费
+ 托管费
+ 赎回费
+ 其他约定费用
然后根据预计持有时间比较。
3.6 场内 / 场外
这里的“场”主要指证券交易所市场。
场内
在证券交易所挂牌交易。
例如:
- 股票
- ETF
- LOF 等
你通常通过:
券商证券账户
进行交易。
场外
不是在交易所订单簿里与其他投资者实时撮合。
常见渠道:
- 基金公司
- 银行
- 天天基金
- 蚂蚁基金等合规基金销售机构
通常是:
你提交申购
↓
按照相应规则确认基金净值
↓
获得基金份额
最直观区别
场内:
我和市场上的卖方成交
场外:
我向基金申购/赎回份额
3.7 基金净值(NAV)
NAV = Net Asset Value。
基金单位净值可以粗略理解为:
基金净资产
────────
基金份额数
例如基金:
总资产:10.2亿元
总负债:0.2亿元
净资产:10亿元
份额:8亿份
那么:
单位净值 = 10亿元 / 8亿份
= 1.25元
净值高 ≠ 基金贵
这是新手特别常见的误区。
例如:
基金A净值:1元
基金B净值:5元
不能因此说:
A 比 B 便宜。
基金未来收益取决于:
它持有的资产未来涨跌。
而不是净值数字本身是不是接近 1。
3.8 跟踪误差(Tracking Error)
如果基金目标是追踪沪深300,那么理想状态是:
沪深300涨10%
基金也涨约10%
现实中会存在差异。
原因包括:
- 管理费
- 托管费
- 交易成本
- 现金仓位
- 指数成分调整
- 申购赎回
- 复制方法
- 税费
- 其他运营因素
跟踪偏离
某段时间:
基金收益:9.5%
指数收益:10.0%
差:
-0.5%
这是一种直观的跟踪偏离。
跟踪误差
严格意义上的 Tracking Error 通常看:
基金相对指数的超额收益序列有多不稳定。
例如每天:
基金收益 - 指数收益
得到一组主动收益(Active Return),再计算其波动程度。
对于普通被动指数基金:
在其他条件相似时,跟踪越稳定通常越符合产品目标。
3.9 管理费、托管费
管理费
支付给基金管理人的费用。
可以理解成:
基金公司负责管理产品的费用。
托管费
支付给基金托管人的费用。
托管机构负责基金资产保管、资金清算、监督等相关职责。
一个常见误区
你通常不会看到:
今天账户扣管理费 0.5 元
因为这些费用一般从基金资产中持续计提,已经影响净值。
所以:
净值表现本身已经包含这些基金运营费用的影响。
为什么小费率很重要?
假设两个指数产品对同一指数的跟踪能力差不多:
A:每年总固定费率更低
B:每年总固定费率更高
长期复利下,小小的费率差异会不断累积。
3.10 最大回撤(Maximum Drawdown)
最大回撤回答:
历史上最惨的一次,从高点跌到后续低点,跌了多少?
例如净值:
1.00
1.20
1.50 ← 高点
1.30
1.10 ← 后续低点
1.25
则这一段回撤:
1.10 / 1.50 - 1
≈ -26.67%
最大回撤就是整个观察期内最严重的这一类跌幅。
公式可写为:
MDD = max_t [(历史峰值_t - 当前净值_t) / 历史峰值_t]
为什么最大回撤极其重要?
假设:
策略A:年化15%,最大回撤40%
策略B:年化12%,最大回撤15%
不能只看:
15% > 12%
因为 A 在某段时间可能让 100 万变成:
60 万
很多投资者心理上和资金管理上根本无法坚持。
3.11 波动率(Volatility)
波动率衡量:
收益率上下波动有多剧烈。
例如:
资产A
+0.2%
-0.1%
+0.3%
-0.2%
资产B
+5%
-6%
+4%
-5%
即使两者长期平均收益接近,B 的波动显然更大。
量化中的常见定义
对每日收益率:
r1, r2, ..., rn
计算标准差:
σ_daily = std(r)
如果粗略年化:
σ_annual ≈ σ_daily × √252
这里 252 是一年交易日数量的常见近似。
波动率不是“风险”的全部
波动率只衡量价格变化幅度,并不等价于全部风险。
它无法完整描述:
- 流动性枯竭
- 公司破产
- 极端跳空
- 政策风险
- 尾部风险
但它非常容易量化,因此是现代金融和量化投资里的核心风险指标。
3.12 夏普比率(Sharpe Ratio)
夏普比率回答:
我每承担一单位波动风险,获得了多少超额收益?
基本形式:
Sharpe = (投资组合收益率 - 无风险收益率) / 投资组合波动率
例如:
基金A
收益:15%
波动:30%
基金B
收益:12%
波动:10%
虽然 A 收益更高,但 B 的单位风险回报可能更好。
直观理解
不要只问:
“赚了多少?”
还要问:
“为了赚这些钱,我承受了多大的波动?”
注意
夏普比率不是万能指标。
它可能受到:
- 样本区间
- 收益分布
- 极端事件
- 无风险利率选择
- 年化方式
影响。
3.13 定投
定投就是:
按固定规则周期性投入资金。
例如:
每月1日
↓
投入1000元
↓
买沪深300指数基金
定投解决的核心问题
它主要解决的是:
持续投入和择时纪律问题。
而不是:
“定投保证赚钱。”
定投并不会改变底层资产本身的长期收益来源。
如果长期买入的是一个不断恶化的资产:
定投也不能凭空创造收益。
定投为什么适合新手?
因为它减少:
- “今天是不是最低点?”
- “明天会不会跌?”
- “我要不要一次全买?”
这些短期决策。
3.14 资产配置(Asset Allocation)
资产配置不是问:
“哪只股票最好?”
而是问:
我的钱应该分别暴露在什么资产风险上?
例如:
股票 60%
债券 30%
现金 10%
进一步还可以拆:
股票
├── A股
├── 港股
├── 美股
└── 其他
A股
├── 大盘
├── 中盘
└── 小盘
为什么资产配置非常重要?
因为投资组合的风险常常并不是由“选中了哪一只股票”决定,而是由:
- 股票占比
- 债券占比
- 地区暴露
- 行业暴露
- 大小盘风格
- 利率风险
- 汇率风险
共同决定。
这已经非常接近量化投资的:
Portfolio Construction(组合构建)。
4. 个股基本面核心概念
4.1 市盈率 PE
PE = Price-to-Earnings Ratio。
常见表达:
PE = 公司市值 / 公司净利润
或者:
PE = 每股股价 / 每股收益 EPS
例如:
公司市值:100亿元
一年净利润:5亿元
则:
PE = 100 / 5 = 20倍
直观理解
如果利润永远不变、全部归属于股东,并做很多简化,20 倍 PE 可以粗略理解成:
市场愿意用约 20 元价格购买当前每年 1 元利润。
但现实远比这个复杂。
PE 低 ≠ 一定便宜
低 PE 可能因为:
- 市场认为利润即将下滑
- 公司处于周期顶部
- 业务风险高
- 财务质量差
高 PE 也不一定贵,因为:
- 公司可能高速增长
- 未来利润可能显著扩大
所以 PE 必须结合:
增长、行业、盈利质量、资产负债表和周期位置。
4.2 市净率 PB
PB = Price-to-Book Ratio。
常见表达:
PB = 公司市值 / 股东权益(净资产)
例如:
市值:100亿元
净资产:50亿元
则:
PB = 2倍
直观理解
市场愿意为账面每 1 元净资产支付:
2 元
PB 对哪些行业更有意义?
对资产负债表较重要、资产可比性较强的行业往往更有参考价值,例如部分:
- 银行
- 保险
- 周期
- 传统资产型公司
对于主要价值来自:
- 软件
- 品牌
- 人才
- 网络效应
- 自研技术
的企业,账面净资产可能无法完整反映经济价值。
4.3 ROE
ROE = Return on Equity,净资产收益率。
常见形式:
ROE = 净利润 / 平均股东权益
例如:
平均净资产:100亿元
净利润:20亿元
则:
ROE = 20%
直观理解
股东投入 100 元净资产,公司一年大约赚:
20 元
高 ROE 为什么常被关注?
它可能意味着公司:
能够较高效率地利用股东资本创造利润。
但高 ROE 也可能是“杠杆堆出来的”
例如公司大量借债:
资产很多
负债也很多
真正股东权益很少
分母变小,会把 ROE 推高。
所以不能只看 ROE 一个数字。
以后可以进一步学习:
杜邦分析(DuPont Analysis)
把 ROE 拆成利润率、资产周转率和财务杠杆。
4.4 营收
营收 = Revenue。
它回答:
公司卖产品或提供服务,总共产生了多少营业收入?
例如咖啡公司:
一年卖出1000万杯
平均每杯20元
粗略营收:
2亿元
营收不是利润
如果:
营收:100亿元
各种成本费用:99亿元
净利润可能只有:
1亿元
所以:
收入规模大
≠
赚钱能力强
量化常看什么?
例如:
- 营收同比增长率
- 营收过去 3 年 CAGR
- 营收增速变化
- 营收 surprise
这些可以构成基本面因子。
4.5 净利润
净利润可以粗略理解为:
公司在一个会计期间扣除各种成本、费用、税费等之后剩余的利润。
结构可以粗略想象:
营业收入
- 营业成本
- 销售费用
- 管理费用
- 研发费用
- 财务费用
± 其他损益
- 所得税
─────────
净利润
净利润增长为什么重要?
股票估值本质上最终需要落到:
企业未来能给股东创造多少经济利益。
因此盈利增长非常关键。
但不要只看“净利润同比 +100%”
要进一步问:
- 去年是不是基数特别低?
- 有没有卖资产产生一次性收益?
- 利润是不是来自主营业务?
- 现金真的收回来了吗?
- 应收账款是否暴涨?
这会把你自然引向:
现金流与盈利质量分析。
4.6 现金流
现金流关心的是:
现金真的进来了还是出去了。
企业现金流量表通常分成三大类:
经营活动现金流
来自主营业务经营。
例如:
卖商品收到现金
支付员工工资
支付供应商货款
这是判断盈利质量非常重要的一类现金流。
投资活动现金流
例如:
购买机器设备
建设工厂
出售资产
购买长期投资
筹资活动现金流
例如:
借钱
还债
发行股票融资
支付股利
为什么“利润”和“现金流”不同?
因为会计采用权责发生制。
例如:
公司已经把100万元商品卖给客户
客户承诺3个月后付款
会计上可能已经确认收入和利润,但:
钱现在还没真正到账。
因此投资者常会比较:
净利润
vs
经营活动现金流
如果长期:
利润很好看
现金流很差
就需要进一步研究原因。
4.7 资产负债表
资产负债表描述某一个时间点:
公司拥有什么、欠别人什么、真正属于股东什么。
最核心公式:
资产 = 负债 + 股东权益
资产
例如:
- 现金
- 应收账款
- 存货
- 厂房
- 机器
- 长期投资
负债
例如:
- 银行借款
- 应付账款
- 债券
- 其他债务
股东权益
可以粗略理解为:
资产扣掉负债之后归股东的账面价值。
即:
股东权益 = 资产 - 负债
为什么买股票必须看资产负债表?
两家公司都赚 10 亿元:
公司A:
负债很少
现金充足
公司B:
负债极高
现金紧张
两者风险完全不同。
4.8 行业周期
行业周期指:
某些行业的盈利会随着供需、价格、库存、产能和宏观经济出现明显周期性变化。
典型周期行业可能包括:
- 钢铁
- 煤炭
- 化工
- 航运
- 半导体部分环节
- 养殖等
一个简化周期:
需求上升
↓
产品涨价
↓
企业利润暴增
↓
大量扩产
↓
供给增加
↓
价格下降
↓
利润下滑
↓
企业减少投资
↓
供给收缩
↓
下一轮循环
为什么周期股 PE 容易骗新手?
可能出现:
行业景气最高点
↓
公司利润异常高
↓
PE看起来特别低
这时低 PE 未必代表便宜。
因为市场可能已经预期:
下一年利润会大幅下降。
4.9 公司竞争力
“公司竞争力”不是单一财务指标。
它可能来自:
- 品牌
- 成本优势
- 规模效应
- 网络效应
- 技术壁垒
- 专利
- 渠道
- 客户黏性
- 数据
- 管理能力
- 牌照
- 供应链
- 转换成本
你以后经常会看到一个词:
Moat(护城河)
核心问题是:
如果这家公司现在非常赚钱,为什么竞争对手不能把它的利润抢走?
这决定高 ROE 和高利润率是否能长期持续。
4.10 估值
估值是在回答:
这家公司合理值多少钱?
注意:
好公司 ≠ 好股票。
因为:
公司非常优秀
但股票价格贵得离谱
仍然可能是很差的投资。
反过来:
公司一般
但价格已经低到充分反映坏消息
也可能出现投资机会。
常见相对估值指标
- PE
- PB
- PS
- EV/EBITDA
- 股息率
绝对估值
典型方法:
DCF(Discounted Cash Flow,现金流折现)
核心思想:
未来能够拿到的钱
↓
按照风险和时间价值折算到今天
↓
得到今天的价值
估值与价格的区别
Price = 市场现在成交多少钱
Value = 你认为它真正值多少钱
投资分析的核心就在研究:
Price 与 Value 是否存在显著差异
5. 从股票进入量化:价格与因子概念
5.1 动量(Momentum)
金融中的动量与物理学动量不是一回事。
投资里的 Momentum 通常指:
过去一段时间表现较强的资产,在之后一段时间仍表现较强的统计倾向。
一个非常简单的动量指标:
Momentum_20
=
今天收盘价 / 20个交易日前收盘价 - 1
例如:
20日前:10元
今天:12元
则:
20日动量 = 20%
一个最简单的量化策略
计算所有股票过去120日收益率
↓
从高到低排序
↓
买入前10%
这就是一个非常粗糙的截面动量策略雏形。
注意
动量不是:
“涨了就一定继续涨。”
它是一种需要在特定市场、特定周期、特定定义下统计检验的现象。
5.2 均值回归(Mean Reversion)
均值回归和动量方向相反。
它假设:
价格或收益短期偏离某个正常水平以后,存在向正常水平恢复的倾向。
例如:
过去20日平均价格:10元
今天突然跌到8元
均值回归策略可能认为:
这次下跌过度,未来可能反弹。
一个简单指标
Deviation
=
当前价格 / 20日移动平均价 - 1
若非常低:
Deviation = -15%
策略可能把它视为超跌信号。
动量 vs 均值回归
动量:
涨 → 认为可能继续涨
均值回归:
涨太多 → 认为可能回落
两者都不是永恒规律。
同一资产可能:
- 很短周期表现均值回归
- 中期表现动量
- 很长周期又表现另一种机制
5.3 波动率
前面已经定义过波动率。
进入量化后,需要把它进一步当成一个可计算变量。
例如:
过去20日波动率
过去60日波动率
过去120日波动率
都可以成为特征。
波动率可以用于
- 风险控制
- 仓位调整
- 因子构建
- 期权定价
- 风险平价
- 市场状态识别
例如风险目标策略:
最近波动很高
↓
降低仓位
最近波动很低
↓
提高仓位
这叫:
Volatility Targeting。
5.4 因子(Factor)
因子是量化投资最重要的概念之一。
可以先把它理解成:
一个能够对大量股票统一计算、并用于解释或预测收益/风险差异的特征。
例如对所有股票计算:
市值
PE
PB
ROE
过去12个月收益
过去20日波动率
过去1个月成交额
这些都可能被加工成因子。
常见因子类别
Value:价值
例如:
- 低 PB
- 低 PE
- 高 Earnings Yield
Momentum:动量
例如:
- 过去 12-1 月收益
Quality:质量
例如:
- 高 ROE
- 稳定盈利
- 低杠杆
Size:规模
例如:
- 小市值 vs 大市值
Low Volatility:低波动
例如:
- 过去 60 日历史波动率较低
因子研究的标准流程
提出经济逻辑
↓
定义因子
↓
数据清洗
↓
去极值
↓
标准化
↓
行业/市值中性化(视研究目标)
↓
分组回测
↓
计算IC
↓
检验稳定性
↓
考虑换手和交易成本
↓
样本外测试
↓
组合构建
这就是你从“炒股”进入真正量化研究的重要分界线。
5.5 Alpha
Alpha 可以先理解为:
无法被你选定的市场风险/因子暴露解释的超额收益。
最简单的直觉例子:
你的策略:+20%
市场基准:+15%
不能直接说:
Alpha = 5%
因为你可能只是承担了更大的风险。
例如你大量买小盘股,而小盘风格本身就涨得更多。
所以需要进一步问:
收益来自:
市场Beta?
小盘因子?
价值因子?
动量因子?
还是策略真正额外创造的收益?
在最简单的 CAPM 表达中:
R_strategy - R_f
=
Alpha
+
Beta × (R_market - R_f)
+
误差
Alpha 是回归里的截距项。
量化研究真正想找什么?
从某种意义上说:
你是在寻找可持续、可解释、扣除风险暴露和交易成本以后仍然存在的 Alpha。
5.6 Beta
Beta 衡量:
一个资产对市场整体涨跌的敏感程度。
最常见定义:
Beta
=
Cov(资产收益, 市场收益)
/
Var(市场收益)
直观理解
Beta ≈ 1
市场涨 1%,资产平均倾向于涨约 1%。
Beta > 1
对市场波动更敏感。
例如:
Beta = 1.5
可以粗略理解:
市场变化 1% 时,该资产通常表现出约 1.5 倍方向性敏感度。
Beta < 1
相对市场波动更弱。
Beta ≠ 确定性预测
Beta 是统计关系,不是:
明天指数 +1%
↓
这只股票必定 +1.5%
它描述的是历史样本中的整体关系。
6. 推荐学习顺序
如果你的最终目标是:
从零开始 → 能看懂基金和股票 → 进入量化
建议按下面顺序。
第一阶段:交易机制
先掌握:
- 股票
- ETF
- 指数
- 交易时间
- 订单簿
- 买一卖一
- 限价单
- 成交
- T+1
- 涨跌停
- 交易费用
实践对象
建议直接观察:
一只成交活跃、规模较大的沪深300 ETF
每天不用买,只看:
买一
卖一
最新价
成交量
K线
指数涨跌
第二阶段:基金评价
学习:
- 净值
- 指数基金
- ETF联接
- A/C类
- 管理费
- 跟踪误差
- 波动率
- 最大回撤
- 夏普比率
然后尝试回答:
两只都叫“沪深300”的基金,为什么长期收益仍会有差异?
第三阶段:个股基本面
学习:
营收
↓
利润
↓
现金流
↓
资产负债表
↓
ROE
↓
PE / PB
↓
估值
目标不是立刻选出“牛股”,而是能够读懂:
一家企业究竟在怎么赚钱。
第四阶段:统计与量化
把自然语言观点变成数学定义。
例如:
“最近涨得比较强”
↓
120日收益率
“比较便宜”
↓
E/P、B/P、FCF Yield
“质量比较高”
↓
ROE、毛利率、利润稳定性、杠杆
“风险较小”
↓
历史波动率、Beta、最大回撤
“策略跑赢市场”
↓
Excess Return、Alpha、Information Ratio
第五阶段:完成第一套量化闭环
获取数据
↓
清洗数据
↓
构造因子
↓
形成交易信号
↓
构建投资组合
↓
加入交易费用
↓
回测
↓
风险分析
↓
样本外测试
↓
模拟盘
↓
小规模实盘
对于计算机专业背景的人,这一阶段你的编程优势才真正开始发挥。
7. 新手最容易混淆的 12 组概念
1. 指数 ≠ ETF
沪深300:指数
沪深300ETF:跟踪指数的基金产品
2. ETF ≠ ETF联接基金
ETF:证券账户里实时交易
ETF联接:通常场外申购赎回
3. 股票价格 ≠ 公司价值
Price:市场报价
Value:对企业经济价值的估计
4. 营收 ≠ 利润
营收100亿
不意味着
赚了100亿
5. 利润 ≠ 现金流
公司可以:
账面赚钱
但现金没有及时收回来
6. 净值高 ≠ 基金贵
净值5元
不代表比
净值1元
贵5倍
7. 分红 ≠ 凭空赚钱
分红会伴随除息机制。
8. 涨停 ≠ 停止交易
真正决定能否成交的是:
是否存在匹配的对手盘。
9. 市价类申报 ≠ 完全不管价格
中国交易所对市价申报存在具体类型和保护机制。
10. 收益率高 ≠ 策略好
还必须看:
- 波动率
- 最大回撤
- Beta
- 夏普
- 交易成本
11. 低PE ≠ 被低估
可能只是:
市场预期未来利润会下降。
12. 回测赚钱 ≠ 实盘赚钱
真实交易还存在:
佣金
印花税
买卖价差
滑点
冲击成本
停牌
涨跌停
T+1
数据偏差
未来函数
幸存者偏差
这些会把漂亮的回测结果“打回现实”。
8. 官方资料来源
以下资料用于核对本文涉及的交易规则、指数定义、ETF 和税费等基础事实。
-
上海证券交易所:《上海证券交易所交易规则(2026年修订)》
https://www.sse.com.cn/lawandrules/sselawsrules2025/stocks/exchange/c/c_20260424_10816482.shtml -
上海证券交易所:上交所修订发布《上海证券交易所交易规则》
https://www.sse.com.cn/aboutus/mediacenter/hotandd/c/c_20260424_10816474.shtml -
深圳证券交易所:深交所市场证券交易时间说明
https://www.szse.cn/www/investor/knowledge/stock/deal/t20191204_572383.html -
上海证券交易所:沪深300指数编制方法
https://www.sse.com.cn/market/sseindex/diclosure/c/c_20150911_3984891.shtml -
中证指数有限公司:中证1000指数编制方案
https://oss-ch.csindex.com.cn/static/html/csindex/public/uploads/indices/detail/files/zh_CN/20231208175402-000852_Index_Methodology_cn.pdf -
上海证券交易所:中证500相关指数说明
https://etf.sse.com.cn/fundtrends/c/5717638.shtml -
上海证券交易所:什么是 ETF?
https://etf.sse.com.cn/fund/learning/knowledge/c/5704301.shtml -
上海证券交易所:投资 ETF 的优势
https://etf.sse.com.cn/fund/learning/knowledge/c/5704298.shtml -
财政部、国家税务总局:《关于减半征收证券交易印花税的公告》
https://www.mof.gov.cn/jrttts/202308/t20230828_3904235.htm -
中国证监会:《公开募集证券投资基金运作管理办法》
https://www.csrc.gov.cn/csrc/c106256/c1653978/content.shtml
最后:把这些词压缩成 10 个真正需要记住的关系
1. 股票 = 公司的一部分所有权
2. 基金 = 一篮子资产对应的基金份额
3. ETF = 可以在交易所实时买卖的基金
4. 指数 = 一套选股 + 加权 + 计算规则,不是基金本身
5. 收益不能脱离风险讨论
6. 最大回撤告诉你“最惨的时候有多惨”
7. 波动率告诉你“平时晃得有多厉害”
8. PE/PB/ROE帮助描述公司的价格、资产与盈利能力
9. 因子 = 能对大量股票统一计算的特征
10. Alpha = 扣除可以解释的风险暴露后,仍然剩下的超额收益
如果把整份手册再压成一条学习主线:
先搞懂“市场怎么交易”
↓
再搞懂“基金怎么衡量收益和风险”
↓
再搞懂“公司为什么值钱”
↓
最后把这些逻辑写成公式和代码
↓
量化投资
Slay the spine 2
《杀戮尖塔 2》毒雾与触媒的数学建模分析
1. 问题描述
设怪物当前有 层毒。
毒的基础机制为:
- 每回合结算一次毒;
- 每次造成等于当前毒层数的伤害;
- 每次结算后,毒层数减少 1。
例如,怪物有 5 层毒,在不再获得新毒的情况下,后续伤害依次为:
两张牌的效果如下:
- 毒雾:每回合施加 3 层毒;
- 触媒:每回合额外结算 2 次毒,即每回合共结算 3 次毒。
假设每回合的结算顺序为:
- 毒雾施加 3 层毒;
- 结算毒伤害;
- 每次结算后毒层数减少 1。
同时假设触媒一旦生效,之后每回合都会额外结算 2 次毒。
玩家 A 认为:
毒的总伤害是固定的,触媒只是把原本以后造成的伤害提前结算。
玩家 B 认为:
应该先利用毒雾叠高毒层,再使用触媒,这样能够发挥毒更强的能力。
这两个观点分别在不同前提下成立。
2. 基础状态模型
定义:
- :第 回合开始时、毒雾触发前的毒层数;
- :第 回合造成的毒伤害。
2.1 未使用触媒
毒雾先施加 3 层毒,因此毒结算前的毒层数为:
本回合造成的伤害为:
结算一次后,毒层数减少 1:
因此:
也就是说,在只有毒雾、没有触媒时,毒层数每回合净增加 2 层。
2.2 使用触媒后
触媒使毒每回合结算 3 次。
毒雾触发后有 层毒,三次结算的伤害分别为:
因此,本回合总伤害为:
化简得到:
三次结算后,毒层数共减少 3:
因此:
这意味着触媒生效以后:
- 毒雾每回合增加 3 层毒;
- 三次毒结算每回合消耗 3 层毒;
- 毒层数不再继续增长,而是维持不变。
这正是双方争论的核心。
3. 没有后续毒源时:玩家 A 正确
假设怪物当前有 层毒,之后不再获得任何新毒。
这些毒最终能够造成的总伤害为:
根据等差数列求和公式:
例如,10 层毒的总伤害为:
普通结算
伤害依次为:
触媒结算
每回合结算三次,伤害可能表现为:
两种情况下,总伤害均为:
从势能函数的角度看,一次普通毒结算满足:
触媒一次进行三次结算时满足:
因此,如果之后不再施加新毒,那么触媒不会改变已有毒的最终总伤害,只会让这些伤害更早出现。
所以,玩家 A 的观点在以下前提下成立:
毒层数固定,之后没有任何新的毒层加入。
4. 有持续毒雾时:玩家 B 正确
有毒雾时,系统不再是一个没有外部输入的封闭系统。
假设当前有 层毒,毒雾增加 3 层毒。毒的剩余总伤害从 增加到 。
毒雾新增的理论总伤害为:
代入势能函数:
化简得到:
因此,同样是施加 3 层毒,其增加的理论总伤害取决于当前已有的毒层数。
| 毒雾触发前的毒层数 | 毒雾新增的理论总伤害 |
|---|---|
| 0 | 6 |
| 5 | 21 |
| 10 | 36 |
| 20 | 66 |
这说明毒雾的收益不是固定的。
毒层数越高,新增加的毒层价值越高。
例如,从 层毒增加到 层毒时,毒的理论总伤害增量为:
即:
所以:
例如:
- 从 0 层增加到 1 层,只增加 1 点理论总伤害;
- 从 10 层增加到 11 层,会增加 11 点理论总伤害;
- 从 20 层增加到 21 层,会增加 21 点理论总伤害。
因此,“每层毒都具有固定价值”的理解并不准确。
5. 为什么过早使用触媒会降低长期收益
设某回合开始时,怪物有 层毒。
5.1 当前回合立即使用触媒
毒雾触发后,怪物有:
层毒。
三次结算造成的伤害为:
即:
结算后毒层数重新回到:
下一回合,毒雾仍然只能在 层毒的基础上继续施加毒。
5.2 等待一回合后再使用触媒
当前回合只进行一次普通毒结算。
本回合伤害为:
回合结束时,毒层数为:
因此,下一回合开始使用触媒时,基础毒层数比立即使用触媒高了 2 层。
触媒生效后,每回合伤害从:
提高为:
两者之差为:
所以:
这并不只是把现有伤害推迟到以后。
过早使用触媒还会阻止毒层继续增长,从而降低之后毒雾产生的新毒的价值。
6. 固定毒雾持续回合数时的严格结论
假设:
- 初始毒层数为 ;
- 毒雾还会触发 次;
- 触媒从第 回合开始生效;
- 毒雾停止后,允许剩余毒全部结算完毕。
比较两种方案:
- 第 回合使用触媒;
- 第 回合使用触媒。
如果第 回合不使用触媒,而是只正常结算一次,那么该回合结束时会比立即使用触媒多保留 2 层毒。
从第 回合开始,每次毒雾都在高 2 层的基础毒层上生效,因此每次毒雾新增的理论总伤害多出:
推迟后仍然存在的毒雾触发次数为:
因此,将触媒推迟一回合带来的最终总伤害增量为:
由此可得:
- 如果之后已经没有毒雾,即 ,推迟触媒不改变最终总伤害;
- 如果之后还有毒雾,即 ,推迟触媒会严格提高最终总伤害;
- 每多剩余一次毒雾,推迟一回合就会多获得 6 点理论总伤害。
因此,在“最大化最终理论总伤害”的目标下:
在这个意义上,玩家 B 的观点正确。
7. 固定战斗回合数时的模型
实际战斗中,不能只考虑所有毒最终结算完毕后的总伤害,还需要考虑限定回合内造成的伤害。
假设:
- 当前初始毒层为 ;
- 战斗最多还会持续 回合;
- 触媒从第 回合开始生效。
其中:
7.1 触媒生效前的伤害
在未使用触媒时,毒层数每回合净增加 2。
第 回合开始时的毒层数为:
毒雾触发后,本回合伤害为:
代入得到:
因此:
7.2 触媒生效后的伤害
触媒从第 回合开始生效。
第 回合开始时的毒层数为:
触媒生效后,毒层数不再增长,因此之后每回合的毒伤害固定为:
化简得到:
其中:
7.3 前 回合的累计伤害
触媒生效前共有 个回合,累计伤害为:
化简得到:
触媒生效后共有:
个回合,每回合伤害为:
因此,前 回合的总伤害为:
8. 应该在哪一回合使用触媒
比较:
- 第 回合使用触媒;
- 第 回合使用触媒。
两种方案的伤害差为:
化简可得:
因此:
推迟一回合更好
如果:
则:
此时应该继续叠毒,下一回合再使用触媒。
当前回合使用更好
如果:
则:
此时应该当前回合使用触媒。
两种时机相同
如果:
则:
两个使用时机的累计伤害相同。
最优触媒回合可以近似写为:
最后还要将结果限制在合法范围内:
如果分式结果恰好为整数,则相邻两个回合可能并列最优。
9. 示例:初始 0 层毒,战斗持续 5 回合
假设:
毒雾始终有效,战斗共持续 5 回合。
方案一:第一回合立即使用触媒
每回合毒雾增加 3 层毒,然后连续结算三次:
三次结算后毒层数重新回到 0。
因此,5 回合总伤害为:
方案二:前四回合叠毒,第五回合使用触媒
| 回合 | 毒雾触发后毒层 | 本回合伤害 | 回合结束毒层 |
|---|---|---|---|
| 1 | 3 | 3 | 2 |
| 2 | 5 | 5 | 4 |
| 3 | 7 | 7 | 6 |
| 4 | 9 | 9 | 8 |
第五回合毒雾触发后,毒层达到:
触媒造成的伤害为:
前 5 回合总伤害为:
相比第一回合立即使用触媒,多造成:
点伤害。
而且第五回合结束后,怪物仍有:
层毒。
只要毒雾和触媒继续生效,之后每回合仍然会造成:
点毒伤害。
这说明过早使用触媒会把毒层锁定在较低水平。
10. 示例:5 回合内不同触媒时机的比较
仍然设:
根据累计伤害公式:
可以计算:
| 触媒开始回合 | 前 5 回合总伤害 |
|---|---|
| 1 | 30 |
| 2 | 51 |
| 3 | 62 |
| 4 | 63 |
| 5 | 54 |
因此,在这个例子中,第 4 回合使用触媒最好。
这说明:
- 第一回合使用过早,毒层没有成长起来;
- 第五回合使用过晚,虽然单回合爆发很高,但剩余触媒回合太少;
- 最优时机通常位于“立即使用”和“最后使用”之间。
11. 当前回合是否应该使用触媒
设当前回合开始时有 层毒,当前回合之后还会继续战斗 个回合。
比较:
- 当前回合立即使用触媒;
- 当前回合正常结算,下一回合再使用触媒。
立即使用触媒的当前伤害
等待一回合的当前伤害
因此,等待一回合会暂时少造成:
即:
点即时伤害。
但是,等待一回合后会多保留 2 层毒,使之后每个触媒回合多造成:
点伤害。
如果等待后还有 个触媒回合,那么等待的长期收益为:
因此,等待一回合的净收益为:
等待一回合更好的条件为:
立即使用触媒更好的条件为:
两种方案等价的条件为:
12. 实战决策总结
适合立即使用触媒的情况
- 怪物剩余生命值较低;
- 当前毒层已经较高;
- 战斗预计很快结束;
- 怪物即将清除负面状态;
- 怪物即将进入无敌阶段或逃跑;
- 当前需要爆发伤害来避免受到致命攻击。
适合继续叠毒的情况
- 当前毒层较低;
- 怪物剩余生命值较高;
- 毒雾还会持续很多回合;
- 战斗预计还会持续较长时间;
- 当前没有迫切的爆发伤害需求。
只追求最终理论总伤害
如果不考虑战斗时间,并允许全部毒最终结算完毕,则应该:
追求最短击杀时间
设怪物剩余生命值为 。
需要对不同触媒时机 计算累计伤害,寻找最早满足以下条件的回合 :
此时的最优方案不一定是最早使用触媒,也不一定是最晚使用触媒。
13. 双方观点的判定
| 命题 | 判定 |
|---|---|
| 已有固定毒层的最终总伤害是固定的 | 正确 |
| 触媒对已有毒层主要是在提前结算 | 正确 |
| 有持续毒雾时,何时使用触媒都不影响总伤害 | 错误 |
| 等毒雾叠高后再使用触媒能提高长期收益 | 正确 |
| 任何情况下都应该尽可能晚使用触媒 | 错误 |
| 敌人血量较低时应倾向立即使用触媒 | 正确 |
| 敌人血量较高、战斗持续较久时应倾向先叠毒 | 正确 |
14. 最终结论
玩家 A 的说法只适用于没有后续毒源的封闭系统。
对于一组已经存在、之后不再增加的毒层,触媒确实只是在提前结算伤害,不会改变这些毒最终能够造成的总伤害。
但是,毒雾会持续向系统中加入新的毒层,而新增毒层的价值取决于当前已有毒层数。过早使用触媒会阻止毒层继续增长,从而降低之后每次毒雾产生的新毒的价值。
因此,在题目描述的持续毒雾环境中:
但在实际战斗中,最优触媒时机仍然取决于:
- 当前毒层;
- 怪物剩余生命值;
- 预计剩余回合数;
- 毒雾还会持续多久;
- 怪物是否会清除毒;
- 是否需要立即造成爆发伤害。
更完整的结论是:
触媒对当前已有毒层主要起到提前结算的作用,但它也会改变之后毒雾叠毒时的基础毒层,从而改变后续新增毒层的总价值。等待能够提高长期伤害,立即使用能够提高短期爆发。
构建
- 取 N/M (M为HNSW图中的节点邻居数,最底层为2M)的点作为上层导航图先进行构建,再将所有 N 点利用上层导航图完成划分分片。
- 上层导航图先利用聚类算法根据聚集关系进行划分,再通过自搜索方式进行微调划分关系。
- 自搜索:以该点作为目标进行搜索(efsearch=efs)对得到的 efs 个结果进行投票,每个结果以自己所在的分区进行投票,将目标点移动到票数最多的分区上。
- 导航图分配点到分片上时,分片同步进行索引构建。对于多分配,则相当于多个分片都接收到了要构建的点,由于分配速度比构建速度快,需要做好缓冲,避免同步阻塞。
- 分配与多分配:一个点应该分配到哪个分片上,应当由其在导航图上的搜索结果确定,以该点作为目标点在导航图上以 efsearch=efs 进行搜索,对于得到的搜索结果,每个结果根据其所在的分片进行投票,最终将目标点分配到所有票数最高的分片上,若最高票为 1 票,则只分配到最近的分片上。
- 分片上构建点时构建完整的多层 HNSW 图。
搜索
- 搜索时根据导航图搜索结果所在的分片来决定哪些分片需要参与搜索,导航图搜索结果中一个分片上的结果点越多,该分片的 efsearch 越大,efsearch= a * EP+b,EP 是该分片的搜索结果个数。
- 在分片上的图中进行搜索时,以导航图中的结果点作为入口点直接在底层分片的最底层开始搜索。
- 搜索时负责路由的导航图与分片,分片与分片之间,分片内部都要充分地并行,不要让 query 之间存在同步阻塞。
构建
- 取 N/M (M为HNSW图中的节点邻居数,最底层为2M)的点作为上层导航图先进行构建,再将所有 N 点利用上层导航图完成划分分片。
- 上层导航图先利用聚类算法根据聚集关系进行划分,再通过自搜索方式进行微调划分关系。最终的划分关系要求使得最终的底层分片划分符合负载均衡。
- 自搜索:以该点作为目标进行搜索(efsearch=efs)对得到的 efs 个结果进行投票,每个结果以自己所在的分区进行投票,将目标点移动到票数最多的分区上。
- 负载均衡:将全量数据点通过上层导航图划分到底层不同分片上时能确保不会出现热点分片,不要求做到绝对均衡,要求控制下限,不要出现差距悬殊的分片影响系统性能。同时该架构的设计不应影响图结构的空间局部性以及图的拓扑感知结构。
- 导航图分配点到分片上时,分片同步进行索引构建。对于多分配,则相当于多个分片都接收到了要构建的点,由于分配速度比构建速度快,需要做好缓冲,避免同步阻塞。
- 分配与多分配:一个点应该分配到哪个分片上,应当由其在导航图上的搜索结果确定,以该点作为目标点在导航图上以 efsearch=efs 进行搜索,对于得到的搜索结果,每个结果根据其所在的分片进行投票,最终将目标点分配到所有票数最高的分片上,若最高票为 1 票,则只分配到最近的分片上。
- 分片上构建点时构建完整的多层 HNSW 图,完成构建后只保留分片上 HNSW 图的最底层图。
搜索
- 搜索时根据导航图搜索结果所在的分片来决定哪些分片需要参与搜索,导航图搜索结果中一个分片上的结果点越多,该分片的 efsearch 越大,efsearch= a * EP+b,EP 是该分片的搜索结果个数。
- 在底层图中进行搜索时,以导航图中的结果点作为入口点直接在底层分片的最底层开始搜索。
“聚类初始化与自搜索投票微调”部分,默认 search_ef=100、投票结果数 k_overlap=10,不是定义中的“efs 个结果”,请改成搜 efs=10,构建阶段的搜索令 efs=topk 就可。
暂时移除负载均衡的设计,只要求导航图使用聚类进行初始分片划分以及使用自投票进行微调。暂且不考虑对底层分片的均衡性进行干预。
严格采用“一个点应该分配到哪个分片上,应当由其在导航图上的搜索结果确定,以该点作为目标点在导航图上以 efsearch=efs 进行搜索,对于得到的搜索结果,每个结果根据其所在的分片进行投票,最终将目标点分配到所有票数最高的分片上,若最高票为 1 票,则只分配到最近的分片上。”的多分配策略。
实现一边通过导航图进行分片数据分配,一边在分片上构建独立的完整 HNSW 图,建成图后不删除非底层图,而是在搜索时直接在最底层以入口点作为起始点开始搜索。
• 已按最终要求完成代码改进,未引入任何负载均衡或分配后调整。
主要改动:
- Orion auto-shard 对所有非空 segment 强制构建完整 HNSW,包括只有 1 个点的分片。lib/shard/src/optimizers/config.rs:184 lib/collection/src/optimizers_builder.rs:254
- 公开 indexing_threshold=0 仍表示禁用索引;内部强制索引语义仅对 Orion 生效,不影响普通 Qdrant 集合。lib/collection/src/config.rs:495
- 完整多层 HNSW 永久保留,同时维持一个空 appendable segment,方便后续插入。
- 显式导航入口存在时,只在 L0 执行 MultiEP 搜索。
- 当前 segment 不包含入口时直接返回空,不再回退默认 HNSW 入口或遍历非底层。lib/segment/src/index/hnsw_index/graph_layers.rs:659
- Plain segment 收到导航入口搜索时不再执行全量扫描,避免绕开 L0 HNSW 语义。lib/segment/src/index/plain_vector_index.rs:83
- 自定义分片构建工具增加完整性门槛:必须全部副本完成索引,并满足每分片“一个完整 HNSW + 一个 appendable segment”。tools/qdrant_two_level_routing_experiment.py:6192
验证:
- Python/工具测试:256 项通过。
- Rust 阈值语义测试:3 项通过。
- Rust L0 禁止回退测试:1 项通过。
- Rust Orion policy 测试:3 项通过。
- Rust 单点分片实际优化测试:通过,确认生成一个单点完整 HNSW 和一个空 appendable segment。
- git diff –check 通过。
源码已完成,但当前在线 Qdrant 进程尚未替换为新二进制或重启;部署后这些服务端行为才会生效。
边分配、边构建分片
尚未实现完整流水线。先算完并发布全部分配产物,再导入。已有的是导入阶段的分片并发。
我要求的是边计算分片分配,边在获得插入点的分片上进行索引的构建。因为从宏观上来看,这就是一个整体的 HNSW 的构建过程,所有分片都参与到构建过程中。如果人为分开导航图分配与底层图构建的过程,就会导致集群的 CPU 利用率低。
不同 Query 的导航与分片执行流水重叠
同一批请求中仍有屏障。先等待整批导航任务完成,再发起分片搜索;不同独立请求可以重叠,但批内尚未做到逐 Query 导航完成就下发。
你认为如何实现才能最大化搜索时的性能表现?我直观上觉得最细粒度地并行是性能最高的方式。任何屏障或者互相阻塞等待都会导致 CPU 空转进而导致 CPU 利用率降低。
构建完整图后只保留 L0
当前
agent.md:12明确要求保留完整多层图,代码也按这个方向执行。此前记录要求“只保留 L0”;如果旧要求仍代表你的 idea,这就是实质变化,会影响索引空间和维护成本。
过去的要求过时了,明确要求构建时底层分片保留完整的多层图,在搜索时从底层分片的 L0 入口点对应的点进行搜索不代表除了 L0 的其他层不再需要了。
“逐点自搜索后迁移”
当前是同步轮更新。如果你想的是按顺序立即更新 owner,两者可能产生不同最终分区。
按顺序立即更新会导致很严重的依赖问题,严重影响并发。我建议采用按轮进行更新,更新几轮,当达到上限或者变化量小的时候停止。
“按聚集关系聚类”
当前具体实现是向量上的 K-Means,距离计算为平方欧氏距离;不是基于导航图边的图划分。若你指的是图社区聚类,这里有概念差异。
此处的聚类就是在 HNSW 的图结构上再次按照空间距离进行聚类划分,跟图结构本身无关。在余弦距离数据集中由于搜索的时候比较的是余弦距离,而不是归一化后的欧氏距离,因此我认为在导航图聚类时应当采用与搜索时行为相同的距离计算的行为,根据此原则来选择聚类的方式。
所有查询都走 Orion 路由
当前只支持匹配导航向量的无过滤 dense nearest 查询等条件。带 filter、exact、其他向量类型等会走普通 Qdrant 路径;批内一个请求不符合条件,也会让整批退出 Orion 路由。
当前场景只面向普通的向量检索,不考虑特殊种类和场景下的向量检索。
1zone
/Users/ruoyangdong/project/1zone
2026-07-11
- [refactor] 完成项目根目录扁平化与 1zone 品牌迁移,统一 Swift Package、Xcode 工程、共享 Scheme、应用目标及 EasyOne 模块命名。
- [feat] 整合任务记录与打卡统计、提醒和位置服务、附件与语音输入、云同步与本地备份、VIP 权益及 Widget 数据链路,形成更完整的原生应用能力。
- [style] 更新全平台应用图标、品牌资源和便单新手引导配图,统一 iPhone、iPad 与 macOS 的产品呈现。
- [test] 扩充应用状态、统计、同步、备份、提醒、Widget、资源和 Xcode 工程测试,并更新综合验证记录。
- [doc] 更新项目说明、代理指南及任务记录、打卡分析、云同步和品牌迁移等设计实施文档。
2026-07-10
- [doc] 完成 1zone 根目录与品牌迁移设计,统一用户可见品牌、EasyOne 代码命名、应用与组件标识,并明确数据身份切换及脏工作区保护边界。
- [doc] 制定覆盖目录扁平化、SwiftPM、Xcode、Widget 与 StoreKit 重命名、残留扫描和跨平台构建测试的实施计划。
2026-07-06
- [feat] 将主界面升级为便单风格的自适应任务工作台,紧凑端提供“清单、日历、四象限、设置”底部导航,宽屏端提供侧栏与智能筛选。
- [feat] 完成今日仪表盘、卡片式月历、四色四象限、主题与深色模式、备份入口及统一任务编辑器,强化中文任务状态、进度与到期信息展示。
- [fix] 修正今日投影与完成操作路由、日历选日与摘要、占位与点击区域、深色表面及紧凑布局细节,并收敛非紧急任务的视觉语义。
- [test] 扩充应用状态、视图投影与便单界面契约测试,并记录 SwiftPM、iOS 模拟器和 macOS 构建验证,最终 69 项测试通过。
2026-07-05
- [doc] 建立 Apple 待办应用规划基线、原生应用实施方案,以及便单风格重设计的规格与实施计划。
- [build] 建立 Swift 6 多目标包与原生 Xcode 工程,拆分核心域、Widget、应用特性和可执行应用,并支持 iOS、iPadOS 与 macOS 构建运行。
- [feat] 实现任务清单、重复规则、实例状态、紧急度、查询与操作服务,以及本地存储、导入导出、通知计划和 Widget 快照等核心能力。
- [feat] 完成 SwiftUI 应用壳层、任务列表与编辑器、日历、四象限、设置和备份预览,并为紧凑导航加入完成、撤销、顺延和滑动操作。
- [fix] 修复任务更新与逾期统计、无日期任务操作、同日选择、iOS 紧凑侧栏构建及计划投影过滤,避免归档、删除或缺失清单污染日历、通知和 Widget。
- [test] 建立覆盖重复规则、紧急度、任务操作、查询、导入导出、Widget 与应用状态的测试体系,并完成 48 项测试、SwiftPM 构建及 iOS、macOS 原生工程验证。
Betterfly
/Users/ruoyangdong/project/betterfly-native-ios-macos-migration
2026-07-08
- [feat] 完成 BetterFly 原生多平台迁移,统一应用路由与平台壳层,并系统升级录制、历史、统计、设置、文档和会话编辑体验。
- [feat] 新增打卡模型、持久化与统计分析链路,支持录制中的并发事件和 Liquid Glass 反馈,并扩展统计视图及筛选能力。
- [refactor] 完善跟踪器查询、导入导出规范化、统计上下文与数据模型,收拢跨功能的数据语义和状态流转。
- [test] 大幅扩充应用导航、录制工作流、历史、设置、统计、存储及端到端界面测试,覆盖迁移后的核心路径。
- [doc] 补齐内置中文文档、主题配图、图像生成配置及壳层、打卡统计、录制并发和会话编辑等设计实施方案。
2026-07-07
- [doc] 细化录制并发事件与 Liquid Glass 交互方案,补充文档页热力图修正、主题配图资产约束及图像生成命令。
2026-07-06
- [doc] 完成统计图表目录的设计与实施计划,并制定会话编辑器时间范围重设计方案。
- [feat] 建立统计图表目录和数据构建层,新增矩形、极坐标图表及单图切换展示能力。
- [fix] 补齐图表数据依赖、活动图表路由、视口范围和日期窗口约束,并通过目录测试验证关键配置。
2026-07-05
- [feat] 新增时间锚点统计、分析面板、Swift Charts 与画布图表,并接入统计仪表盘和动画展示。
- [fix] 完善时间锚点画像分类、面板身份、图表语义、文案与漂移桶显示,并以午夜画像和面板测试覆盖关键边界。
- [feat] 对齐专业仪表盘中的设置、内置文档和编辑器体验,并完成仪表盘重设计验证。
2026-07-04
- [feat] 按分析主题分组并打磨统计仪表盘,统一自定义图表的布局与呈现。
- [fix] 修正统计图表评审问题和界面测试稳定性,同时本地化历史记录操作并扩大编辑触控区域,补充仪表盘章节断言。
2026-07-03
- [doc] 定义专业版仪表盘的视觉方向、界面结构与分步实施计划。
- [feat] 建立仪表盘设计系统和录制摘要,重做录制看板及事件编辑核心交互。
- [fix] 修复录制看板的工作流状态、记录卡片身份、触控范围和无障碍问题,并补强流程与界面测试。
- [feat] 重构历史工作区和会话编辑体验,完善历史记录浏览、筛选、整理及相关测试。
2026-07-02
- [doc] 制定本地优先构建与持久化模式的实施计划。
- [feat] 建立本地优先持久化模式,使 Debug 构建无需 CloudKit 签名,并明确本地运行时的 iCloud 状态。
- [fix] 调整状态枚举、跟踪器快照和领域值的并发隔离,补强持久化模式预期测试。
2026-07-01
- [feat] 实现 CDWI、健康 TSB、热力图、直方图、雷达图、散点图、频谱与趋势等原生统计计算,并覆盖统计边界场景。
- [feat] 建立录制、事件编辑和会话编辑流程,将录制状态提升为跨界面共享状态。
- [feat] 新增原生历史与统计工作区,完善统计控件及图表身份稳定性。
- [feat] 增加设置页数据导入导出、iCloud 同步状态和内置使用文档。
- [fix] 收敛日期范围、无效记录、整日会话和桶身份等统计问题,完成迁移评审修正、冒烟测试与无障碍打磨。
2026-06-30
- [doc] 编写原生多平台迁移设计和实施计划,明确架构、数据与交付步骤。
- [build] 搭建原生多平台应用壳层、路由与模型容器,并按 macOS、iOS 和 iPadOS 拆分平台界面。
- [feat] 建立跟踪器领域模型、日期与颜色校验、导入导出编解码和数据规范化,并移除规范导出中的内部元数据。
- [feat] 新增 SwiftData 事件、会话与设置模型以及查询和存储层,补充存储、规范化和导出测试。
- [chore] 完善 Swift 与 SDD 产物忽略规则,清理误跟踪的临时报告并保持任务草稿不入库。
2026-06-21
- [build] 建立 Betterfly Xcode 工程、应用入口、基础 SwiftData 示例界面及单元测试和界面测试脚手架。
Golden Compass
/Users/ruoyangdong/project/golden-compass-apple
2026-07-07
- [feat] 完善 Markdown 路线图工作流与首次使用引导,加入教程、插图资源和工作台入口,串联项目导入、节点编辑、依赖聚焦与导出操作。
- [feat] 将检查器重塑为可浮动的 Apple 风格属性面板,统一画布、源码查看器、侧边栏与工作台的视觉语义和信息层级。
- [fix] 收敛原生工作台交互与编辑问题,修正源码选择、依赖移除提示、轨迹板平移方向、面板标题排版和命令联动,并保持当前进度 YAML 的实际文件路径。
- [test] 扩充应用冒烟、源码编辑与工作区回归覆盖,验证教程引导、面板交互和进度文件写回等关键流程。
2026-07-06
- [fix] 完成 JSON 工作台评审收尾,修正项目派生、状态同步、检查器编辑和画布呈现等阻塞问题。
- [doc] 补充画布优先 Liquid Glass 工作台、原生辅助面板布局与 Apple 原生重设计的设计规格和实施计划。
- [refactor] 将工作台重构为画布优先架构,分离导航与源码查看,把依赖编辑收归检查器,并建立按需显示的辅助面板和原生命令工具栏。
- [feat] 为节点画布加入平移、缩放、适配视图与 Liquid Glass 控件,支持轨迹板操作,并持续修正紧凑布局、玻璃效果和工具栏语义。
- [feat] 让示例项目可原地编辑和持久化,以原生菜单与分栏侧边栏统一命令入口,并新增基于文件系统的源码树和代码查看器。
- [test] 补充工作台区域状态、面板切换、画布视口、命令菜单、示例项目存储和源码浏览的冒烟与单元测试。
2026-07-05
- [doc] 编写中文节点工作台设计规格,并为中文节点工作台与 JSON 工作台制定实施计划,明确可视化编辑、项目数据交换与运行时迁移路线。
- [feat] 建立中文节点工作台,覆盖布局状态持久化、项目节点投影、节点对象视图、交互式画布、检查器编辑和主要业务视图本地化。
- [fix] 修正节点尺寸、缩放适配、检查器草稿生命周期和中文文案一致性,并通过冒烟测试收敛中文工作台体验。
- [feat] 建立 JSON 项目文档模型及 Markdown 导入导出能力,接入运行时、源码与检查器面板、布局持久化和依赖有向图编辑,形成项目数据闭环。
- [fix] 修复 Markdown 围栏解析、折叠面板状态、运行时布局、源码忠实展示、空筛选工具可见性和内置示例加载问题,完成 JSON 工作台验收。
- [refactor] 将核心库、工作区模块、应用源码和测试迁移到统一 Swift Package,补齐 Xcode 工作区、共享方案、应用图标和产品结构校正。
2026-07-03
- [build] 初始化 Golden Compass Apple 的 Swift Package、Xcode 工程、共享 Scheme、应用宿主和基础测试入口,建立原生项目骨架。
- [feat] 移植路线图、依赖与进度解析能力,建立项目模型、状态推导和依赖进度计算等核心数据基础。
- [feat] 搭建 SwiftUI 原生工作台,提供概览、工作队列、问题、依赖、路线图和详情编辑等桌面端主要界面。
- [feat] 打通工作区与示例项目加载、源码读取编辑和写回流程,使原生工作台具备完整项目浏览与修改能力。
- [fix] 修复最终评审发现的状态同步、进度解析、源码编辑和详情展示问题,并补齐核心单元测试与应用冒烟验证。
- [doc] 更新项目说明并形成主代理验收报告,沉淀原生化迁移范围、验证结果和交付结论。
mdbook frontmatter render
/Users/ruoyangdong/project/rust/mdbook-frontmatter-render
Pixrompt
/Users/ruoyangdong/project/pixrompt-apple
2026-07-10
- [chore] 建立当前项目基线,纳入 PixromptCore 核心源码与 SwiftUI Apple 应用的现有代码和资源。
- [test] 随基线纳入核心逻辑、应用状态、持久化、同步及 UI 接线等现有测试。
- [build] 随基线纳入 Swift Package、Xcode 工程、工作区与共享 Scheme 等工程配置。
- [doc] 随基线纳入 README、设计规格、实施计划、审计与交付记录等现有文档。
Qoffee
/Users/ruoyangdong/project/QoffeeApple
2026-07-07
- [feat] 推进记录闭环与冲煮分析,补充冲煮诊断模型、规则与持久化迁移,完善记录呈现,并同步调整主导航信息架构与分享评分尺度。
- [feat] 将分析页升级为图表优先体验,支持筛选、详情下钻和洞察操作,完善异常值路由、维度缺失处理、筛选保持与评分尺度。
- [feat] 建立主题环境令牌并应用到共享界面与图表,加入主题动效、品牌降级资源以及覆盖 iPhone、iPad 和 macOS 的应用图标资产。
- [style] 新增一览式设计系统组件,重整资产、记录、指南、冲煮和分析界面的信息层级,并将记录详情改为报告式呈现。
- [fix] 修复冲煮与指南流程的可访问性回归、记录启动预览标签,以及 macOS 截图捕获、iPad 侧栏和外观切换等跨平台视觉验证问题。
- [test] 建立 iPad 与 macOS 视觉验证矩阵,覆盖主题切换、动态字体、窗口降级捕获、应用图标和共享测试方案,并修复 iOS 单元测试目标编译。
- [doc] 补充本地功能闭环规格与分波实施计划,以及分析增强、主题环境与一览式界面的设计规格和实施计划,明确后续体验演进的架构边界。
2026-07-06
- [feat] 完成本地指南仓储与仓储驱动路由,支持指南列表、详情和启动状态的持续更新,并对空指南提供明确反馈。
- [feat] 建立本地冲煮会话仓储与路由,持久化活动会话元数据,完善阶段缺失处理、指南作用域和计时器推进。
- [feat] 加入实验持久化仓储和实验工作台路由,支持从记录复盘创建实验,并打通项目详情与核心契约。
- [feat] 新增本地学习路线、教练建议、分享卡片与订阅页面,使指南和分析结果能够驱动离线学习与建议体验。
- [fix] 将实验数据纳入本地备份,修复指南、分析、设置与个人资料路由的流式同步,以及教练建议来源和订阅状态文案。
- [test] 补充指南、会话、实验、学习、订阅、备份和记录生成实验失败场景的仓储与路由测试。
2026-07-05
- [feat] 完成豆子、磨豆机和配方编辑保存链路,支持磨豆刻度归一化、水温曲线编辑、配方删除、资产上下文菜单与 macOS 命令。
- [feat] 建立分析统计、仪表盘聚合、洞察报表、趋势分析、参数窗口与分段对比能力,并对齐近期高亮选择逻辑。
- [feat] 组装本地可写应用环境,接入本地分析仓储、备份导出以及仓储驱动的数据、设置和个人资料路由。
- [style] 优化自适应应用壳层、导航和设计系统基础组件,统一选择态对比度、图表符号语义、资产扫描效率与动态字体布局。
- [fix] 修复配方数值精度、记录完成恢复的水温曲线映射、只读水温曲线可读性、工作台操作反馈,以及本地不支持能力的状态表达和预览隔离。
- [test] 新增资产流程 UI 冒烟测试,并收紧分析环境、备份、设置资料、本地预览和图表语义的测试覆盖。
- [doc] 编写 QoffeeApple 界面改进设计规格与首轮实施计划,明确应用壳层、设计系统和核心工作台的体验方向。
2026-07-04
- [feat] 扩展记录编辑器,支持水温曲线编辑、完成校验与持久化,并在完成期间阻止冲突修改。
- [feat] 建立记录详情报告,加入复用记录、覆盖配方、确认删除和从记录创建指南等操作。
- [feat] 将资产首页接入本地仓储,以统一展示豆子、磨豆机和配方数据及其记录入口。
- [fix] 修复记录自动保存意图陈旧、记录详情磨豆刻度归一化和完成流程状态一致性问题。
- [test] 补充记录编辑、完成、详情操作、水温曲线和资产展示的单元与呈现测试。
2026-07-03
- [feat] 搭建 SwiftData 持久化基础设施,实现存档、目录和偏好仓储,并以仓储工作流测试覆盖核心读写路径。
- [feat] 完成配方、记录草稿、记录完成和豆子库存投影的持久化仓储,形成从配方到库存扣减的本地数据链路。
- [feat] 构建自适应应用壳层、存档切换、macOS 命令和可复用设计系统组件,覆盖紧凑与宽屏导航。
- [feat] 封装柱状图、折线图、散点图、直方图和水温曲线等 Swift Charts 组件,统一图表脚手架与系列模型。
- [feat] 新增记录工作台入口、草稿路由和编辑器自动保存框架,并为 iPad 只读模式提供清晰标识。
- [fix] 修复实验重复层级标识、演示存档复制、配方水温曲线严格解码、紧凑布局路由选择、图表非颜色编码和自动保存串行化问题。
- [test] 补充 SwiftData 仓储、记录草稿与完成、配方、库存投影、图表设计系统、导航和记录流程测试。
2026-07-02
- [build] 创建空白 QoffeeApple 工程基线并搭建原生 SwiftUI 应用契约,配置应用环境、路由、壳层、工程文件和启动冒烟测试。
- [feat] 建立存档、资产、冲煮、配方、记录等核心领域模型与仓储协议,实现磨豆归一化、配方投影、记录预填与校验、实验规划和水温曲线规则。
- [feat] 补齐分析、实验、指南、设置和同步模型的跨端对齐脚手架与预览数据,为后续本地功能迁移奠定模型基础。
- [fix] 修复契约评审发现的问题,以及豆子库存优先级标识重复导致的规则冲突。
- [test] 建立核心契约、领域规则和模型对齐测试,验证初始工程结构与关键业务约束。
Sharkdown
/Users/ruoyangdong/project/sharkdown
2026-07-08
- [doc] 完成跨平台编辑器大纲体验的设计与实施规划,统一 macOS 与 iOS 的文档结构导航和编辑方向。
- [chore] 补充本地 worktree 目录的忽略规则,避免隔离开发路径进入版本控制。
2026-07-07
- [feat] 为文档导航增加角色分区、上下文目标和就地插入能力,并在 Mac 侧栏按结构组织章节与操作入口。
- [feat] 增加浮动 Markdown 编辑工具栏,完善中文菜单、预览窗格布局、配色和控件状态,收敛工作区审阅中的交互问题。
- [feat] 完成新手引导模型、配图和引导弹窗,支持首次启动自动展示并可从设置再次打开。
- [fix] 将引导资源接入应用包,保留进入引导前挂起的资料库命令,并统一引导按钮的排版令牌。
- [test] 加强新手引导产品覆盖,并修复工作区与预览控制相关的最终回归问题。
2026-07-06
- [doc] 完成文档导航编辑器的设计与实施规划,明确工作区内结构化导航、编辑和拖拽流程。
- [feat] 建立文档导航行模型和路径落位规则,加入可视化拖拽排序及落点有效性校验。
- [feat] 打通 mdBook 预览页面与源文档的映射,使预览定位能够回到对应编辑内容。
- [feat] 增加 Markdown 语法转换、编辑模式和常用编辑命令,形成从只读预览到内容编辑的完整流程。
- [test] 补充文档导航编辑工作流回归验证,覆盖结构调整、预览映射和编辑命令协作。
- [fix] 修复编辑器只读预览状态与原生菜单状态的保持问题。
2026-07-05
- [feat] 支持打开已有 mdBook 工作区和创建新的 mdBook 工作区,补齐真实文档站点工程的启动入口。
2026-07-04
- [chore] 建立 Sharkdown 项目源码、测试与文档基线,并移除临时 SDD 报告的版本跟踪。
- [doc] 完成跨平台界面改进及中文界面与 mdBook 运行时的设计、实施规划,明确 Apple 平台体验和预览服务方向。
- [fix] 改进共享状态语义、macOS 资料库工作区和 iOS 书籍导航,解决首轮界面审阅中的阻塞问题。
- [fix] 完成共享组件、Mac 工作区、运行时诊断、辅助功能标签和 iOS 页面中文化,统一应用文案与布局体验。
- [test] 增加中文界面文案和 mdBook serve 运行时覆盖,验证本地预览主链路。
- [fix] 修复演示元数据、预览、发布和资料库界面的陈旧状态,并综合改善中文应用体验与 mdBook 运行稳定性。
sts2stats
/Users/ruoyangdong/project/sts2stats-clone
Surge lens
/Users/ruoyangdong/project/dashboard-for-surge
traintrace
/Users/ruoyangdong/project/traintrace
2026-07-10
- [chore] 建立 traintrace 当前项目基线,随基线纳入现有 IntervalsCore 领域、API、存储与安全能力,以及 SwiftUI 应用界面和图表实现。
- [test] 随基线纳入现有核心层与应用层测试、脱敏接口 fixture 和 fixture 安全检查,作为当前测试资产快照。
- [build] 随基线纳入现有 SwiftPM 包、Xcode 工程、应用配置、设计资源及 QA 与发布辅助脚本。
- [doc] 随基线纳入现有 API、发布、QA、设计系统和实施规划文档,记录当前产品与工程背景。
typst
/Users/ruoyangdong/Documents/typst
常用代码
查看 codex 额度
(async () => {
"use strict";
// ============================================================
// Codex Quota Compass V2.3
// 多模型费率反校正版 · 纯实测 · 无套餐倍率
//
// 数据来源:
//
// 1. /backend-api/wham/usage
// -> 实时 weekly used_percent / reset
//
// 2. /backend-api/wham/usage/daily-token-usage-breakdown
// -> 每天每个模型消耗的 allowance 百分点
//
// 3. /backend-api/wham/analytics/daily-workspace-usage-counts
// -> 每日总 uncached / cached / output token
// -> 每模型 turns(只展示,不用于价格分配)
//
// 个人账号没有 per-model token breakdown。
//
// 多模型日算法:
//
// 当天 Token 结构:
// fu = uncached / total
// fc = cached / total
// fo = output / total
//
// 模型综合费率:
// Rm = fu * inputRate
// + fc * cachedRate
// + fo * outputRate
//
// OpenAI 给出的该模型 allowance 百分点 = Pm
//
// Token 权重:
// Wm = Pm / Rm
//
// 模型 Token share:
// Sm = Wm / sum(W)
//
// 再按 Sm 分配当天三类总 Token。
//
// 这样可以保证昂贵模型不会因为 quota 占比高
// 就被错误分配同样比例的 Token。
//
// 注意:
// - 完全没有 Plus×1 / ProLite×5 / Pro×20
// - plan_type 只显示
// - 忽略 >272K long-context multiplier
// - 所有价格始终使用基础 Token 单价
// ============================================================
const CONFIG = {
HISTORY_DAYS: 45,
USD_PER_CREDIT: 0.04,
// allowance 百分比太小的日子容易受统计舍入影响。
MIN_DAY_PERCENT: 0.10,
// 同一周期多个反推结果的允许离散。
CLUSTER_TOLERANCE: 0.10,
// ==========================================================
// OpenAI 基础 API / ChatGPT Work Token rate card
// USD / 1M tokens
//
// 按用户要求:
// 不考虑 >272K context 的 2x / 1.5x 加价。
// ==========================================================
MODEL_RATES: {
"gpt-6-astra": {
input: 10.00,
cached: 1.00,
output: 50.00,
},
"gpt-5.6-sol": {
input: 4.00,
cached: 0.40,
output: 20.00,
},
"gpt-5.6-terra": {
input: 2.00,
cached: 0.20,
output: 12.00,
},
"gpt-5.6-luna": {
input: 0.20,
cached: 0.02,
output: 1.20,
},
"gpt-5.5": {
input: 5.00,
cached: 0.50,
output: 30.00,
},
"gpt-5.4": {
input: 2.50,
cached: 0.25,
output: 15.00,
},
"gpt-5.4-mini": {
input: 0.75,
cached: 0.075,
output: 4.50,
},
"gpt-5.3-codex": {
input: 1.75,
cached: 0.175,
output: 14.00,
},
"gpt-5.2": {
input: 1.75,
cached: 0.175,
output: 14.00,
},
"daybreak-blue": {
input: 4.00,
cached: 0.40,
output: 20.00,
},
"daybreak-red": {
input: 12.50,
cached: 1.25,
output: 75.00,
},
},
};
// ============================================================
// 基础工具
// ============================================================
const n = (v) => {
const x = Number(v);
return Number.isFinite(x) ? x : 0;
};
const clamp = (v, min, max) =>
Math.min(max, Math.max(min, v));
const fmtMoney = (v) =>
Number.isFinite(v)
? `$${v.toFixed(2)}`
: "—";
const fmtPct = (v, d = 3) =>
Number.isFinite(v)
? `${v.toFixed(d)}%`
: "—";
const fmtNum = (v) => {
if (!Number.isFinite(v)) return "—";
if (v >= 1e9)
return `${(v / 1e9).toFixed(2)}B`;
if (v >= 1e6)
return `${(v / 1e6).toFixed(2)}M`;
if (v >= 1e3)
return `${(v / 1e3).toFixed(2)}K`;
return Math.round(v).toLocaleString();
};
const dayKey = (ms) =>
new Date(ms).toISOString().slice(0, 10);
const addDays = (key, days) => {
const d = new Date(`${key}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() + days);
return d.toISOString().slice(0, 10);
};
function median(values) {
const a = values
.filter(Number.isFinite)
.sort((x, y) => x - y);
if (!a.length) return null;
const i = Math.floor(a.length / 2);
return a.length % 2
? a[i]
: (a[i - 1] + a[i]) / 2;
}
// ============================================================
// Auth
// ============================================================
function getAccessToken() {
const boot =
document.getElementById("client-bootstrap")
?.textContent || "";
return (
boot.match(
/[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}/
)?.[0] || null
);
}
async function apiGet(path, token) {
const res = await fetch(path, {
credentials: "include",
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
},
});
const text = await res.text();
if (!res.ok) {
throw new Error(
`HTTP ${res.status}: ${text.slice(0, 300)}`
);
}
return JSON.parse(text);
}
// ============================================================
// Model helpers
// ============================================================
function normalizeModel(model) {
return String(model || "")
.trim()
.toLowerCase();
}
function getRate(model, speed = "standard") {
// 这里故意不把 Fast 套用 Standard 价格。
// 当前脚本只精确处理 standard。
if (speed && speed !== "standard") {
return null;
}
return (
CONFIG.MODEL_RATES[
normalizeModel(model)
] || null
);
}
function getModelTurns(countRow) {
const result = new Map();
for (const m of countRow?.models || []) {
const model = normalizeModel(m.model);
result.set(
model,
(result.get(model) || 0) +
n(m.turns)
);
}
return result;
}
// ============================================================
// 多模型反向分配
// ============================================================
function estimateDay(countRow, pctRow) {
const totals = countRow?.totals;
if (!totals) {
return {
valid: false,
reason: "Token 尚未结算",
};
}
const uncached =
n(totals.uncached_text_input_tokens);
const cached =
n(totals.cached_text_input_tokens);
const output =
n(totals.text_output_tokens);
const total =
uncached + cached + output;
if (!(total > 0)) {
return {
valid: false,
reason: "无 Token",
};
}
// 当天 Token 类型结构。
const fu = uncached / total;
const fc = cached / total;
const fo = output / total;
const modelRows = (pctRow?.models || [])
.map((m) => ({
model: normalizeModel(m.model),
speed: m.speed || "standard",
percent: n(m.credits),
}))
.filter((m) => m.percent > 0);
const totalPercent =
modelRows.reduce(
(sum, m) => sum + m.percent,
0
);
if (!(totalPercent > 0)) {
return {
valid: false,
reason: "无 allowance 百分比",
};
}
if (
totalPercent <
CONFIG.MIN_DAY_PERCENT
) {
return {
valid: false,
reason: "allowance 样本太小",
};
}
const turns =
getModelTurns(countRow);
const priced = [];
const unknown = [];
for (const m of modelRows) {
const rate =
getRate(m.model, m.speed);
if (!rate) {
unknown.push(m);
continue;
}
// 当前一天的综合费率。
// USD / 1M “当天平均结构 Token”
const compositeRate =
fu * rate.input +
fc * rate.cached +
fo * rate.output;
if (!(compositeRate > 0)) {
continue;
}
priced.push({
...m,
rate,
compositeRate,
// 关键:
//
// quota share ≈ token * price
//
// 所以:
//
// token weight ≈ quota share / price
tokenWeight:
m.percent /
compositeRate,
});
}
if (!priced.length) {
return {
valid: false,
reason: "当天模型价格均未知",
};
}
const knownPct =
priced.reduce(
(sum, m) => sum + m.percent,
0
);
// 为了避免未知模型偷偷导致金额偏低,
// 只有所有 allowance 都能定价时
// 才允许用于周额度反推。
const coverage =
knownPct / totalPercent;
if (coverage < 0.999) {
return {
valid: false,
reason:
`价格覆盖仅 ${(coverage * 100).toFixed(2)}%`,
unknown,
};
}
const weightTotal =
priced.reduce(
(sum, m) =>
sum + m.tokenWeight,
0
);
if (!(weightTotal > 0)) {
return {
valid: false,
reason: "无法计算 Token 权重",
};
}
let dayUsd = 0;
const allocatedModels =
priced.map((m) => {
const share =
m.tokenWeight /
weightTotal;
// 假设各模型当天 uncached/cache/output
// 结构与当天总结构一致。
//
// 个人接口没有更细的数据,
// 这是必要的估算假设。
const mu =
uncached * share;
const mc =
cached * share;
const mo =
output * share;
const modelUsd =
(mu / 1e6) *
m.rate.input +
(mc / 1e6) *
m.rate.cached +
(mo / 1e6) *
m.rate.output;
dayUsd += modelUsd;
return {
model: m.model,
speed: m.speed,
allowancePercent:
m.percent,
turns:
turns.get(m.model) || 0,
tokenShare:
share,
uncached:
mu,
cached:
mc,
output:
mo,
totalTokens:
mu + mc + mo,
compositeRate:
m.compositeRate,
usd:
modelUsd,
};
});
// ==========================================================
// 一整份 allowance 的等价美元价值
// ==========================================================
const allowanceUsd =
dayUsd /
(totalPercent / 100);
return {
valid: true,
date:
countRow.date,
totalPercent,
uncached,
cached,
output,
totalTokens:
total,
dayUsd,
allowanceUsd,
models:
allocatedModels,
estimatedAllocation:
allocatedModels.length > 1,
unknown,
};
}
// ============================================================
// 合并 daily 两个接口
// ============================================================
function buildDays(counts, percents) {
const countMap =
new Map(
(counts?.data || []).map(
(x) => [x.date, x]
)
);
const pctMap =
new Map(
(percents?.data || []).map(
(x) => [x.date, x]
)
);
const dates = [
...new Set([
...countMap.keys(),
...pctMap.keys(),
]),
].sort();
return dates.map((date) => {
const count =
countMap.get(date) || null;
const pct =
pctMap.get(date) || null;
const result =
estimateDay(count, pct);
return {
date,
count,
pct,
...result,
};
});
}
// ============================================================
// 当前周期额度聚合
// ============================================================
function estimateCycle(rows) {
const usable =
rows.filter(
(r) =>
r.valid &&
Number.isFinite(r.allowanceUsd) &&
r.allowanceUsd > 0
);
if (!usable.length) {
return null;
}
if (usable.length === 1) {
return {
value:
usable[0].allowanceUsd,
samples: 1,
spread: 0,
rows: usable,
};
}
const initial =
median(
usable.map(
(r) => r.allowanceUsd
)
);
const cluster =
usable.filter(
(r) =>
Math.abs(
r.allowanceUsd -
initial
) /
initial <=
CONFIG.CLUSTER_TOLERANCE
);
if (!cluster.length) {
return null;
}
// 按当天消耗百分比加权。
// 额度消耗越大的日子统计误差通常越小。
const weight =
cluster.reduce(
(sum, r) =>
sum + r.totalPercent,
0
);
const weighted =
cluster.reduce(
(sum, r) =>
sum +
r.allowanceUsd *
r.totalPercent,
0
) / weight;
const values =
cluster.map(
(r) => r.allowanceUsd
);
const spread =
values.length > 1
? (
Math.max(...values) -
Math.min(...values)
) /
weighted
: 0;
return {
value: weighted,
samples:
cluster.length,
spread,
dropped:
usable.length -
cluster.length,
rows:
cluster,
};
}
// ============================================================
// UI
// ============================================================
function addStyle() {
document
.getElementById(
"cqv23-style"
)
?.remove();
const style =
document.createElement("style");
style.id = "cqv23-style";
style.textContent = `
#cqv23 {
position: fixed;
top: 2vh;
left: 50%;
transform: translateX(-50%);
width: min(1180px, calc(100vw - 24px));
max-height: 96vh;
overflow: auto;
z-index: 2147483647;
background: white;
color: #222;
border: 1px solid #ddd;
border-radius: 14px;
padding: 20px;
box-shadow:
0 18px 70px rgba(0,0,0,.28);
font-family:
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
}
#cqv23 * {
box-sizing: border-box;
}
#cqv23 .head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
#cqv23 .title {
font-size: 19px;
font-weight: 700;
}
#cqv23 .close {
font-size: 28px;
cursor: pointer;
color: #888;
}
#cqv23 .cards {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 10px;
margin-bottom: 14px;
}
#cqv23 .card {
border: 1px solid #ececec;
background: #f7f7f7;
border-radius: 9px;
padding: 11px;
}
#cqv23 .card.good {
background: #eefaf5;
border-color: #cfeee1;
}
#cqv23 .card.used {
background: #fff8ea;
border-color: #f4dfad;
}
#cqv23 .label {
color: #777;
font-size: 11px;
margin-bottom: 5px;
}
#cqv23 .value {
font-size: 16px;
font-weight: 750;
color: #0f8f70;
white-space: nowrap;
}
#cqv23 .card.used .value {
color: #a35d00;
}
#cqv23 .sub {
color: #777;
font-size: 10px;
margin-top: 4px;
}
#cqv23 .note {
background: #fafafa;
border: 1px solid #eee;
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 14px;
font-size: 12px;
line-height: 1.6;
color: #555;
}
#cqv23 .tablebox {
max-height: 390px;
overflow: auto;
border: 1px solid #eee;
border-radius: 8px;
margin-bottom: 14px;
}
#cqv23 table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
}
#cqv23 th,
#cqv23 td {
padding: 7px 6px;
border-bottom: 1px solid #eee;
text-align: left;
white-space: nowrap;
}
#cqv23 th {
position: sticky;
top: 0;
z-index: 1;
background: #f5f5f5;
color: #666;
}
#cqv23 .goodtext {
color: #0f8f70;
font-weight: 600;
}
#cqv23 .warn {
color: #a35d00;
}
#cqv23 .muted {
color: #999;
}
#cqv23 details {
margin-top: 5px;
}
#cqv23 summary {
cursor: pointer;
}
@media(max-width: 900px) {
#cqv23 .cards {
grid-template-columns: repeat(3,1fr);
}
}
`;
document.head.appendChild(style);
}
function render({
usage,
rows,
cycleStartDate,
estimate,
}) {
document
.getElementById("cqv23")
?.remove();
addStyle();
const root =
document.createElement("div");
root.id = "cqv23";
const window =
usage?.rate_limit
?.secondary_window ||
usage?.rate_limit
?.primary_window;
const livePercent =
clamp(
n(window?.used_percent),
0,
100
);
const weeklyUsd =
estimate?.value ?? null;
const usedUsd =
weeklyUsd != null
? weeklyUsd *
livePercent /
100
: null;
const leftUsd =
weeklyUsd != null
? weeklyUsd - usedUsd
: null;
const weeklyCredits =
weeklyUsd != null
? weeklyUsd /
CONFIG.USD_PER_CREDIT
: null;
const usedCredits =
usedUsd != null
? usedUsd /
CONFIG.USD_PER_CREDIT
: null;
const currentRows =
rows.filter(
(r) =>
r.date >= cycleStartDate
);
const settledPct =
currentRows.reduce(
(sum, r) =>
sum + n(r.totalPercent),
0
);
const rowsHtml =
[...rows]
.sort(
(a, b) =>
b.date.localeCompare(a.date)
)
.map((r) => {
const current =
r.date >= cycleStartDate;
if (!r.valid) {
return `
<tr>
<td>${current ? "● " : ""}${r.date}</td>
<td>${fmtPct(
(r.pct?.models || [])
.reduce(
(s,m) => s+n(m.credits),
0
)
)}</td>
<td colspan="4" class="muted">
${r.reason || "无可用数据"}
</td>
</tr>
`;
}
const modelText =
r.models
.map(
(m) =>
`${m.model.replace("gpt-","")} ` +
`${fmtPct(m.allowancePercent)}`
)
.join(" / ");
return `
<tr>
<td>
${current ? "● " : ""}
${r.date}
</td>
<td>
${fmtPct(r.totalPercent)}
</td>
<td>
${modelText}
</td>
<td>
${fmtNum(r.totalTokens)}
</td>
<td>
${fmtMoney(r.dayUsd)}
</td>
<td class="goodtext">
${fmtMoney(r.allowanceUsd)}
${
r.estimatedAllocation
? " ≈"
: ""
}
</td>
</tr>
`;
})
.join("");
const modelDetails =
currentRows
.filter((r) => r.valid)
.flatMap((r) =>
r.models.map((m) => ({
date: r.date,
...m,
}))
)
.map(
(m) => `
<tr>
<td>${m.date}</td>
<td>${m.model.replace("gpt-","")}</td>
<td>${m.turns}</td>
<td>${fmtPct(m.allowancePercent)}</td>
<td>${(m.tokenShare * 100).toFixed(2)}%</td>
<td>${fmtNum(m.uncached)}</td>
<td>${fmtNum(m.cached)}</td>
<td>${fmtNum(m.output)}</td>
<td>${fmtNum(m.totalTokens)}</td>
<td>${fmtMoney(m.usd)}</td>
</tr>
`
)
.join("");
root.innerHTML = `
<div class="head">
<div class="title">
📊 Codex Quota Compass V2.3 · 多模型费率反校正
</div>
<div
class="close"
id="cqv23-close"
>
×
</div>
</div>
<div class="cards">
<div class="card">
<div class="label">
当前计划
</div>
<div class="value">
${usage?.plan_type || "unknown"}
</div>
<div class="sub">
仅展示,不参与任何额度计算
</div>
</div>
<div class="card">
<div class="label">
实时周额度已用
</div>
<div class="value">
${livePercent.toFixed(1)}%
</div>
<div class="sub">
每日接口已结算 ${settledPct.toFixed(3)}%
</div>
</div>
<div class="card good">
<div class="label">
当前完整周额度
</div>
<div class="value">
${
weeklyUsd != null
? fmtMoney(weeklyUsd)
: "等待当天 Token"
}
</div>
<div class="sub">
${
estimate
? `${estimate.samples} 个当前周期真实日样本`
: "无套餐倍率 / 无历史兜底"
}
</div>
</div>
<div class="card used">
<div class="label">
本周已用等价价值
</div>
<div class="value">
${
usedUsd != null
? fmtMoney(usedUsd)
: "—"
}
</div>
<div class="sub">
${
usedCredits != null
? `${fmtNum(usedCredits)} Credits eq.`
: "—"
}
</div>
</div>
<div class="card">
<div class="label">
本周剩余等价价值
</div>
<div class="value">
${
leftUsd != null
? fmtMoney(leftUsd)
: "—"
}
</div>
</div>
<div class="card">
<div class="label">
周总等价 Credits
</div>
<div class="value">
${
weeklyCredits != null
? fmtNum(weeklyCredits)
: "—"
}
</div>
<div class="sub">
$0.04 / Credit 仅作换算展示
</div>
</div>
</div>
<div class="note">
<b>算法:</b>
多模型日期不会再被丢弃,也不会简单按调用次数或额度百分比直接分 Token。
脚本先用当天真实 uncached / cached / output 结构计算每个模型的
<b>综合 API 费率</b>,再按照
<code>模型 allowance% ÷ 综合费率</code>
反向估算模型 Token 权重。
<br><br>
每模型的 <b>turns 是接口真实调用轮数</b>,
但不会拿 turns 直接计价,因为不同调用的 Token 长度差异可能极大。
<br>
混模日的 per-model Token 属于
<b>费率校正估算</b>;
当天只有一个模型时,三类 Token 则无需分配,属于直接值。
<br>
<b>长上下文加价已完全忽略。</b>
无论单次请求是否超过 272K,本脚本都只使用基础 API Token rate card。
</div>
<div class="tablebox">
<table>
<thead>
<tr>
<th>日期</th>
<th>Allowance%</th>
<th>模型占比</th>
<th>总 Tokens</th>
<th>当日等价$</th>
<th>反推完整周$</th>
</tr>
</thead>
<tbody>
${rowsHtml}
</tbody>
</table>
</div>
<details>
<summary>
🤖 查看当前周期各模型估算用量
</summary>
<div class="tablebox" style="margin-top:8px">
<table>
<thead>
<tr>
<th>日期</th>
<th>模型</th>
<th>实际 Turns</th>
<th>Allowance%</th>
<th>估算 Token 占比</th>
<th>未缓存输入</th>
<th>缓存输入</th>
<th>输出</th>
<th>总 Token</th>
<th>等价$</th>
</tr>
</thead>
<tbody>
${
modelDetails ||
`<tr>
<td colspan="10" class="muted">
当前周期 Token 尚未结算
</td>
</tr>`
}
</tbody>
</table>
</div>
</details>
<div class="note" style="margin-top:14px;margin-bottom:0">
当前周期开始:
<b>${cycleStartDate}</b>
${
estimate
? `
<br>
当前周期有效样本:
<b>${estimate.samples}</b> 天;
完整周额度:
<b>${fmtMoney(estimate.value)}</b>;
样本离散:
<b>${(estimate.spread * 100).toFixed(2)}%</b>。
`
: `
<br>
当前周期目前没有同时拥有
<b>日 Token + 日 allowance%</b>
的完整样本,因此还无法确定分母。
这不是套餐倍率问题,而是当天 Token 数据尚未落库。
`
}
</div>
`;
document.body.appendChild(root);
document
.getElementById("cqv23-close")
.onclick = () => {
root.remove();
document
.getElementById("cqv23-style")
?.remove();
};
}
// ============================================================
// MAIN
// ============================================================
try {
console.log(
"[Quota Compass V2.3] 获取登录信息..."
);
const token =
getAccessToken();
if (!token) {
throw new Error(
"无法取得 ChatGPT access token"
);
}
const usage =
await apiGet(
"/backend-api/wham/usage",
token
);
const window =
usage?.rate_limit
?.secondary_window ||
usage?.rate_limit
?.primary_window;
if (!window) {
throw new Error(
"没有找到 weekly rate limit window"
);
}
const now = Date.now();
const today =
dayKey(now);
const startDate =
dayKey(
now -
CONFIG.HISTORY_DAYS *
86400000
);
const endDate =
addDays(today, 1);
const range =
`start_date=${encodeURIComponent(startDate)}` +
`&end_date=${encodeURIComponent(endDate)}` +
`&group_by=day`;
const cycleStartMs =
(
n(window.reset_at) -
n(window.limit_window_seconds)
) * 1000;
const cycleStartDate =
dayKey(cycleStartMs);
console.log(
"[Quota Compass V2.3] 当前周期:",
cycleStartDate
);
const [
counts,
percents,
] = await Promise.all([
apiGet(
"/backend-api/wham/analytics/" +
"daily-workspace-usage-counts?" +
range +
"&workspace_user=true",
token
),
apiGet(
"/backend-api/wham/usage/" +
"daily-token-usage-breakdown?" +
range,
token
),
]);
const rows =
buildDays(
counts,
percents
);
const currentRows =
rows.filter(
(r) =>
r.date >= cycleStartDate
);
const estimate =
estimateCycle(
currentRows
);
console.table(
rows
.filter(
(r) =>
r.valid ||
(
r.pct?.models ||
[]
).some(
(m) =>
n(m.credits) > 0
)
)
.map((r) => ({
date:
r.date,
current:
r.date >= cycleStartDate,
allowance_pct:
r.totalPercent || 0,
tokens:
r.totalTokens || 0,
day_usd:
r.dayUsd
? +r.dayUsd.toFixed(3)
: null,
full_week_usd:
r.allowanceUsd
? +r.allowanceUsd.toFixed(2)
: null,
result:
r.valid
? (
r.estimatedAllocation
? "费率校正"
: "单模型直接值"
)
: r.reason,
}))
);
if (estimate) {
const live =
clamp(
n(window.used_percent),
0,
100
);
console.log(
"====== 当前周实测 ======"
);
console.log(
"周额度 API 等价价值:",
estimate.value
);
console.log(
"实时已用:",
live + "%"
);
console.log(
"本周已用等价价值:",
estimate.value *
live /
100
);
console.log(
"本周剩余等价价值:",
estimate.value *
(
1 -
live /
100
)
);
}
render({
usage,
rows,
cycleStartDate,
estimate,
});
console.log(
"[Quota Compass V2.3] ✅ 完成"
);
} catch (e) {
console.error(
"[Quota Compass V2.3]",
e
);
alert(
"Quota Compass V2.3 失败:\n\n" +
(
e?.message ||
String(e)
)
);
}
})();
降低人工生成率
- 不是…而是…
- 赖以生存的
mdbook-embedify 操作手册
mdbook-embedify 用来把第三方应用或本地内容嵌入 mdBook 页面。当前 0.3.x 与 mdBook 0.5.x 配套使用。
原仓库:https://github.com/MR-Addict/mdbook-embedify
通用语法
所有 Embedify 应用都遵循:
{% embed app option="value" another-option="value" %}
参数使用空格分隔,值需要放在引号中。
YouTube
样例
<!-- {% embed youtube id="DyTCOwB0DVw" loading="lazy" %} -->
<div data-embedify data-app="youtube" data-option-id="DyTCOwB0DVw" data-option-loading="lazy" style="display:none"></div>
<iframe allowfullscreen name="youtube" loading="lazy" src="https://www.youtube.com/embed/DyTCOwB0DVw" style="width: 100%; height: 100%; border: none; aspect-ratio: 16/9; border-radius: 1rem; background: black" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"></iframe>
常用参数:
| 参数 | 含义 |
|---|---|
id | YouTube 视频 ID |
loading | lazy 或 eager |
Bilibili
样例
<!-- {% embed bilibili id="BV1uT4y1P7CX" loading="lazy" %} -->
<div data-embedify data-app="bilibili" data-option-id="BV1uT4y1P7CX" data-option-loading="lazy" style="display:none"></div>
<iframe allowfullscreen name="bilibili" loading="lazy" src="https://player.bilibili.com/player.html?bvid=BV1uT4y1P7CX&danmaku=0" style="width: 100%; height: 100%; border: none; aspect-ratio: 16/9; border-radius: 1rem; background: black"></iframe>
id 使用 BV 号。
Vimeo
<!-- {% embed vimeo id="914391191" loading="lazy" %} -->
<div data-embedify data-app="vimeo" data-option-id="914391191" data-option-loading="lazy" style="display:none"></div>
<iframe name="vimeo" loading="lazy" allow="autoplay; fullscreen; picture-in-picture" src="https://player.vimeo.com/video/914391191" style="width: 100%; height: 100%; border: none; aspect-ratio: 16/9; border-radius: 1rem; background: black"></iframe>
GitHub Gist
<!-- {% embed gist id="76cf171d1bdd7da41d4ca96b908eb57a" %} -->
<div data-embedify data-app="gist" data-option-id="76cf171d1bdd7da41d4ca96b908eb57a" style="display:none"></div>
<script src="https://gist.github.com/76cf171d1bdd7da41d4ca96b908eb57a.js"></script>
适合把较长代码片段维护在 Gist 中,再嵌入文档。
CodePen
<!-- {% embed codepen user="MR-Addict" slug="NWBOqKw" height="600" theme="dark" loading="lazy" %} -->
<div data-embedify data-app="codepen" data-option-user="MR-Addict" data-option-slug="NWBOqKw" data-option-height="600" data-option-theme="dark" data-option-loading="lazy" style="display:none"></div>
<iframe allowfullscreen name="codepen" loading="lazy" height="600" style="width: 100%; border: none; border-radius: 1rem; background: black" src="https://codepen.io/MR-Addict/embed/NWBOqKw?default-tab=result&theme-id=dark"></iframe>
常用参数:
user
slug
height
theme
loading
StackBlitz
<!-- {% embed stackblitz id="vitejs-vite-y8mdxg" theme="light" loading="lazy" %} -->
<div data-embedify data-app="stackblitz" data-option-id="vitejs-vite-y8mdxg" data-option-theme="light" data-option-loading="lazy" style="display:none"></div>
<iframe name="stackblitz" loading="lazy" src="https://stackblitz.com/edit/vitejs-vite-y8mdxg?embed=1&theme=light&view=preview" style="width: 100%; height: 100%; border: none; aspect-ratio: 16/9; border-radius: 1rem; background: black"></iframe>
CodeSandbox
<!-- {% embed codesandbox id="ke8wx" theme="light" loading="lazy" %} -->
<div data-embedify data-app="codesandbox" data-option-id="ke8wx" data-option-theme="light" data-option-loading="lazy" style="display:none"></div>
<iframe name="sandbox" loading="lazy" src="https://codesandbox.io/embed/ke8wx?&theme=light" style="width: 100%; height: 100%; border: none; aspect-ratio: 16/9; border-radius: 1rem; background: black"></iframe>
Giscus 评论
Giscus 依赖 GitHub Discussions。
单页嵌入
<!-- {% embed giscus repo="owner/repo" repo-id="R_XXXXXXXXXX" category="General" category-id="DIC_XXXXXXXXXXXXXXXX" theme="book" loading="eager" %} -->
<div data-embedify data-app="giscus" data-option-repo="owner/repo" data-option-repo-id="R_XXXXXXXXXX" data-option-category="General" data-option-category-id="DIC_XXXXXXXXXXXXXXXX" data-option-theme="book" data-option-loading="eager" style="display:none"></div>
<style>.giscus { margin-top: 6rem; }</style><script src="https://giscus.app/client.js" data-repo="owner/repo" data-repo-id="R_XXXXXXXXXX" data-category="General" data-category-id="DIC_XXXXXXXXXXXXXXXX" data-mapping="title" data-strict="0" data-reactions-enabled="1" data-emit-metadata="0" data-input-position="bottom" data-lang="en" data-loading="eager" crossorigin="anonymous" data-theme="book" async></script><script>(() => { const giscusScript = document.querySelector("script[data-repo][data-repo-id]"); if (giscusScript?.getAttribute("data-theme") !== "book") return; const mapTheme = (theme) => (["light", "rust"].includes(theme) ? "light" : "dark"); const bookTheme = localStorage.getItem("mdbook-theme") || html.getAttribute("class"); giscusScript.setAttribute("data-theme", mapTheme(bookTheme)); document.querySelectorAll("button[role='menuitem'].theme").forEach((btn) => { btn.addEventListener("click", (event) => { const theme = mapTheme(event.target.id); const iframe = document.querySelector("iframe.giscus-frame"); if (iframe) iframe.contentWindow.postMessage({ giscus: { setConfig: { theme } } }, "*"); }); }); })();</script>
常用参数:
| 参数 | 必需 | 说明 |
|---|---|---|
repo | 是 | GitHub 仓库 |
repo-id | 是 | 仓库 ID |
category | 是 | Discussion 分类 |
category-id | 是 | 分类 ID |
reactions-enabled | 否 | 是否启用 reactions |
theme | 否 | book、light、dark |
lang | 否 | 语言 |
loading | 否 | lazy / eager |
全书启用 Giscus
[preprocessor.embedify]
giscus.enable = true
giscus.repo = "owner/repo"
giscus.repo-id = "R_XXXXXXXXXX"
giscus.category = "General"
giscus.category-id = "DIC_XXXXXXXXXXXXXXXX"
giscus.reactions-enabled = "1"
giscus.theme = "book"
giscus.lang = "zh-CN"
giscus.loading = "lazy"
如果直接用 file:// 打开生成的 HTML,Giscus 可能拒绝连接;应通过 mdbook serve 或其他 HTTP server 预览。
Include:把本地文件显示为代码块
include 会读取文件,并把内容包成 Markdown code block,而不是把文件当作正文继续解析。
文件路径相对于 mdBook 书籍根目录。
完整文件
<!-- {% embed include file="src/SUMMARY.md" %} -->
<div data-embedify data-app="include" data-option-file="src/SUMMARY.md" style="display:none"></div>
```markdown
# Summary
[Homepage](Homepage.md)
[Frontmatter Render Test](Frontmatter-Render-Test.md)
# Paper
- [ANNS](Paper/ANNS/ANNS.md)
- [Quake](Paper/ANNS/Quake.md)
- [FlowANN](Paper/ANNS/FlowANN.md)
- [HELMESMAN](Paper/ANNS/HELMESMAN.md)
- [GustANN](Paper/ANNS/GustANN.md)
- [LLM](Paper/LLM/LLM.md)
# Note
- [Quant](Note/Quant/Quant.md)
- [股票基金与量化投资_零基础核心名词手册](Note/Quant/股票基金与量化投资_零基础核心名词手册.md)
- [Slay the spine 2](Note/Slay%20the%20spine%202/Slay%20the%20spine%202.md)
- [毒雾与触媒的数学建模分析](Note/Slay%20the%20spine%202/毒雾与触媒的数学建模分析.md)
- [Orion](Note/Orion/Orion.md)
- [系统设计](Note/Orion/系统设计.md)
# Project
- [1zone](Project/1zone/1zone.md)
- [Betterfly](Project/Betterfly/Betterfly.md)
- [Golden Compass](Project/Golden%20Compass/Golden%20Compass.md)
- [mdbook frontmatter render](Project/mdbook%20frontmatter%20render/mdbook%20frontmatter%20render.md)
- [Pixrompt](Project/Pixrompt/Pixrompt.md)
- [Qoffee](Project/Qoffee/Qoffee.md)
- [Sharkdown](Project/Sharkdown/Sharkdown.md)
- [sts2stats](Project/sts2stats/sts2stats.md)
- [Surge lens](Project/Surge%20lens/Surge%20lens.md)
- [traintrace](Project/traintrace/traintrace.md)
- [typst](Project/typst/typst.md)
# Musing
- [常用代码](Musing/常用代码/常用代码.md)
- [查看 codex 额度](Musing/常用代码/查看%20codex%20额度.md)
- [降低人工生成率](Musing/降低人工生成率.md)
# Example
- [embedify](Example/embedify.md)
- [frontmatter-render](Example/frontmatter-render.md)
- [gitinfo](Example/gitinfo.md)
- [inline-highlighting](Example/inline-highlighting.md)
- [katex](Example/katex.md)
- [mermaid](Example/mermaid.md)
- [obsidian-format](Example/obsidian-format.md)
- [pikchr](Example/pikchr.md)
- [qr](Example/qr.md)
- [reading-time](Example/reading-time.md)
- [toc](Example/toc.md)
### 指定代码语言
```text
<!-- {% embed include file="src/SUMMARY.md" lang="markdown" %} -->
<div data-embedify data-app="include" data-option-file="src/SUMMARY.md" data-option-lang="markdown" style="display:none"></div>
```markdown
# Summary
[Homepage](Homepage.md)
[Frontmatter Render Test](Frontmatter-Render-Test.md)
# Paper
- [ANNS](Paper/ANNS/ANNS.md)
- [Quake](Paper/ANNS/Quake.md)
- [FlowANN](Paper/ANNS/FlowANN.md)
- [HELMESMAN](Paper/ANNS/HELMESMAN.md)
- [GustANN](Paper/ANNS/GustANN.md)
- [LLM](Paper/LLM/LLM.md)
# Note
- [Quant](Note/Quant/Quant.md)
- [股票基金与量化投资_零基础核心名词手册](Note/Quant/股票基金与量化投资_零基础核心名词手册.md)
- [Slay the spine 2](Note/Slay%20the%20spine%202/Slay%20the%20spine%202.md)
- [毒雾与触媒的数学建模分析](Note/Slay%20the%20spine%202/毒雾与触媒的数学建模分析.md)
- [Orion](Note/Orion/Orion.md)
- [系统设计](Note/Orion/系统设计.md)
# Project
- [1zone](Project/1zone/1zone.md)
- [Betterfly](Project/Betterfly/Betterfly.md)
- [Golden Compass](Project/Golden%20Compass/Golden%20Compass.md)
- [mdbook frontmatter render](Project/mdbook%20frontmatter%20render/mdbook%20frontmatter%20render.md)
- [Pixrompt](Project/Pixrompt/Pixrompt.md)
- [Qoffee](Project/Qoffee/Qoffee.md)
- [Sharkdown](Project/Sharkdown/Sharkdown.md)
- [sts2stats](Project/sts2stats/sts2stats.md)
- [Surge lens](Project/Surge%20lens/Surge%20lens.md)
- [traintrace](Project/traintrace/traintrace.md)
- [typst](Project/typst/typst.md)
# Musing
- [常用代码](Musing/常用代码/常用代码.md)
- [查看 codex 额度](Musing/常用代码/查看%20codex%20额度.md)
- [降低人工生成率](Musing/降低人工生成率.md)
# Example
- [embedify](Example/embedify.md)
- [frontmatter-render](Example/frontmatter-render.md)
- [gitinfo](Example/gitinfo.md)
- [inline-highlighting](Example/inline-highlighting.md)
- [katex](Example/katex.md)
- [mermaid](Example/mermaid.md)
- [obsidian-format](Example/obsidian-format.md)
- [pikchr](Example/pikchr.md)
- [qr](Example/qr.md)
- [reading-time](Example/reading-time.md)
- [toc](Example/toc.md)
### 指定行范围
```text
<!-- {% embed include file="src/SUMMARY.md" range="1-20" %} -->
<div data-embedify data-app="include" data-option-file="src/SUMMARY.md" data-option-range="1-20" style="display:none"></div>
```markdown
# Summary
[Homepage](Homepage.md)
[Frontmatter Render Test](Frontmatter-Render-Test.md)
# Paper
- [ANNS](Paper/ANNS/ANNS.md)
- [Quake](Paper/ANNS/Quake.md)
- [FlowANN](Paper/ANNS/FlowANN.md)
- [HELMESMAN](Paper/ANNS/HELMESMAN.md)
- [GustANN](Paper/ANNS/GustANN.md)
- [LLM](Paper/LLM/LLM.md)
# Note
- [Quant](Note/Quant/Quant.md)
- [股票基金与量化投资_零基础核心名词手册](Note/Quant/股票基金与量化投资_零基础核心名词手册.md)
- [Slay the spine 2](Note/Slay%20the%20spine%202/Slay%20the%20spine%202.md)
- [毒雾与触媒的数学建模分析](Note/Slay%20the%20spine%202/毒雾与触媒的数学建模分析.md)
支持的 range 形式:
```text
1-10
1-
-10
同时指定语言和范围
<!-- {% embed include file="book.toml" lang="toml" range="1-30" %} -->
<div data-embedify data-app="include" data-option-file="book.toml" data-option-lang="toml" data-option-range="1-30" style="display:none"></div>
```toml
[book]
title = "Pages"
authors = ["quaternijkon"]
description = "Digital garden of quaternijkon"
language = "zh-CN"
# [preprocessor.obsidian-format]
# after = ["index", "frontmatter-render"]
# warn-on-missing = true
# transform-embeds = true
[preprocessor.frontmatter-render]
# Source-linked development mode: plugin updates take effect on the next build.
command = "cargo run --quiet --manifest-path /Users/ruoyangdong/project/rust/mdbook-frontmatter-render/Cargo.toml --"
# 基础外观:普通用户通常只需要调整下面几项。
layout = "card" # card | badges | table
placement = "after-title" # after-title | before-title
style = "embedded" # embedded 使用内置样式;none 使用自定义 CSS
collapsible = true # 是否允许折叠
default-open = true # 初始是否展开
# https://github.com/MR-Addict/mdbook-embedify
[preprocessor.embedify]
# https://github.com/CompEng0001/mdbook-gitinfo
[preprocessor.gitinfo]
enable = true
# choose placement(s)
如果不写 `lang`,插件会尝试根据文件扩展名自动识别语言。
## Footer
### 单页 footer
```text
<!-- {% embed footer message="Copyright © 2026 Quaternijkon" %} -->
<div data-embedify data-app="footer" data-option-message="Copyright © 2026 Quaternijkon" style="display:none"></div>
<style>footer { text-align: center; text-wrap: balance; margin-top: 5rem; display: flex; flex-direction: column; justify-content: center; align-items: center; } footer p { margin: 0; }</style><footer><p>Copyright © 2026 Quaternijkon</p></footer>
message 支持 Markdown。
Scroll to Top
单页启用
<!-- {% embed scroll-to-top %} -->
<div data-embedify data-app="scroll-to-top" style="display:none"></div>
<style>.scroll-to-top { font-size: 2.5rem; width: 3.2rem; height: 3.2rem; display: none; align-items: center; justify-content: center; position: fixed; padding: 0.75rem; bottom: 4rem; right: calc(1.25rem + 90px + var(--page-padding)); z-index: 999; cursor: pointer; border: none; color: var(--bg); background: var(--fg); border-radius: 50%; } .scroll-to-top.hidden { display: none; } .scroll-to-top i { transform: translateY(-2px); } @media (min-width: 1080px) { .scroll-to-top { display: flex; } }</style><button type="button" aria-label="scroll-to-top" class="scroll-to-top hidden" onclick="scrollToTop()"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"> <path d="m18 15-6-6-6 6"></path> </svg></button><script>const scrollToTop = () => window.scroll({ top: 0, behavior: "smooth" }); window.addEventListener("scroll", () => { const button = document.querySelector(".scroll-to-top"); button.classList.toggle("hidden", window.scrollY <200); });</script>
全书启用
[preprocessor.embedify]
scroll-to-top.enable = true
全局启用后,不要再在每个章节手动嵌入,否则会重复出现。
Announcement Banner
单页公告
<!-- {% embed announcement-banner id="2026-09" message="*文档站已更新*" %} -->
<div data-embedify data-app="announcement-banner" data-option-id="2026-09" data-option-message="*文档站已更新*" style="display:none"></div>
<style>.announcement-banner { --site-announcement-bar-stripe-color1: #e5e7eb; --site-announcement-bar-stripe-color2: #d1d5db; z-index: 150; position: relative; flex-direction: column; justify-content: center; align-items: center; margin: 0; padding: 1rem 3.5rem; background: repeating-linear-gradient( 45deg, var(--site-announcement-bar-stripe-color1), var(--site-announcement-bar-stripe-color1) 20px, var(--site-announcement-bar-stripe-color2) 10px, var(--site-announcement-bar-stripe-color2) 40px ); } html:is(.navy, .coal, .ayu) .announcement-banner { --site-announcement-bar-stripe-color1: #1f2937; --site-announcement-bar-stripe-color2: #111827; } .announcement-banner p { color: var(--fg); width: 100%; margin: 0; padding: 0; overflow: hidden; text-align: center; white-space: nowrap; text-overflow: ellipsis; text-wrap: balance; } .announcement-banner button[data-close] { top: 50%; right: 1rem; position: absolute; transform: translateY(-50%); width: 3rem; height: 3rem; cursor: pointer !important; border: none; font-weight: 900; border-radius: 50%; background-color: transparent; }</style><div style="display: none" data-id="2026-09" class="announcement-banner"> <p><em>文档站已更新</em></p> <button type="button" data-close>X</button></div><script>(() => { const banner = document.querySelector(".announcement-banner"); const id = banner.getAttribute("data-id"); const message = banner.querySelector("p").textContent; const localData = JSON.parse(localStorage.getItem("mdbook-announcement-banner")); if (!localData || localData.id !== id || localData.hide !== true) { banner.style.display = "flex"; const page = document.querySelector(".page"); page.parentNode.insertBefore(banner, page); banner.querySelector("button").addEventListener("click", () => { banner.remove(); localStorage.setItem("mdbook-announcement-banner", JSON.stringify({ id, hide: true, message })); }); } })();</script>
message 支持 Markdown。
公告内容发生明显变化时建议更新 id。
全局嵌入
部分应用支持在 book.toml 中直接全局启用,例如:
[preprocessor.embedify]
scroll-to-top.enable = true
此类全局应用会自动加入每一章,不需要再手写对应 embed macro。
在操作手册中展示 Embedify 语法,而不执行它
这是编写本页这类手册时最重要的功能。
把 embed 改成 embed-ignore:
{% embed youtube id="DyTCOwB0DVw" loading="lazy" %}
插件会保留并显示相应 embed 语法,而不会真正渲染视频。
批量忽略一段区域
<!-- embed ignore begin -->
这里的 Embedify 宏不会执行。
<!-- embed ignore end -->
适合手册中一次展示很多 embed 示例。
当前书库配置
[preprocessor.embedify]
因此所有局部 embed 功能均可以直接在章节 Markdown 中使用。
使用选择
- 在线视频:YouTube / Bilibili / Vimeo;
- 在线代码演示:CodePen / StackBlitz / CodeSandbox;
- 代码片段:Gist;
- 评论区:Giscus;
- 展示仓库中的真实源码:Include;
- 页面附加 UI:Footer、Scroll to Top、Announcement Banner。
mdbook-frontmatter-render 操作手册
mdbook-frontmatter-render 用于把 Markdown 文件开头的 YAML Frontmatter 渲染成结构化元数据组件。
它会保留原始可见文本,同时根据字段内容识别日期、布尔值、进度、颜色、邮箱、URL、GitHub/Hugging Face 标识、DOI、ORCID、标签、状态、评分和 WikiLink 等类型。
本地源码:/Users/ruoyangdong/project/rust/mdbook-frontmatter-render
最基础的写法
Frontmatter 必须位于 Markdown 文件最开头:
---
title: Example
created: 2026-07-11
tags: [Rust, mdBook]
---
# Example
插件会在构建时把 YAML 转换成元数据卡片。
一个较完整的样例
---
title: FlowANN
created: 2026-08-31
updated: 2026-09-04
draft: false
aigc: false
progress: 80%
rating: 4.5
color: "#7c3aed"
email: docs@example.com
github: OpenAI/codex
doi: 10.1145/1234567.8901234
tags: [ANNS, GPU, VectorSearch]
related: "[[HELMESMAN]]"
---
# FlowANN
不同字段会根据语义使用不同的显示形式。
布尔值
draft: false
aigc: true
插件默认把 draft、aigc、ai_generated 等字段按 Boolean 语义处理。
接受:
true / false
yes / no
on / off
1 / 0
推荐直接使用 YAML 原生 Boolean。
日期与时间
created: 2026-09-04
updated: 2026/09/04
reviewed: 2026-09-04T18:30:00+08:00
歧义日期的顺序由:
date-order = "auto"
控制,可选:
auto
ymd
dmy
mdy
时区处理:
timezone = "preserve"
可选 preserve、utc、local。使用 local 时还要指定:
local-timezone = "Asia/Shanghai"
百分比与进度
progress: 75%
如果字段被识别为 percentage-progress,会显示进度语义。
显式指定:
[preprocessor.frontmatter-render.fields.progress]
type = "percentage-progress"
颜色
color: "#7c3aed"
有效 CSS 颜色会显示 color swatch,同时保留原字符串。
邮箱与 URL
email: docs@example.com
homepage: https://pages.quaternijkon.online
邮箱会生成 mailto: 链接;安全 HTTP/HTTPS URL 会变为可点击链接。
GitHub / GitLab / Hugging Face
github: OpenAI/codex
gitlab: group/project
model: Qwen/Qwen3-Embedding-4B
例如把 model 明确设为 Hugging Face model:
[preprocessor.frontmatter-render.fields.model]
type = "hf-model"
还支持 huggingface、hf-dataset、hf-space。
DOI / ORCID
doi: 10.1145/1234567.8901234
orcid: 0000-0002-1825-0097
识别后会链接到各自的规范地址。
Status / Rating
status: reading
rating: 4.5
可以显式指定:
[preprocessor.frontmatter-render.fields.status]
type = "status"
[preprocessor.frontmatter-render.fields.rating]
type = "rating"
Rating 按 /5 语义处理。
Tags
单行:
tags: [ANNS, GPU, VectorSearch]
多行:
tags:
- ANNS
- GPU
- VectorSearch
插件会保留用户写下的 # 前缀。
Aliases
aliases:
- Flow ANN
- 流式向量检索
Aliases 也可以参与 WikiLink 目标解析。
Frontmatter 中的 WikiLink
普通链接
related: "[[HELMESMAN]]"
指定显示文字
related: "[[HELMESMAN|相关论文]]"
指向标题
related: "[[FlowANN#实验|FlowANN 实验]]"
当前页面标题
related: "[[#系统设计]]"
相对路径
related: "[[../Paper/ANNS/FlowANN]]"
从书籍根目录解析
related: "[[/Homepage]]"
插件只解析到当前 mdBook 已存在的章节,不会越过 source root。
WikiLink 模式
生成链接:
wikilinks = "resolve"
只显示文字:
wikilinks = "display"
完全保留原文:
wikilinks = "off"
未解析 WikiLink
unresolved-wikilinks = "warn"
| 值 | 行为 |
|---|---|
ignore | 保留文字,不输出诊断 |
warn | 输出警告,继续构建 |
error | 中止构建 |
三种布局
Card:
layout = "card"
Badges:
layout = "badges"
Table:
layout = "table"
当前书库使用 card。
放置位置
标题后:
placement = "after-title"
页面最前:
placement = "before-title"
折叠
collapsible = true
default-open = true
collapsible = true:允许折叠;default-open = true:默认展开;default-open = false:默认收起;collapsible = false:完全不可折叠。
隐藏字段和空值
hidden = ["cssclasses", "private", "internal_notes"]
show-null = false
show-empty = false
false 和数字 0 不会被当成空值。
标题去重
deduplicate-title = true
当 Frontmatter title 与页面可见 H1 完全一致时,不重复显示 title。
单字段自定义
[preprocessor.frontmatter-render.fields.aigc]
label = "AIGC"
type = "boolean"
icon = "sparkles"
order = -10
hidden = false
full-width = false
| 配置 | 作用 |
|---|---|
label | 修改显示名 |
type | 强制字段类型 |
icon | 指定图标 |
order | 越小越靠前 |
hidden | 隐藏/显示 |
full-width | Card 模式是否跨整行 |
可用类型
auto, text, boolean, integer, float,
date, time, datetime, duration,
email, url, wikilink,
percentage, percentage-progress, color, version,
github, gitlab, huggingface,
hf-model, hf-dataset, hf-space,
doi, orcid, license, language, status, rating,
tags, aliases
可用图标
calendar, clock, mail, link, git-branch, repository, box,
sparkles, percent, palette, tag, hash, toggle, none
批量规则
[[preprocessor.frontmatter-render.rules]]
keys = ["release.*", "metadata.version"]
type = "version"
[[preprocessor.frontmatter-render.rules]]
keys = ["contact.email", "authors.*.email"]
type = "email"
规则按书写顺序匹配,第一个命中生效。* 只匹配一个 dotted path 段。
嵌套对象
authors:
- name: Alice
email: alice@example.com
- name: Bob
email: bob@example.com
最大递归深度:
max-depth = 4
过深结构会安全退化为 YAML code block。
类型推断强度
inference = "aggressive"
可选:
| 值 | 行为 |
|---|---|
aggressive | 积极根据 key、YAML 类型和值格式推断 |
conservative | 只识别高置信度格式 |
keys-only | 主要依赖 key 和显式配置 |
显式 fields.<key>.type 优先级最高。
严格模式
本地写作:
strict = false
CI / 正式发布:
strict = true
unresolved-wikilinks = "error"
当前书库配置
[preprocessor.frontmatter-render]
command = "cargo run --quiet --manifest-path /Users/ruoyangdong/project/rust/mdbook-frontmatter-render/Cargo.toml --"
layout = "card"
placement = "after-title"
style = "embedded"
collapsible = true
default-open = true
与 obsidian-format 的职责划分
当前顺序:
frontmatter-render → obsidian-format → renderer
- Frontmatter WikiLink:
frontmatter-render; - 正文 WikiLink / Embed:
obsidian-format。
mdbook-gitinfo 操作手册
mdbook-gitinfo 会把章节对应的 Git 信息注入页面 header、footer 或两者。
原仓库:https://github.com/CompEng0001/mdbook-gitinfo
当前书库只启用了 header,可以在页面顶部查看最后更新时间;底部不再显示构建时间和 commit 信息。
当前显示格式
[preprocessor.gitinfo]
enable = true
header = true
footer = false
align.header = "center"
margin.header.top = "2em"
margin.header.bottom = "2em"
font-size = "0.8em"
separator = " • "
date-format = "%A %d %B %Y"
time-format = "@ %H:%M:%S"
branch = "main"
hyperlink = true
timezone = "source"
[preprocessor.gitinfo.message]
header = "Last updated: <em>{{date}}</em>"
页面顶部会居中显示类似内容,日期部分使用斜体:
Last updated: Thursday 15 January 2026 @ 23:45:51
模板占位符
| 占位符 | 含义 |
|---|---|
{{hash}} | 短 commit hash |
{{long}} | 完整 commit hash |
{{tag}} | 最近 tag |
{{date}} | 按配置格式化后的提交时间 |
{{sep}} | separator |
{{branch}} | 分支名 |
样例:日期 + 分支 + Commit
[preprocessor.gitinfo.message]
footer = "Updated {{date}}{{sep}}{{branch}}{{sep}}{{hash}}"
效果类似:
Updated 2026-09-04 • main • a1b2c3d
样例:完整 Commit
[preprocessor.gitinfo.message]
footer = "Commit {{long}}"
Header / Footer
只显示顶部:
header = true
footer = false
只显示底部:
header = false
footer = true
上下都显示:
header = true
footer = true
Header 和 Footer 分别使用不同模板
[preprocessor.gitinfo.message]
header = "Last updated: {{date}}"
footer = "{{branch}}{{sep}}{{hash}}"
Header 和 Footer 共用模板
[preprocessor.gitinfo.message]
both = "{{date}}{{sep}}{{branch}}{{sep}}{{hash}}"
如果某个位置同时定义了专用模板和 both,专用模板优先。
对齐方式
支持:
left
center
right
分别设置:
[preprocessor.gitinfo.align]
header = "left"
footer = "right"
统一设置:
[preprocessor.gitinfo.align]
both = "center"
默认是居中。
Margin
Margin 使用 CSS 风格的 Top / Right / Bottom / Left 顺序。
单值:四边相同
[preprocessor.gitinfo]
margin.both = "1em"
四值数组
[preprocessor.gitinfo]
margin.header = ["0", "0", "1.25em", "0"]
margin.footer = ["2em", "0", "0", "0"]
顺序:
Top Right Bottom Left
单独指定某一边
[preprocessor.gitinfo]
margin.header.top = "1em"
margin.footer.bottom = "2em"
字体大小
当前:
font-size = "0.8em"
希望文字更大:
font-size = "0.9em"
分隔符
当前:
separator = " • "
模板中通过 {{sep}} 使用。
也可以改成:
separator = " | "
日期与时间格式
date-format = "%Y-%m-%d"
time-format = "%H:%M:%S"
常用格式符:
| 格式 | 含义 |
|---|---|
%Y | 四位年份 |
%m | 月 |
%d | 日 |
%H | 24 小时制小时 |
%M | 分钟 |
%S | 秒 |
%z | 时区 offset |
时区
当前:
timezone = "source"
可选:
local
utc
source
fixed:+08:00
fixed:-05:00
例如固定东八区:
timezone = "fixed:+08:00"
若希望时间字符串显示 offset,需要在 time-format 中使用 %z、%:z 或 %Z。
Commit / Branch 超链接
hyperlink = true
插件会尝试从 CI 环境变量或 remote.origin.url 推导远程仓库,把 hash / branch 做成链接。
关闭:
hyperlink = false
Branch
branch = "main"
若指定 branch 找不到,插件会回退到 main 并给出 warning。
GitHub Actions
如果通过 GitHub Actions 构建,希望插件取得完整 Git 历史,应使用:
- uses: actions/checkout@v4
with:
fetch-depth: 0
否则 shallow clone 可能使 tag、历史 commit 等信息不完整。
使用方式总结
Gitinfo 与 Mermaid、KaTeX 不同:它通常不需要在 Markdown 正文中写特殊语法。日常使用主要是调整 book.toml 模板、位置、对齐、时间格式和链接行为。
mdbook-inline-highlighting 操作手册
mdbook-inline-highlighting 用于给 Markdown 行内代码增加与 fenced code block 相同的 highlight.js 语法高亮。
原仓库:https://github.com/phoenixr-codes/mdbook-inline-highlighting
快速使用
当前书库配置了:
[preprocessor.inline-highlighting]
default-language = "js"
因此最常用的是下面三种写法。
样例:显式指定语言
Python:[py] lambda x: x % 2 == 0
对应源码:
Python:`[py] lambda x: x % 2 == 0`
[py] 会覆盖当前默认的 JavaScript 高亮。
样例:使用默认语言
JavaScript:(x) => x % 2 == 0
对应源码:
JavaScript:`(x) => x % 2 == 0`
因为当前 default-language = "js",没有语言前缀的行内代码会按 JavaScript 高亮。
样例:明确禁用高亮
普通代码:[none] accumulate
对应源码:
普通代码:`[none] accumulate`
[none] 会覆盖默认语言,让这一段保持普通行内代码样式。
语言前缀语法
格式必须是:
[LANGUAGE] TEXT
例如:
`[rust] Vec<String>`
`[py] print("hello")`
`[js] const x = 1`
`[json] {"enabled": true}`
`[bash] mdbook build`
右方括号后必须有一个空格。
正确:
[py] lambda x: x + 1
错误:
[py]lambda x: x + 1
后者会触发 missing space after language identifier。
语言名称由 mdBook 当前使用的 highlight.js 支持范围决定;本插件与代码块使用同一套高亮器。
显示以 [ 开头的普通行内代码
如果行内代码本身以 [xxx] 开头,但这不是语言声明,需要在最前面加反斜杠转义。
例如,要显示 TOML 表名:
`\[preprocessor.katex]`
插件会去掉用于转义的反斜杠,并把 [preprocessor.katex] 当作普通文本,而不是语言标识。
行为速查
| 写法 | 行为 |
|---|---|
`[py] x = 1` | Python 高亮 |
`[rust] let x = 1;` | Rust 高亮 |
`[none] foo` | 不高亮 |
`const x = 1` | 使用默认 JavaScript |
`\[table.name]` | 显示以 [ 开头的普通内容 |
显式 [LANGUAGE] 始终优先于 default-language。
什么时候使用
适合函数、类型、短表达式、API 调用和一两行以内的代码。多行代码、完整脚本和配置文件仍应使用 fenced code block:
```rust
fn main() {
println!("hello");
}
```
当前书库的推荐写法
- 需要准确着色时显式写
[rust]、[py]、[bash]等语言; - JavaScript 可直接使用默认语言;
- 不希望着色时写
[none]; - 内容本身以
[xxx]开头时使用反斜杠转义。
mdbook-katex 操作手册
mdbook-katex 用于在 mdBook 中渲染 LaTeX 数学公式,适合论文笔记、推导、算法复杂度和技术文档。
原仓库:https://github.com/lzanini/mdbook-katex
行内公式
样例
向量范数写作 ,余弦相似度可以写作 。
对应源码:
向量范数写作 $\|x\|_2$,余弦相似度可以写作 $\frac{x\cdot y}{\|x\|\|y\|}$。
行内公式使用单个 $...$。
块级公式
样例
对应源码:
$$
P(X=k)=\binom{n}{k}p^k(1-p)^{n-k}
$$
块级公式使用 $$...$$。
多行公式
样例
对应源码:
$$
\begin{aligned}
f(x) &= x^2+2x+1 \\
&= (x+1)^2
\end{aligned}
$$
矩阵
样例
对应源码:
$$
A=
\begin{bmatrix}
1 & 2 \\
3 & 4
\end{bmatrix}
$$
分段函数
样例
对应源码:
$$
f(x)=
\begin{cases}
x^2, & x \ge 0 \\
-x, & x < 0
\end{cases}
$$
常用公式写法速查
| 效果 | 源码 |
|---|---|
| 上标 | $x^2$ |
| 下标 | $x_i$ |
| 分数 | $\frac{a}{b}$ |
| 根号 | $\sqrt{x}$ |
| 求和 | $\sum_{i=1}^{n}x_i$ |
| 积分 | $\int_0^1 f(x)\,dx$ |
| 极限 | $\lim_{n\to\infty}a_n$ |
| 向量 | $\mathbf{x}$ |
| 实数空间 | $\mathbb{R}^d$ |
| 属于 | $x\in S$ |
| 近似 | $a\approx b$ |
| 小于等于 | $a\le b$ |
| 大于等于 | $a\ge b$ |
希腊字母
$\alpha, \beta, \gamma, \delta, \epsilon$
$\theta, \lambda, \mu, \sigma, \phi, \omega$
$\Gamma, \Delta, \Theta, \Lambda, \Sigma, \Omega$
对齐长推导
aligned 很适合论文笔记中的连续推导:
$$
\begin{aligned}
T(n)
&= T(n/2)+O(n) \\
&= O(n)+O(n/2)+O(n/4)+\cdots \\
&= O(n)
\end{aligned}
$$
正常显示美元符号
因为 $ 是公式分隔符,如果正文要显示普通美元符号,需要转义:
价格是 \$100。
自定义宏
如果某些表达式频繁出现,可以定义宏文件。
例如 macros.txt:
\grad:{\nabla}
\R:{\mathbb{R}^{#1 \times #2}}
在 book.toml 中指定:
[preprocessor.katex]
macros = "macros.txt"
之后可以在 Markdown 中写:
$$
\grad f(x) \in \R{n}{p}
$$
自定义公式分隔符
默认是:
行内:$ ... $
块级:$$ ... $$
如果希望改成 \(...\) 与 \[...\]:
[preprocessor.katex]
block-delimiter = { left = "\\[", right = "\\]" }
inline-delimiter = { left = "\\(", right = "\\)" }
当前书库配置
[preprocessor.katex]
before = ["links"]
当前采用默认的预渲染模式:公式在构建阶段生成 HTML,页面不依赖客户端 JavaScript 再做公式计算。
KaTeX 必须先于 links 预处理器运行,避免 <、& 和反斜杠等 LaTeX 内容被提前改写。
使用建议
- 句子中的短变量、短表达式使用行内公式;
- 推导、矩阵、分段函数使用块级公式;
- 长公式尽量使用
aligned分行; - 如果公式无法渲染,先检查 KaTeX 是否支持该 LaTeX 命令。
mdbook-mermaid 操作手册
mdbook-mermaid 用于在 mdBook 中渲染 Mermaid 图。最适合流程图、时序图、状态图、ER 图以及各种关系图。
原仓库:https://github.com/badboy/mdbook-mermaid
流程图
样例:横向流程
flowchart LR
A[输入] --> B{检查}
B -->|通过| C[处理]
B -->|失败| D[报错]
对应源码:
```mermaid
flowchart LR
A[输入] --> B{检查}
B -->|通过| C[处理]
B -->|失败| D[报错]
```
方向可用:
LR:从左到右
RL:从右到左
TB / TD:从上到下
BT:从下到上
常用节点形状
flowchart LR
A[矩形]
B(圆角矩形)
C{判断}
D((圆形))
E[(数据库)]
对应源码:
```mermaid
flowchart LR
A[矩形]
B(圆角矩形)
C{判断}
D((圆形))
E[(数据库)]
```
带标签的箭头
flowchart LR
Query -->|RPC| Worker
Worker -->|Top-k| Controller
对应源码:
```mermaid
flowchart LR
Query -->|RPC| Worker
Worker -->|Top-k| Controller
```
子图
适合描述系统中的模块分组。
flowchart LR
subgraph ClientSide[Client]
A[Query]
end
subgraph ServerSide[Server]
B[Controller]
C[Worker]
end
A --> B --> C
对应源码:
```mermaid
flowchart LR
subgraph ClientSide[Client]
A[Query]
end
subgraph ServerSide[Server]
B[Controller]
C[Worker]
end
A --> B --> C
```
时序图
样例
sequenceDiagram
participant C as Client
participant M as Controller
participant W as Worker
C->>M: Query
M->>W: Search shard
W-->>M: Top-k
M-->>C: Final result
对应源码:
```mermaid
sequenceDiagram
participant C as Client
participant M as Controller
participant W as Worker
C->>M: Query
M->>W: Search shard
W-->>M: Top-k
M-->>C: Final result
```
时序图中的循环和条件
sequenceDiagram
Client->>Server: Request
alt cache hit
Server-->>Client: Cached result
else cache miss
Server->>DB: Query
DB-->>Server: Result
Server-->>Client: Result
end
对应源码:
```mermaid
sequenceDiagram
Client->>Server: Request
alt cache hit
Server-->>Client: Cached result
else cache miss
Server->>DB: Query
DB-->>Server: Result
Server-->>Client: Result
end
```
状态图
stateDiagram-v2
[*] --> Idle
Idle --> Running
Running --> Success
Running --> Failed
Success --> [*]
Failed --> Idle
对应源码:
```mermaid
stateDiagram-v2
[*] --> Idle
Idle --> Running
Running --> Success
Running --> Failed
Success --> [*]
Failed --> Idle
```
ER 图
erDiagram
USER ||--o{ NOTE : owns
NOTE }o--o{ TAG : has
对应源码:
```mermaid
erDiagram
USER ||--o{ NOTE : owns
NOTE }o--o{ TAG : has
```
类图
classDiagram
class Controller {
+search(query)
+merge(results)
}
class Worker {
+searchShard(query)
}
Controller --> Worker
对应源码:
```mermaid
classDiagram
class Controller {
+search(query)
+merge(results)
}
class Worker {
+searchShard(query)
}
Controller --> Worker
```
当前书库配置
[preprocessor.mermaid]
command = "mdbook-mermaid"
[output.html]
additional-js = ["mermaid.min.js", "mermaid-init.js"]
项目根目录的 mermaid-init.js 会根据 mdBook 当前主题选择 Mermaid 明暗主题;从亮色切到暗色或反向切换时,会通过刷新页面重新渲染 Mermaid。
Mermaid 适合什么
优先使用 Mermaid:
- 调用链;
- 服务间交互;
- 状态机;
- 数据模型关系;
- 逻辑流程;
- 架构模块关系。
如果重点是精确控制几何位置、框体尺寸和论文式示意图布局,优先考虑 Pikchr。
mdbook-obsidian-format 操作手册
mdbook-obsidian-format 用于让 mdBook 正文直接使用 Obsidian 的 WikiLink 和 Embed 语法。
本地源码:/Users/ruoyangdong/project/rust/mdbook-obsidian-format
原仓库:https://github.com/Quaternijkon/mdbook-obsidian-format
转换只发生在 mdBook 构建时的内存 Book 中,不会改写原 Markdown 文件。
普通 WikiLink
样例
实际效果:[[Homepage]]
对应源码:
[[Homepage]]
没有扩展名时会自动按 Markdown 章节处理并补 .md。
WikiLink 别名
样例
实际效果:[[Homepage|返回首页]]
对应源码:
[[Homepage|返回首页]]
竖线右侧是页面上显示的文字。
指向子目录中的章节
样例
实际效果:[[Paper/ANNS/FlowANN]]
对应源码:
[[Paper/ANNS/FlowANN]]
所有路径都相对于 mdBook 的 src 根目录解析。
路径 + 别名
样例
实际效果:[[Paper/ANNS/FlowANN|FlowANN 论文笔记]]
对应源码:
[[Paper/ANNS/FlowANN|FlowANN 论文笔记]]
指向章节标题
[[Paper/ANNS/FlowANN#某个标题]]
也可以带别名:
[[Paper/ANNS/FlowANN#某个标题|跳到指定小节]]
构建后会变成带 heading anchor 的标准 Markdown 链接。
图片 Embed
![[assets/logo.png]]
图片会转换成标准 Markdown image。
PDF Embed
![[downloads/manual.pdf]]
当前实现会转换成普通文件链接,不会生成 PDF viewer。
音频 Embed
![[audio/intro.mp3]]
会转换成普通文件链接,不会自动生成播放器。
视频或其他非图片文件
![[video/demo.mp4]]
同样按普通文件链接处理。
笔记 Embed
![[Paper/ANNS/FlowANN]]
当前不会把另一个 Markdown 章节正文完整嵌入本页,而是降级为该笔记的普通链接。
目标不存在时
当前配置:
warn-on-missing = true
因此:
- 构建输出 warning;
- 仍生成链接;
- 不会直接中止构建。
如不希望 warning:
[preprocessor.obsidian-format]
warn-on-missing = false
是否处理 Embed
当前:
transform-embeds = true
因此 ![[...]] 会转换。
若希望保留原始 Obsidian Embed 文本:
transform-embeds = false
不会转换的区域
下面这些区域会保持原样:
- YAML Frontmatter;
- fenced code block;
- 行内代码;
- raw HTML;
- callout;
- Obsidian properties;
- tags;
- comments;
- Dataview;
- Mermaid;
- MathJax / 数学公式。
为什么代码块里的 WikiLink 不会变化
例如:
```markdown
[[Homepage]]
```
因为 fenced code block 是示例源码,插件会跳过它。这正适合操作手册中展示 Obsidian 语法。
Block Anchor
Obsidian block reference 没有直接对应的 mdBook 标准能力,因此 block anchor 会降级为文件链接。
需要稳定页内定位时,优先使用 heading:
[[Note#Heading]]
SUMMARY.md 不能使用 WikiLink
SUMMARY.md 会在 preprocessor 运行前由 mdBook 自己解析,因此必须使用标准 Markdown link:
- [FlowANN](Paper/ANNS/FlowANN.md)
不能写:
- [[Paper/ANNS/FlowANN]]
正文页面则可以正常使用 WikiLink。
当前配置
[preprocessor.obsidian-format]
after = ["index", "frontmatter-render"]
warn-on-missing = true
transform-embeds = true
与 frontmatter-render 的配合
当前顺序:
frontmatter-render → obsidian-format → renderer
| 区域 | 负责插件 |
|---|---|
| Frontmatter WikiLink | frontmatter-render |
| 正文 WikiLink | obsidian-format |
正文 ![[...]] Embed | obsidian-format |
常用写法速查
| 需求 | 写法 |
|---|---|
| 链接笔记 | [[Note]] |
| 自定义显示文字 | [[Note|文字]] |
| 跳到标题 | [[Note#Heading]] |
| 标题 + 别名 | [[Note#Heading|文字]] |
| 嵌入图片 | ![[image.png]] |
| 链接 PDF | ![[manual.pdf]] |
| 链接音频 | ![[audio.mp3]] |
mdbook-pikchr 操作手册
mdbook-pikchr 会把 pikchr fenced code block 渲染成 SVG 图,适合论文式框图、结构图和需要精确相对布局的技术示意图。
原仓库:https://github.com/podsvirov/mdbook-pikchr
最基础的框和箭头
对应源码:
```pikchr
box "Client"
arrow
box "Server"
```
横向流程
对应源码:
```pikchr
right
box "Query"
arrow
box "Index"
arrow
box "Top-k"
```
纵向流程
对应源码:
```pikchr
down
box "Controller"
arrow
box "Worker"
arrow
box "SSD"
```
常用图形元素
Pikchr 常见对象包括:
box
circle
ellipse
line
arrow
move
text
常用方向:
right
left
up
down
样例:不同形状
对应源码:
```pikchr
right
box "Box"
arrow
circle "C"
arrow
ellipse "Ellipse"
```
用变量重复布局
Pikchr 的优势之一是可以给对象命名,之后基于对象位置继续画图。
A: box “Controller”
B: box “Worker” right of A
arrow from A.e to B.w
/* 1 */ A: box "Controller"
/* 2 */ B: box "Worker" right of A
^^^^^
ERROR: use with line-oriented objects only
对应源码:
```pikchr
A: box "Controller"
B: box "Worker" right of A
arrow from A.e to B.w
```
对象的常用锚点包括:
.n north
.s south
.e east
.w west
.ne .nw .se .sw
这比只依赖自动排版更适合需要精确连接位置的论文框图。
样例:一对多结构
C: box “Controller”
W1: box “Worker 1” below left of C
W2: box “Worker 2” below right of C
arrow from C.s to W1.n
arrow from C.s to W2.n
/* 1 */ C: box "Controller"
/* 2 */ W1: box "Worker 1" below left of C
^^^^
ERROR: use with line-oriented objects only
对应源码:
```pikchr
C: box "Controller"
W1: box "Worker 1" below left of C
W2: box "Worker 2" below right of C
arrow from C.s to W1.n
arrow from C.s to W2.n
```
图的对齐方式
mdbook-pikchr 支持配置整个渲染图在页面中的水平对齐:
[preprocessor.pikchr]
align = "center"
可选:
left
center
right
默认是 center。
当前书库配置
[preprocessor.pikchr]
因此当前使用默认居中对齐。
Mermaid 和 Pikchr 怎么选
用 Mermaid:
- 流程图;
- 时序图;
- 状态机;
- ER 图;
- 关系复杂但不关心像素级布局的系统图。
用 Pikchr:
- 论文中的简洁框图;
- 需要控制对象相对位置;
- 箭头必须从某个框的特定边进入/离开;
- 希望最终图像更像传统技术论文中的手工制图。
mdbook-qr 操作手册
mdbook-qr 在构建时生成 PNG 二维码,并把 Markdown 中的二维码 marker 替换为 <img>。
原仓库:https://github.com/CompEng0001/mdbook-qr
本书当前二维码
当前主二维码编码的是:
https://pages.quaternijkon.online
注意:默认二维码编码的是 book.toml 中配置的 url,不是自动拼接当前章节 URL。
基础用法
默认 marker 为:
{{QR_CODE}}
在 Markdown 中放入它,构建后会替换成二维码图片。
当前配置:
[preprocessor.qr]
enable = true
url = "https://pages.quaternijkon.online"
qr-path = "src/qr.png"
margin = 2
background = "#FFFFFFFF"
module = "#000000FF"
[preprocessor.qr.fit]
width = 256
height = 256
[preprocessor.qr.shape]
circle = true
修改二维码内容
url 本质上是二维码要编码的字符串。
[preprocessor.qr]
url = "https://pages.quaternijkon.online"
也可以是普通文本:
[preprocessor.qr]
url = "Quaternijkon mdBook"
设置尺寸
[preprocessor.qr.fit]
width = 256
height = 256
如果只提供一个维度,插件会把同一个值用于另一个维度。
设置边距
[preprocessor.qr]
margin = 2
margin 是二维码周围的 quiet zone,单位为二维码 module。边距过小可能降低扫描可靠性。
设置颜色
[preprocessor.qr]
background = "#FFFFFFFF"
module = "#000000FF"
支持 #RRGGBB、#RRGGBBAA,也支持 RGB/RGBA 数组形式。前景和背景应保持高对比度。
设置模块形状
支持:
[preprocessor.qr.shape]
square = true
circle = true
rounded_square = true
vertical = true
horizontal = true
diamond = true
多个值同时为 true 时,优先级为:
circle → rounded_square → vertical → horizontal → diamond → square
如果均未指定,默认使用 square。
一个书中生成多个不同二维码
使用 custom.* 定义命名二维码。
样例:GitHub 二维码
[preprocessor.qr]
url = "https://pages.quaternijkon.online"
[preprocessor.qr.custom.github]
marker = "{{QR_GITHUB}}"
url = "https://github.com/Quaternijkon"
qr-path = "src/qr-github.png"
fit.width = 160
fit.height = 160
shape.diamond = true
然后在 Markdown 中使用:
{{QR_GITHUB}}
自定义配置会继承主配置,显式字段覆盖父配置。
本地预览模式
[preprocessor.qr]
localhost-qr = true
本地预览时会生成指向:
http://127.0.0.1:3000/
的开发二维码,并使用专门的本地图片路径。
常见问题
页面没有二维码
检查:
- Markdown 中是否放了对应 marker;
- custom 二维码是否定义了
marker; enable是否为true。
扫出来不是当前文章
这是正常的。默认 QR 编码固定 url。如果不同页面需要不同目标地址,应定义不同的 custom marker / URL。
mdbook-reading-time 操作手册
mdbook-reading-time 用于计算当前章节的字词数量和预计阅读时间。
原仓库:https://github.com/pawurb/mdbook-reading-time
本页实际结果
本页字词数:484
预计阅读时间:1 minute
这两个值由插件在构建时替换。
两个核心占位符
插件不会自动把结果插到页面中,需要在 Markdown 中主动放置占位符。
字词数
484
构建后替换为当前章节的 word count。
阅读时间
1 minute
构建后替换为类似:
9 minutes
样例:文章标题下显示阅读信息
# FlowANN
> 484 words · 1 minute
正文……
样例:只显示预计阅读时间
预计阅读:1 minute
当前配置
[preprocessor.reading-time]
words-per-minute = 250
插件默认是 200 WPM;当前书库改为 250 WPM。
概念上的计算关系是:
reading time ≈ word count / words-per-minute
例如:
1000 words / 250 WPM ≈ 4 minutes
调整阅读速度
希望预计时间更长,可以降低 WPM:
[preprocessor.reading-time]
words-per-minute = 180
希望预计时间更短,可以提高 WPM:
[preprocessor.reading-time]
words-per-minute = 300
这个参数只影响 reading_time,不会改变 word_count。
常用放置位置
标题下方
# 文章标题
> 484 words · 1 minute
正文末尾
---
全文 484 words。
Frontmatter 卡片之后
如果页面同时使用 mdbook-frontmatter-render,可把阅读信息放在属性卡之后、正文之前。
中文与论文笔记
插件输出的是自身统计得到的 word count,再按 WPM 换算时间。中文、公式密集或代码很多的页面,应把结果理解为粗略阅读成本,而不是严格计时。
如果需要校准,选几篇你实际读过的长文,对照真实耗时后统一调整 words-per-minute 即可。
mdbook-toc 操作手册
mdbook-toc 用于在章节内部生成页内目录。它扫描 marker 后面的标题,并生成对应锚点列表。
原仓库:https://github.com/badboy/mdbook-toc
本页目录
上面的目录就是本插件根据本页标题生成的。
基础用法
默认 marker:
<!-- toc -->
把它放在希望显示目录的位置即可。
样例
# 向量检索系统设计
<!-- toc -->
## 背景
正文……
## 索引结构
### 上层索引
正文……
### 下层索引
正文……
## 实验
正文……
构建后 marker 会被替换成指向这些标题的页内目录。
marker 的位置
插件只根据 marker 后方的标题生成目录,所以通常应放在 H1 之后、第一个 H2 之前。
# 文章标题
简介。
<!-- toc -->
## 第一部分
...
如果把 marker 放在文章中间,前面的标题不会进入 TOC。
标题层级
目录层次直接来自 Markdown heading:
## 一级目录项
### 二级目录项
#### 三级目录项
不要为了字号效果随意跳标题级别,否则 TOC 结构也会混乱。
最大标题级别
默认包含到 H4。可以修改:
[preprocessor.toc]
max-level = 3
这样目录最多包含到 ###。
如果只想显示主要部分:
max-level = 2
自定义 marker
例如改成 GitLab 风格:
[preprocessor.toc]
marker = "[[_TOC_]]"
正文中就使用:
[[_TOC_]]
多行 marker
支持多行字符串,例如:
[preprocessor.toc]
marker = """* auto-gen TOC;
{:toc}"""
也可以写成:
[preprocessor.toc]
marker = "* auto-gen TOC;\n{:toc}"
当前书库配置
[preprocessor.toc]
command = "mdbook-toc"
renderer = ["html"]
当前仅对 HTML renderer 运行。
什么时候使用
推荐用于长论文笔记、三级以上标题较多的技术文档、系统设计文档和操作手册。短页面通常不需要 TOC。
常见问题
某个标题没有进入目录
检查:
- 标题是否位于 marker 之前;
- 标题级别是否超过
max-level; - 是否使用了真正的 Markdown heading。
目录层级很乱
通常是正文标题跳级造成的。先修正文档标题结构,而不是单独调整 TOC。