问题:教授还没有教过的内容

我有一个包含47个课时的编程课程。每个课时都有notes(我解释概念的地方)和labs(学生练习的地方)。我有个问题:有时我在labs中使用了还没在notes中解释过的概念。

“好的,在这个练习中用map来转换列表。”

问题在哪?我要到三个课时之后才解释map是什么鬼东西。

这种情况比你想象的更常见。你脑子里装着所有材料,在不同地方跳来跳去,不知不觉就假设学生知道你还没告诉他们的东西。结果:挫败感、困惑,还有学生认为自己很笨,其实笨的是你。

手动解决方案是检查每个lab,记下使用了哪些概念,然后验证这些概念之前是否已经解释过。但我有47个课时,每个都有好几个notebook。算了吧。

解决方案:使用ChromaDB进行语义搜索

思路很简单:

  1. 从每个notebook中提取概念(教了什么,用了什么)
  2. 保存在一个理解语义而不仅仅是文本的数据库中
  3. 对于lab中使用的每个概念,验证它是否存在于之前的notes中

这里"理解语义"是关键。如果在notes中我说"高阶函数",在lab中使用"higher-order function",grep什么也找不到。但语义上它们是一样的。

这就是ChromaDB的用武之地:一个向量数据库,它将文本转换为嵌入向量并允许按相似性搜索。用人话说:你保存文本,然后可以问"有没有类似这个的东西?“它会返回最相似的结果。

5分钟了解ChromaDB

ChromaDB就像SQLite但用于嵌入向量。一个文件(或文件夹),无需服务器,无需配置。安装,使用,开跑。

1
2
3
pip install chromadb
# 或者如果你使用uv:
uv add chromadb

基本概念

在普通数据库中你保存带列的行。在ChromaDB中你保存带嵌入向量文档

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import chromadb

# 创建客户端(持久化到磁盘)
client = chromadb.PersistentClient(path="./mi_db")

# 创建一个"集合"(类似表)
collection = client.get_or_create_collection(
    name="conceptos",
    metadata={"hnsw:space": "cosine"}  # 余弦距离
)

# 保存文档
collection.add(
    ids=["c1", "c2", "c3"],
    documents=["纯函数", "for循环", "递归"],
    metadatas=[
        {"clase": "class_010", "tipo": "notes"},
        {"clase": "class_015", "tipo": "notes"},
        {"clase": "class_020", "tipo": "notes"},
    ]
)

就这样。ChromaDB自动:

  1. 生成文档的嵌入向量(默认使用all-MiniLM-L6-v2
  2. 为快速搜索建立索引
  3. 持久化到磁盘

按相似性搜索

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
results = collection.query(
    query_texts=["higher-order function"],
    n_results=3
)

print(results["documents"])
# [['高阶函数', '纯函数', '递归']]

print(results["distances"])
# [[0.23, 0.45, 0.67]]  # 越小 = 越相似

看到了吗?我搜索"higher-order function”,找到了"高阶函数",尽管文本完全不同。这就是嵌入向量的魔力。

完整系统:课程验证

现在我们来构建验证我没有踩坑的系统。真实代码在我的项目中,但这里给你一个简化版本让你理解概念。

步骤1:从notebook中提取概念

首先我们需要从每个notebook中提取概念。我用LLM(通过OpenRouter的Gemini Flash)来做,但如果你够勇敢也可以用正则表达式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
def extract_concepts_from_notebook(notebook_path: Path) -> list[dict]:
    """
    从Jupyter notebook中提取概念。

    Returns:
        包含{"name": "概念", "category": "introduces|uses"}的列表
    """
    content = get_notebook_content(notebook_path)

    # 调用LLM提取概念
    response = llm.chat(
        messages=[
            {"role": "system", "content": EXTRACTION_PROMPT},
            {"role": "user", "content": content}
        ]
    )

    return json.loads(response)

LLM将每个概念分类为:

  • introduces:通过解释教授
  • uses:假设已有知识的使用

步骤2:保存到ChromaDB

现在保存概念及其元数据:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from chromadb.utils import embedding_functions

# 使用多语言模型(中文+英文)
embedding_fn = embedding_functions.SentenceTransformerEmbeddingFunction(
    model_name="paraphrase-multilingual-MiniLM-L12-v2"
)

collection = client.get_or_create_collection(
    name="course_concepts",
    embedding_function=embedding_fn,
    metadata={"hnsw:space": "cosine"}
)

# 保存概念
for class_id, concepts in course_concepts.items():
    for concept in concepts:
        collection.add(
            ids=[f"{class_id}:{concept['name']}"],
            documents=[concept["name"]],
            metadatas=[{
                "class_id": class_id,
                "category": concept["category"],
                "source_type": concept["source_type"],  # notes或labs
            }]
        )

步骤3:验证进度

这里是有趣的部分。对于lab中"使用"的每个概念,我们验证在之前的notes中是否存在类似内容:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def validate_curriculum(course_concepts: dict) -> list[str]:
    """
    验证labs不会使用未教授的概念。

    Returns:
        发现的错误列表
    """
    errors = []
    known_concepts = set()

    # 按顺序处理课程
    for class_id in sorted(course_concepts.keys()):
        class_data = course_concepts[class_id]

        # 将notes中介绍的概念添加到已知概念中
        for c in class_data:
            if c["source_type"] == "notes" and c["category"] == "introduces":
                known_concepts.add(c["name"].lower())

        # 验证labs中使用的概念
        for c in class_data:
            if c["source_type"] == "labs" and c["category"] == "uses":
                if not is_concept_known(c["name"], known_concepts):
                    errors.append(
                        f"{class_id}: '{c['name']}' 使用但未教授"
                    )

    return errors

is_concept_known函数是ChromaDB发挥作用的地方。我们不做精确匹配,而是做语义搜索:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
def is_concept_known(concept: str, known_concepts: set) -> bool:
    """验证概念是否已知(精确或语义)。"""

    # 1. 精确匹配
    if concept.lower() in known_concepts:
        return True

    # 2. 语义搜索
    results = collection.query(
        query_texts=[concept],
        n_results=3,
        where={"category": "introduces"}  # 只在"introduces"中搜索
    )

    # 如果有非常相似的内容(距离 < 0.3),认为已知
    if results["distances"][0] and results["distances"][0][0] < 0.3:
        return True

    return False

步骤4:报告

在我的课程上运行验证,得到一份漂亮的报告:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# 课程验证报告

发现17个问题:

## class_006_abstraciones_abstraccion_funcion

- **重构**:概念'重构'在lab中使用但未教授
  - 文件:`labs/0.funciones_basicas.ipynb`

## class_020_secuencias_while

- **记忆化**:概念'记忆化'在lab中使用但未教授
  - 文件:`labs/2.generar_secuencias.ipynb`

## class_026_funciones_orden_superior

- **map**:概念'map'在lab中使用但未教授
  - 文件:`labs/0.ejercicios_aplicadores_listas.ipynb`

现在我确切知道需要修复什么。

重要细节

多语言嵌入模型

如果你的内容是中文,使用多语言模型:

1
2
3
embedding_fn = embedding_functions.SentenceTransformerEmbeddingFunction(
    model_name="paraphrase-multilingual-MiniLM-L12-v2"
)

默认模型(all-MiniLM-L6-v2)主要用英文训练,处理中文可能给出奇怪结果。

余弦距离vs欧几里得距离

对于文本,使用余弦距离:

1
2
3
4
collection = client.get_or_create_collection(
    name="concepts",
    metadata={"hnsw:space": "cosine"}  # ← 这个
)

余弦距离测量向量之间的角度,忽略幅度。这是你在语义相似性中想要的。

将距离转换为相似度

ChromaDB返回距离(越小=越相似)。如果你想要相似度(越大=越相似):

1
similarity = 1 - distance

对于余弦距离,范围是[0, 2],所以相似度在[-1, 1]。实际上,对于相似文本通常在[0.5, 1]。

持久化

ChromaDB有两种模式:

1
2
3
4
5
# 内存中(关闭时丢失)
client = chromadb.Client()

# 持久化(保存到磁盘)
client = chromadb.PersistentClient(path="./data/chroma")

对于要重复执行的验证系统,使用持久化。这样就不用每次重新计算嵌入向量。

使用where过滤

你可以通过元数据过滤结果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 只在notes中搜索
results = collection.query(
    query_texts=["函数"],
    where={"source_type": "notes"}
)

# 只在020课之前的课程中搜索
results = collection.query(
    query_texts=["函数"],
    where={"class_num": {"$lt": 20}}
)

这对验证至关重要:我们只想在已经教授的概念中搜索。

ChromaDB的替代方案

ChromaDB不是唯一选择。这里有其他选项:

工具优点缺点
ChromaDB简单,无服务器,文档良好限制在百万级向量
Pinecone可扩展,托管服务付费,供应商锁定
Weaviate强大,GraphQL API配置复杂
Qdrant快速,Rust编写知名度较低
pgvector如果你已在用PostgreSQL需要PostgreSQL

对于这样的项目(数千概念,不是数百万),ChromaDB是完美的。如果需要扩展到万亿向量或高可用性,看看其他选择。

pre-commit钩子

为了让这真正有用,我将其集成到git工作流中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#!/bin/bash
# .git/hooks/pre-commit

echo "🔍 验证课程进度..."

if python bin/concept_index.py validate; then
    echo "✓ 验证成功"
    exit 0
else
    echo "❌ 有未教授就使用的概念"
    echo "   执行:make concept-validate-report"
    exit 1
fi

现在,每次我尝试提交时,系统验证我没有踩坑。如果有违规,提交被阻止并告诉我修复什么。

结论

ChromaDB是那种当你发现它时会想"我之前怎么没有这个东西生活的?“的工具之一。它是嵌入向量的SQLite:简单、本地、有效。

我展示的用例(课程验证)只是一个例子。向量数据库适用于:

  • 文档语义搜索
  • LLM的RAG(检索增强生成)
  • 语义重复检测
  • 基于相似性的推荐系统
  • 内容聚类

最好的是:入门门槛很低。安装,保存文档,搜索。不需要配置服务器、模式或复杂索引。

如果你有一个需要找到"类似这个的东西"的问题,试试ChromaDB。最坏情况是它工作得太好,你会疑惑为什么之前不用它。


TL;DR:ChromaDB是一个本地简单的向量数据库。我们用它来验证编程课程不会在教授概念之前就使用这些概念,使用语义搜索来检测语义相似的概念,即使文本不同。