目录

campusclaw — 班级限定的可追溯知识库检索

「2026 互联网软件开发」课程第 4 课的变更实现:在已有的 Flask 教学材料应用上,加一条 限定在本班范围内的知识库检索链,并让每条检索结果都能回溯到材料原文。向量检索 用 Qdrant 承载。

仓库同时存放变更的规格驱动文档,便于对照「需求 → 设计 → 任务 → 实现」的对应关系:

README.md                  ← 本文件
campusclaw-mygroup/        ← 实现代码
openspec/                  ← 变更文档(proposal / specs / design / tasks)
└── changes/add-traceable-vector-retrieval/

这一课解决的问题

上一课已经有了登录、角色权限与班级隔离,但材料只能整份下载,没法问「材料里讲了什么」。 本变更补上这条链:把材料切成切片、建索引、按班检索,并把每一条命中都带回它在原文中的位置。

三条硬要求:

  1. 班级隔离:检索范围一律取自会话里的班级,请求体里带的 class_id 一律忽略;跨班检索 返回 200 + 空命中,而不是报错或泄露他班内容。
  2. 内容溯源:每条命中带材料标题、切片序号、字符区间与摘录,可据此打开原文定位。
  3. 不编造:没有依据时返回固定文案「资料中未找到相关内容」,不会拿相关度偏低的切片充数。

检索是怎么工作的

两种召回各自独立过滤,再按名次融合(Reciprocal Rank Fusion,k = 60):

模式 做法 依赖
keyword SQLite FTS5 MATCH + bm25() 排序 只用关系库
vector 问句嵌入 → Qdrant 按 class_id 过滤 → 丢弃余弦 < 0.35 → 按主键回表 嵌入网关 + Qdrant
hybrid(默认) 上面两路各取一份名次,做 RRF 求和 两者

几个刻意的取舍:

  • 融合基于名次,不是分数相加。 关键字给的是 bm25(越小越好),向量给的是余弦 (越大越好),两者量纲完全不同,直接相加没有意义。
  • 摘录一律回关系库取,chunk_text 是唯一事实来源。写进 Qdrant 的 payload 只有 5 个标识 键(class_id / material_id / knowledge_entry_id / chunk_id / chunk_index), 不含切片正文——向量库不是正文的存储。
  • 中文两字词靠手工二元组化。 本机 SQLite 既没有 ngram 分词器,unicode61 对中文 命中为 0,trigram 对两字词命中为 0。因此切分写入与查询匹配共用同一个 chunking.search_blob(),把连续 CJK 拆成逐字二元组(向量库 → 向量 量库), 用显式 OR 拼成 MATCH 查询。

目录结构(实现部分)

campusclaw-mygroup/
├── app.py              Flask 应用与全部路由
├── auth.py             登录、会话、角色与班级守卫
├── db.py               SQLite 连接与路径配置
├── schema.sql          建表语句
├── init_db.py          幂等建库 + 预置账号与材料
├── chunking.py         切分策略与二元组化(纯函数,无外部依赖)
├── services.py         外部服务客户端:Qdrant + 嵌入网关 + 对话网关
├── retrieval.py        入库索引、两路召回、RRF 融合、问答
├── materials.py        上传落盘与入库
├── check_index.py      索引一致性断言(可当命令行工具跑)
├── templates/          Jinja2 模板(含 search.html)
├── static/style.css
└── tests/              单元测试(标准库 unittest)

未新增任何 pip 依赖:Qdrant 与两个网关一律用标准库 urllib.request 通信, requirements.txt 仍只有 Flask / argon2-cffi / python-dotenv。

快速开始

Docker Compose(推荐)

cd campusclaw-mygroup
cp .env.example .env      # 然后填 SECRET_KEY(必填)
docker compose up --build

本机直跑(不用容器)

cd campusclaw-mygroup
python -m venv .venv
.venv/Scripts/python.exe -m pip install -r requirements.txt   # Windows
.venv/Scripts/python.exe init_db.py
.venv/Scripts/python.exe app.py                                # 默认 :5000

直跑时若想连本机 Qdrant,把 .env 里的 QDRANT_URL 改成 http://127.0.0.1:6333。

环境变量

变量 必填 说明
SECRET_KEY ✅ 会话签名密钥。缺失时应用直接拒绝启动
QDRANT_URL — 向量库地址,默认 http://qdrant:6333
EMBEDDING_BASE_URL — 嵌入网关(OpenAI 兼容 POST /v1/embeddings)
EMBEDDING_API_KEY — 嵌入网关密钥
EMBEDDING_MODEL — 嵌入模型名
EMBEDDING_DIM — 嵌入维度,须与模型实际输出一致
DIALOG_BASE_URL — 对话网关(OpenAI 兼容 POST /v1/chat/completions)
DIALOG_API_KEY — 对话网关密钥
DIALOG_MODEL — 对话模型名

四项 EMBEDDING_* 全齐才启用向量检索;缺任意一项时应用照常启动,只在启动日志里 给出一条明确告警。密钥只从环境变量读,不入库、不写日志、不进响应体。

生成一个 SECRET_KEY:

python -c "import secrets; print(secrets.token_urlsafe(48))"

预置账号

init_db.py 只在库为空时写入,内置两个班的师生账号(库里只存 argon2 哈希):

用户名 密码 角色 班级
teacher3 teacher3-pass 教师 高一(3)班
student3 student3-pass 学生 高一(3)班
teacher4 teacher4-pass 教师 高一(4)班
student4 student4-pass 学生 高一(4)班

预置材料:3 班《向量检索讲解》、4 班《生物实验记录》——用来演示班级隔离。

接口

方法 路径 说明
GET /health 健康检查,返回 200
GET/POST /login · POST /logout 会话
GET/POST /materials 材料列表 / 上传(教师,≤ 16 MB)
GET /materials/<id> 按班下载材料,跨班 404
GET /search 检索页面(原生 JS,无任何 CDN 依赖)
POST /api/search 检索,body 带 query 与可选 mode
POST /api/ask 就材料提问,返回带 [1] 标注的回答与出处列表
POST /api/materials/<id>/reindex 教师专属,按请求指定策略重建索引

检索请求示例:

curl -s -b cookies.txt http://127.0.0.1:8000/api/search \
  -H "Content-Type: application/json" \
  -d '{"query": "余弦相似度阈值", "mode": "keyword"}'

状态码语义:非法模式或空查询 400;向量库不可用时 vector / hybrid 返回 503 (且响应里不含任何编造的相似度数值),而 keyword 仍正常返回 200;未登录一律重定向到登录页。

测试

cd campusclaw-mygroup
.venv/Scripts/python.exe -X utf8 -m unittest discover

另有一个索引一致性检查工具:

.venv/Scripts/python.exe check_index.py

它校验「切片的 class_id 与所属条目一致」「FTS 表与切片表行数一致且 rowid 一一对应」等断言, 故意制造脱节数据时会以非零码退出。

⚠️ 一个必须知道的依赖

整条检索链——包括看起来纯本地的 keyword 一路——都依赖一个可用的 EMBEDDING_API_KEY。

原因是索引就绪门槛:两路检索共同只认 index_status = 'ready' 的切片,而只有嵌入调用成功 才会把切片置为 ready。所以没有密钥时,上传产生的切片**全部为 failed**,此时用材料正文里 确有的词做关键字检索,也会返回「资料中未找到相关内容」。

这是课程规定的语义,不是实现缺陷。本项目刻意不做本地嵌入兜底——那样会让「向量检索」 在演示时看起来是对的,实际并没有调用任何嵌入模型。配上密钥后,重启应用会自动为缺少索引的 材料补建索引(retrieval.backfill())。

验证状态(如实说明)

已在开发机上验证:

  • 63 项单元测试全部通过(unittest,覆盖切分、网关客户端、向量库客户端、检索融合、 问答、接口层与索引一致性)
  • 对真实运行进程的 30 项 HTTP 验证全部通过(登录、上传、四类状态码、班级隔离、 摘录字段、503 降级、转义与注入防护等)

尚未验证(受外部条件限制,不是代码问题):

  • 一切需要真实 Qdrant 的环节——本机 Docker 守护进程未运行,无法拉起容器
  • 嵌入与对话的正向路径——拿不到课程网关的密钥
  • 浏览器人工点检(提交检索、点击定位原文)

因此上面「快速开始」里的容器化演示步骤未经端到端验证,请以实际运行为准。

关于
221.0 KB
邀请码
    Gitlink(确实开源)
  • 加入我们
  • 官网邮箱:gitlink@ccf.org.cn
  • QQ群
  • QQ群
  • 公众号
  • 公众号

版权所有:中国计算机学会技术支持:开源发展技术委员会
京ICP备13000930号-9 京公网安备 11010802047560号