问题:教授概念前提前使用

我有一门包含47节课的编程课程。每节课都有笔记(用来解释概念)以及实验室任务(供学员练习)。然而,我有一个问题:有时我会在实验中使用尚未在笔记中解释过的概念。

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

问题是什么?直到三节课后,我才开始解释 map 的含义。

这种问题比你想象中更常见。因为我对教学内容非常熟悉,经常跳跃思路,可能在无意间假设学生已经理解了一些我还没有实际讲到的概念。结果导致学生感到非常困惑和沮丧,他们认为自己不够聪明,但真正需要改进的是老师的教学大纲。

手动解决方案是检查每个实验室任务,列出所使用的概念,并验证这些概念是否已被教学过。然而,我的课程有47节课,每节课有多个Notebooks。手动搞定显然不是办法。

解决方案:使用 ChromaDB 实现语义搜索

解决方案其实很简单:

  1. 从每个 Notebook 中提取概念(包括教学和使用的概念)
  2. 将这些概念存储到一个能够处理“意义”、不仅是文本的数据库中
  3. 对实验室任务中使用的每个概念,验证其是否已在之前的笔记中介绍过

这个“处理意义”是关键。如果在笔记中我提到了“高阶函数”,而在实验室任务中使用了“higher-order function”,普通的 grep 是无法匹配到的。但从语义上来说,它们是相同的。

这就是 ChromaDB 派上用场的地方:这是一种将文本转化为嵌入向量的向量数据库,并支持基于相似度的搜索。简单来说,你可以将文本存储进去,然后问它“有没有类似这个的东西?”它会返回最相似的内容。

五分钟了解 ChromaDB

ChromaDB 就像是用于嵌入向量的 SQLite。一个单独的文件(或文件夹),无需服务器部署,也不需要复杂的配置。安装后即可直接使用。

pip install chromadb
# 如果你使用 uv:
uv add chromadb

基础概念

在普通的关系型数据库中,你存储的是行和列。而在 ChromaDB 中,你存储的是带有 嵌入向量 的 文档:

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. 将它们保存在磁盘上,确保持久性

相似性搜索

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”,结果返回“高阶函数”,即使它们的文字完全不同。这就是嵌入向量的神奇之处。

完整系统:课程验证工具

接下来,我们将构建一个验证系统,确保不会犯教学错误。以下展示了简化的代码概念,方便你了解大致框架。

第一步:从 Notebooks 中提取概念

首先,我们需要从每个 Notebook 文件中提取概念。我使用 LLM(例如通过 OpenRouter 的 Gemini Flash),但如果你足够大胆,也可以使用正则表达式:

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: 在假设知识背景的情况下使用的概念

第二步:保存到 ChromaDB

接下来,把提取的概念和元信息存入 ChromaDB:

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
            }]
        )

第三步:验证学习进度

这是重点。对于每个在实验中“使用”的概念,验证该概念是否在以往的笔记中出现过:

def validate_curriculum(course_concepts: dict) -> list[str]:
    """
    验证实验是否使用未教学的概念。

    Returns:
        找到的问题列表
    """
    errors = []
    known_concepts = set()

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

        # 将笔记中引入的概念加入已知概念集合
        for c in class_data:
            if c["source_type"] == "notes" and c["category"] == "introduces":
                known_concepts.add(c["name"].lower())

        # 验证实验中使用的概念
        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 来进行语义搜索而非精确匹配:

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

第四步:生成报告

对课程运行验证后,我得到了一份清晰的报告:

# 课程验证报告

发现17个问题:

## class_006_abstracciones_abstraccion_funcion

- **重构**:概念“重构”在实验中使用但未教学
  - 文件:`labs/0.funciones_basicas.ipynb`

## class_020_secuencias_while

- **记忆化**:概念“记忆化”在实验中使用但未教学
  - 文件:`labs/2.generar_secuencias.ipynb`

## class_026_funciones_orden_superior

- **map**: 概念“map”在实验中使用但未教学
  - 文件:`labs/0.ejercicios_aplicadores_listas.ipynb`

现在我知道哪些地方需要调整。

关键细节

多语言嵌入模型

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

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

默认模型(all-MiniLM-L6-v2)主要训练于英文语料,处理其他语言可能效果不佳。

余弦距离 vs 欧几里得距离

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

collection = client.get_or_create_collection(
    name="concepts",
    metadata={"hnsw:space": "cosine"}  # ← 使用余弦距离
)

余弦距离测量向量之间的角度,忽略向量的大小。对于语义相似性,它是更合适的选择。

距离转化为相似度

ChromaDB 返回的是 距离(越小越相似)。如果需要 相似度(越大越相似):

similarity = 1 - distance

对于余弦距离,范围是 [0, 2],因此相似度范围为 [-1, 1]。在实践中,相似文本的相似度通常在 [0.5, 1]。

持久化

ChromaDB 提供两种模式:

# 内存模式(关闭时数据丢失)
client = chromadb.Client()

# 持久化模式(数据存储在磁盘中)
client = chromadb.PersistentClient(path="./data/chroma")

对于需要反复执行的验证系统,建议使用持久化,以避免每次都重新计算嵌入向量。

使用 where 设置筛选条件

可以通过元数据筛选结果:

# 只在笔记中搜索
results = collection.query(
    query_texts=["函数"],
    where={"source_type": "notes"}
)

# 仅搜索20节课之前的概念
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 的工作流中:

#!/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:简单、本地化且高效。

我们展示的用例(课程验证)只是其中之一。向量数据库还可用于:

  • 文本的语义搜索
  • 增强生成(RAG) 用于大语言模型
  • 语义重复检测
  • 基于相似度的推荐系统
  • 内容聚类

最棒的是,它的入门门槛极低。安装后即可存储文档并进行搜索,无需配置复杂的服务器、数据结构或索引。

如果你需要解决“找到与某事类似的东西”的问题,试试 ChromaDB。最糟的结果是它比想象中还好用,让你感到不曾早一点使用它还真是遗憾。

本文原文为西班牙语,借助AI翻译。