看完短课容易忘。把自己的笔记做成间隔重复卡片是个好办法。 这篇做
to_anki.py:把 Markdown 里Q:/A:/Tags:格式的笔记转成 Anki 可以直接导入的文本文件(带文件头,自动选好分隔符、牌组和标签列),先检查重复和空答案;再用 Anki 官方的 Python 包在一个临时收藏里真实导入一次,确认 Anki 能正确识别。
先看清成品
- 用 Anki 官方 Python 包(anki 26.09.3)真实导入:5 张卡片、标签、牌组
学习笔记::RAG和多行答案都正确。- 真实测试重复导入:同一文件再导入 0 新增 5 重复;改一个答案再导入,在原笔记上更新,但计数在
first_field_match,不在updated。- 发现并修复:笔记格式错时脚本直接抛 Traceback。5 项 pytest 通过。
合规提醒:DeepLearning.AI 的课程视频、Notebook 和讲义受版权保护,按其使用条款只供个人学习。本文不复制、不抓取任何课程内容,只把课程讲到的方法用自己写的数据和代码重新做一遍。课程是否免费、证书规则以 deeplearning.ai 页面为准。卡片内容用自己的话写,不要大段复制课程讲义。
运行说明:全部在 box 上真实运行(2026-10-21 21:52 UTC+8 左右)。导入验证用的是 Anki 官方发布在 PyPI 的
anki包和一个临时收藏,我没有在 Anki 桌面版里手动点导入。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
my_notes.md |
示例笔记(我写的) |
to_anki.py |
转换 + 检查 |
cards.txt |
生成的导入文件 |
verify_import.py |
用官方 anki 包试导入 |
test_to_anki.py |
5 项测试 |
需要准备哪些环境?
| 项目 | 说明 |
|---|---|
| Python | 3.9 以上,pip install pytest |
| Anki | 桌面版 2.1.54 以上(文件头功能从这个版本开始支持) |
| 可选 | pip install anki 用于自动验证 |
关键事实
| 事实 | 来源 |
|---|---|
| 文本文件必须 UTF-8;Anki 按第一条非注释行判断字段数 | Anki 手册 Text Files |
| 文件头(2.1.54+):#separator、#html、#tags、#columns、#deck、#tags column 等,每个一行,放在文件最前面 | 同上 |
html 打开时用 <br> 换行;尖括号和 & 要转义 |
同上 |
| Anki 用第一个字段判断重复;默认会用文件内容更新已有笔记 | 同上 |
文件结构
87-deeplearning-ai-C-src/
├── my_notes.md
├── to_anki.py
├── cards.txt
├── verify_import.py
└── test_to_anki.py
第 1 步:笔记格式
# RAG 学习笔记(自己的话)
Q: 句子窗口检索的思路是什么?
A: 用单个句子做检索单位,命中后把前后几句一起交给模型,兼顾检索精度和上下文。
Tags: rag 检索
Q: RAG 三元组指哪三项?
A: 上下文相关性(Context Relevance)、有据性(Groundedness)、答案相关性(Answer Relevance)。
Tags: rag 评估
Q: 为什么固定长度切块会出问题?
A: 可能把一个完整信息切成两半,比如把 IP 地址 192.168.31.20 切到两个块里。
Tags: rag
Q: 提示词的两条原则?
A: 1. 写清楚、具体的指令;
2. 给模型时间“思考”。
Tags: prompt
Q: 分隔符有什么用?
A: 把要处理的文本和指令分开,比如用 ```三个反引号``` 或 <tag></tag>,减少把输入当成指令的情况。
Tags: prompt 安全
第 2 步:转换脚本
"""把自己的 Q/A 笔记转成 Anki 可导入的纯文本文件(UTF-8,Tab 分隔,带文件头)。
格式依据 Anki 手册“Text Files”:文件头 #separator / #html / #columns / #deck / #tags column(2.1.54+);
多行内容在 html:true 时用 <br>;尖括号和 & 需转义;Anki 用第一个字段判断重复,所以 Front 必须唯一。"""
import argparse, html, re, sys
def parse(md):
cards, cur, key = [], None, None
for line in md.splitlines():
m = re.match(r"^(Q|A|Tags):\s*(.*)$", line)
if m:
key = m.group(1)
if key == "Q":
cur = {"Q": m.group(2), "A": "", "Tags": ""}; cards.append(cur)
elif cur is None:
raise ValueError(f"{key}: 出现在任何 Q: 之前")
else:
cur[key] = m.group(2)
elif cur and key == "A" and line.strip():
cur["A"] += "\n" + line # 答案可以多行
elif not line.strip():
key = None
return cards
def field(s):
# 先转义 < > &,再把换行变成 <br>;Tab 是分隔符,替换成空格
return html.escape(s.strip(), quote=False).replace("\t", " ").replace("\n", "<br>")
def check(cards):
errs, seen = [], {}
for i, c in enumerate(cards, 1):
if not c["Q"].strip(): errs.append(f"第{i}张没有问题")
if not c["A"].strip(): errs.append(f"第{i}张没有答案:{c['Q'][:20]}")
k = c["Q"].strip()
if k in seen: errs.append(f"第{i}张和第{seen[k]}张问题相同,导入时会被当成重复(Anki 按第一个字段判重)")
seen.setdefault(k, i)
return errs
def render(cards, deck):
head = ["#separator:tab", "#html:true", f"#deck:{deck}", "#columns:Front\tBack\tTags", "#tags column:3"]
rows = [f"{field(c['Q'])}\t{field(c['A'])}\t{c['Tags'].strip()}" for c in cards]
return "\n".join(head + rows) + "\n"
def main(argv=None):
ap = argparse.ArgumentParser()
ap.add_argument("notes"); ap.add_argument("-o", "--out", default="cards.txt")
ap.add_argument("--deck", default="学习笔记::RAG")
a = ap.parse_args(argv)
try:
cards = parse(open(a.notes, encoding="utf-8").read())
except ValueError as e:
print("✗ 笔记格式错误:", e, file=sys.stderr); return 1
errs = check(cards)
for e in errs: print("✗", e, file=sys.stderr)
if errs: return 1
open(a.out, "w", encoding="utf-8", newline="\n").write(render(cards, a.deck))
print(f"{len(cards)} 张卡片 → {a.out}(在 Anki 里 文件 → 导入,选这个文件)")
return 0
if __name__ == "__main__":
sys.exit(main())
第 3 步:生成的文件
#separator:tab
#html:true
#deck:学习笔记::RAG
#columns:Front Back Tags
#tags column:3
句子窗口检索的思路是什么? 用单个句子做检索单位,命中后把前后几句一起交给模型,兼顾检索精度和上下文。 rag 检索
RAG 三元组指哪三项? 上下文相关性(Context Relevance)、有据性(Groundedness)、答案相关性(Answer Relevance)。 rag 评估
为什么固定长度切块会出问题? 可能把一个完整信息切成两半,比如把 IP 地址 192.168.31.20 切到两个块里。 rag
提示词的两条原则? 1. 写清楚、具体的指令;<br>2. 给模型时间“思考”。 prompt
分隔符有什么用? 把要处理的文本和指令分开,比如用 ```三个反引号``` 或 <tag></tag>,减少把输入当成指令的情况。 prompt 安全
第 4 步:导入验证
"""用 Anki 官方的 Python 包(pip install anki)在一个临时的新收藏里真实导入 cards.txt,确认文件头被识别、卡片数和字段正确。
不碰你自己的 Anki 收藏。"""
import os, sys, tempfile
from anki.collection import Collection, ImportCsvRequest
path = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else "cards.txt")
with tempfile.TemporaryDirectory() as d:
col = Collection(os.path.join(d, "test.anki2"))
md = col.get_csv_metadata(path=path, delimiter=None)
print("识别到:分隔符", md.Delimiter.Name(md.delimiter), "| html", md.is_html, "| 列", list(md.column_labels), "| 牌组 id", md.deck_id)
res = col.import_csv(ImportCsvRequest(path=path, metadata=md))
print("新增笔记:", len(res.log.new), "| 重复:", len(res.log.duplicate), "| 按第一字段匹配并更新:", len(res.log.first_field_match))
res2 = col.import_csv(ImportCsvRequest(path=path, metadata=md))
print("同一文件再导入一次 → 新增", len(res2.log.new), "| 按第一字段更新", len(res2.log.first_field_match), "| 重复", len(res2.log.duplicate))
for nid in col.find_notes(""):
n = col.get_note(nid)
print(" ", col.decks.name(col.get_card(n.card_ids()[0]).did), "|", n.fields[0][:18], "|", n.fields[1][:24], "|", n.tags)
col.close()
第 5 步:测试
import to_anki as t
def test_parse_multiline_and_tags():
c = t.parse("Q: 问\nA: 第一行\n第二行\nTags: a b\n")
assert c == [{"Q": "问", "A": "第一行\n第二行", "Tags": "a b"}]
def test_field_escapes_html_and_tab():
assert t.field("a<b>&c\td\ne") == "a<b>&c d<br>e"
def test_check_dupes_and_empty():
errs = t.check(t.parse("Q: x\nA: 1\n\nQ: x\nA: 2\n\nQ: y\n"))
assert any("重复" in e for e in errs) and any("没有答案" in e for e in errs)
def test_render_headers():
out = t.render(t.parse("Q: q\nA: a\nTags: k\n"), "D::E").splitlines()
assert out[:5] == ["#separator:tab", "#html:true", "#deck:D::E", "#columns:Front\tBack\tTags", "#tags column:3"]
assert out[5] == "q\ta\tk"
def test_answer_before_question_rejected():
import pytest
with pytest.raises(ValueError): t.parse("A: 孤儿\n")
第 6 步:运行
$ python to_anki.py my_notes.md
5 张卡片 → cards.txt(在 Anki 里 文件 → 导入,选这个文件)
$ python verify_import.py
识别到:分隔符 TAB | html True | 列 ['Front', 'Back', 'Tags'] | 牌组 id 0
新增笔记: 5 | 重复: 0 | 按第一字段匹配并更新: 0
同一文件再导入一次 → 新增 0 | 按第一字段更新 0 | 重复 5
学习笔记::RAG | 句子窗口检索的思路是什么? | 用单个句子做检索单位,命中后把前后几句一起交给模 | ['rag', '检索']
学习笔记::RAG | RAG 三元组指哪三项? | 上下文相关性(Context Relevance | ['rag', '评估']
学习笔记::RAG | 为什么固定长度切块会出问题? | 可能把一个完整信息切成两半,比如把 IP 地址 | ['rag']
学习笔记::RAG | 提示词的两条原则? | 1. 写清楚、具体的指令;<br>2. 给模型时 | ['prompt']
学习笔记::RAG | 分隔符有什么用? | 把要处理的文本和指令分开,比如用 ```三个反引 | ['prompt', '安全']
# 检查功能
$ python to_anki.py bad2.md # 两个相同问题 + 一个空答案
✗ 第2张和第1张问题相同,导入时会被当成重复(Anki 按第一个字段判重)
✗ 第3张没有答案:空答案
$ python to_anki.py bad1.md # 答案出现在问题之前
✗ 笔记格式错误: A: 出现在任何 Q: 之前
$ python -m pytest -q
5 passed
怎么检查结果?
- 在 Anki 桌面版:文件 → 导入,选
cards.txt,预览里应该已经选好 Tab 分隔、允许 HTML、牌组和标签列,不用手动调。 - 第 4 张卡片的答案显示为两行。
- 第 5 张卡片里的
<tag></tag>原样显示,没有被当成 HTML 吞掉。
调试示例
1. 修改笔记后再导入,“更新”数是 0,但内容确实更新了。 我把第一张卡的答案改了几个字,在同一个收藏里再导入:
cards.txt 新增 5 更新 0 重复 0 | dupe_resolution 0
cards_edit.txt 新增 0 更新 0 重复 4 | dupe_resolution 0
一开始以为没更新。列出日志的全部字段:duplicate 4、first_field_match 1、found_notes 5;再读出笔记,答案已经是新内容。也就是说,在这个版本里按第一个字段匹配并更新的笔记记在 first_field_match 里,不在 updated 里。验证脚本因此改为打印 first_field_match。这和手册说的“默认会更新已有笔记”一致,只是统计字段名和我猜的不一样。
2. 格式错误时直接抛 Traceback。 用一个以 A: 开头的文件测试,屏幕上是一整段 Python 调用栈。改为在 main 里捕获 ValueError,打印一行“笔记格式错误”并返回 1。
3. 为什么不能随便改问题。 Anki 按第一个字段(这里是问题)判断是不是同一张卡。如果改了问题的措辞,再导入就会变成一张新卡,旧卡和学习记录还在。要改问题,最好在 Anki 里直接改。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 中文乱码 | 文件不是 UTF-8 | 本脚本固定写 UTF-8 |
| 全部挤在一个字段 | 分隔符没识别 | 确认文件头 #separator:tab 在最前面 |
| 出现重复卡 | 改了问题的措辞 | 在 Anki 里改问题 |
原样显示 |
导入时没允许 HTML | 文件头已有 #html:true;手动导入时勾选 |
模板规则
- 问题唯一,作为判重的依据。
- 用文件头固定导入设置。
- 先检查再生成。
- 用自己的话写卡片。
项目资源
| 用途 | 地址 |
|---|---|
| DeepLearning.AI 短课列表 | https://www.deeplearning.ai/short-courses/ |
| DeepLearning.AI 使用条款 | https://www.deeplearning.ai/terms-of-use/ |
| Anki 手册:导入文本文件 | https://docs.ankiweb.net/importing/text-files.html |
| anki(PyPI,官方 Python 包) | https://pypi.org/project/anki/ |
| Building and Evaluating Advanced RAG 课程页 | https://www.deeplearning.ai/short-courses/building-evaluating-advanced-rag/ |
每学完一节课,花五分钟写几张卡,比重看一遍视频有效。