一个专升本考生做的政策问答系统。从"指标很好看"到"用户说能用的地方不多",我花了三周才搞明白: 我一直在优化一个自己幻想出来的需求。
一、为什么做这个
我是 2027 届专升本考生,河南。备考时有个痛点:政策文件太散,报名时间、考试科目、免试条件、投档规则散在教育厅通知和几十所院校的招生章程里,用搜索引擎找不准,问同学也不权威。
所以我想做一个政策问答机器人:输入问题,返回答案 + 引用出处。
技术上选 RAG(检索增强生成):
- 语料:河南省教育考试院政策文件 + 院校招生章程,142 个文本块 / 8.4 万字
- 向量库:Qdrant + BGE-M3(1024 维)
- 生成:DeepSeek-V3
- 后端:FastAPI
- 部署:腾讯云香港,2 核 2G
二、三个坑,每个都让我推翻之前的结论
坑 1:三个 Qdrant 集合,导致我一周半的结论是错的
现象:第三周我做了个实验,结论是"rrf混合检索没用(query语义用向量更好),BM25 + 向量加起来不如只用向量"。数据摆在那:混合 47.1%,向量 47.1%,一样。我信了一周半。
转折:第四周做别的实验时顺手查了向量库:
bash
curl http://localhost:6333/collections
发现同时存在三个集合:
chunks:139 个点ls_rag_docs_clean:2403 个点ls_rag_docs_clean_200:200 个点
而我三个评估脚本,各指一个集合。
根因:我过去三周所有的"对比实验",比的其实是三个不同的知识库。
修复 :统一到 chunks(139 点),point_id 用 uuid5(NAMESPACE_URL, chunk_id),payload 存 chunk_id。
结论:同源验证不做,所有对比实验都是假的。
坑 2:id 空间不一致,RRF 一直在空转
现象:混合检索"看起来在跑",但效果始终等于单路。
排查 :打了日志,发现 BM25 返回的 id 是 17、16、89 这种整数,向量返回的是 e7061bb895c2 这种哈希。
根因:
python
# 错误代码
chunks[idx].get("chunk_id", idx)
我的字段名叫 id,这里写的却是 chunk_id,取不到就静默回落成数组下标。
所以 RRF 一直在融合两个完全不相交的 id 集合:同一个块,在 BM25 里叫 17,在向量里叫 e7061bb895c2,被当成两个不同实体。
修复 :强制只认 id/chunk_id/chunkId,缺失就抛异常,不回落下标。
结论 :静默兜底比崩溃更危险。
坑 3:30.5% 的评估要点,语料里根本没有
现象 :要点覆盖率一直上不去(38.3%),归因全是 miss_recall(检索没捞到)。
排查:我逐个要点去全语料里搜 4-gram,结果:
erlang
118 个非拒答要点中,36 个(30.5%)语料根本没有
也就是说,我一直在为不存在的答案扣分。
修复:分母从 128 改成 80(语料可支撑要点),4 道不可答题从分母排除。
结论 :"检索失败"和"知识库缺失"必须分开量化,否则会优化错方向。
三、修完坑,指标终于能看了
数据处理
| 阶段 | 块数 | 重复 | 噪声 |
|---|---|---|---|
| 原始 | 200 | 9 组重复 id | 15 条导航/新闻列表 |
| 清洗后 | 139 | 0 | 4.3% |
| 补语料后 | 142 | 0 | 4.3% |
清洗做了什么:
- 按 id 去重
- 噪声块过滤(导航、页脚、日期列表)
- 表格整表合并(11 个碎片 → 10 张整表)
- 分数线表碎片合并(40 个碎片 → 4 张整表,剥掉重复模板头)
检索效果(块级 recall@5)
| 配置 | recall@5 | 说明 |
|---|---|---|
| 向量单路(原始基线) | 19/33 = 57.6% | 起点 |
| + 元数据过滤 | 24/33 = 72.7% | 主力,+15.1pp |
| + 多路 RRF | 24/33 = 72.7% | 无增量 |
| + rerank | 26/33 = 78.8% | +6.1pp,但是延迟 5.5 倍 |
| BM25 单路 | 13/33 = 39.4% | --- |
| query 改写 | 25/33 = 75.8% | 与 rerank 能力重叠,无用 |
元数据过滤为什么有效 :142 块语料里 125 块是院校招生章程,模板句("录取分数线""录取规则")在纯语义检索下会挤占真正的政策块和分数线块。按 doc_type 路由后,policy 只剩 10 块、score 只剩 7 块,精度直接上去。
四、然后我收集了 20 条真实用户提问
这是整个项目最重要的转折点。
我在 QQ 群、贴吧、知乎、小红书、百度知道上,原话抄了 20 条真实考生的提问,然后逐条判断:我的系统能回答吗?
结果
| 判定 | 条数 | 占比 |
|---|---|---|
| 能答 | 1 | 5% |
| 部分可答 | 4 | 20% |
| 不能答 | 15 | 75% |
只有 1 条能答。
答不了的分为两类:
75% 是超出范围:
- "英语60出头冲100现实吗"(备考策略)
- "考点附近酒店什么时候订"(事务办理)
- "郑州哪里有自习室"(生活信息)
- "为了公办再战一年值不值"(个人决策)
剩下是语料缺失:
- "跨专业要交什么材料"(缺材料清单)
- "平行志愿会滑档吗"(缺退档细则)
- "护理学能填报哪些学校"(缺招生计划)
这才是最重要的发现
我的 33 道评估题,全是我按语料编的。
我当初的做法是:翻语料,看到有什么内容,就出一道对应的题。所以 题目天然都在语料覆盖范围内。
这个评估集测的是"我设计的题",不是"用户真正问的题"。
72.7% 这个数字,是系统性高估的。
还有一个细节很有意思:那唯一能答的 1 条,是"免试是不是不用考试"。
- BM25 答不上(0/1)------ 用户说"不用考试",原文写"综合考查",词面完全不重合
- 向量答得上(1/1)------ 语义能理解
而我的 20 条真实提问里,70% 是口语化的。这说明如果线上用纯 BM25,或者 BM25 权重太高,对真实用户基本失效。
五、所以我做了三件事
1. 建"拒答"指标
那 15 条答不了的问题,系统应该正确说"未找到相关信息",而不是编造。
建了 19 条拒答测试集(16 条真实提问 + 3 条原评估集),实测:
| 分组 | 结果 |
|---|---|
| 超范围(14 条) | 14/14 = 100% |
| 语料缺失(5 条) | 4/5 = 80% |
| 合计 | 18/19 = 94.7% |
"承认不知道"和"答对"同等重要。
2. 收窄产品定位
| 用户想知道的 | 占比 | 系统能做什么 |
|---|---|---|
| 政策/资格(报名、考试、投档、免试) | ~40% | ✅ 部分能答 |
| 备考策略 | ~25% | ❌ 不属于本产品 |
| 事务办理 | ~20% | ❌ 不属于本产品 |
| 院校体验 | ~15% | ❌ 不属于本产品 |
结论:这是政策问答机器人,不是备考助手。 超范围的问题就该拒答,并在页面上写清楚。
3. 把三组数字一起报
| 指标 | 数值 | 说明 |
|---|---|---|
| core(我编的题) | 24/33 = 72.7% | 基线 |
| user(真实提问) | 1/20 = 5% 可答率 | 真实场景 |
| 拒答准确率 | 18/19 = 94.7% | 边界能力 |
这三组放在一起,比单看 72.7% 诚实得多。
六、工程部分:3 个 API + 流式 + 部署
API 设计
bash
POST /chat { question, mode: "fast"|"high" } → { answer, sources[], ... }
POST /search { query, top_k, filters } → { hits[] }
GET /health → { status, chunks_count, same_source, ... }
POST /chat/stream → SSE 流式
关键设计:
- 启动时强制同源校验:Qdrant 点数 must == 文件块数,不一致返回 503(不是返回空结果)
- 每条结果带
in_corpus标记:便于排查 id 对不上的问题 - 两种模式:fast(过滤+向量,72.7%,¥9.86/千次)/ high(+rerank,78.8%,¥19.82/千次)
流式输出
用户反馈"等 6.7 秒以为卡死了",所以做了 SSE。
| 指标 | 非流式 | 流式 | 改善 |
|---|---|---|---|
| 首字延迟 | 6675ms | 1577ms | 4.2× |
| 来源展示 | 6675ms | 736ms | 9.1× |
事件设计:
csharp
event: meta → 来源 + 检索耗时(用户马上看到出处)
event: token → 逐字
event: done → 首字延迟 / 总耗时 / 字数
关键认知 :延迟的 90% 来自 LLM 生成,不是检索。所以优化方向是流式,不是优化检索。
部署
腾讯云香港轻量(免备案),Docker + Qdrant + systemd + Nginx。
踩的坑:
- 阿里云 1GiB 内存装 Docker 时 SSH 被 OOM 杀掉 2 次 → 换 2GiB
- 腾讯云默认
ubuntu用户,密钥配错了目录 → 用户和密钥必须对应
七、用真实用户测,又发现了新问题
找了 2 个专升本考生实测(一个计算机网络技术、一个护理)。
他们说
| 测试者 | 一句话评价 |
|---|---|
| T1 | "初窥门径" |
| T2 | "能用的地方不多" |
发现的问题(我完全没想到的)
🔴 材料N 编号悬空:
我的 API 返回了 sources[] 数组,字段齐全。但 LLM 在答案里写"见材料1",而来源卡片没有编号。
两个测试者独立提出:"不知道哪来的材料1"
教训:数据完整 ≠ 用户理解。 给结构化数据不加编号对应,等于给了半套方案。
🔴 "我能报什么专业"被答成分数线汇总:
T2 问"我能报考什么专业",系统答了"所有专业及其分数线"。
她说:"啊?不记得我是什么专业了么?"
根因 :没有加入记忆,用户要的是"专科专业 → 本科专业对照关系",另一方面我的语料里根本没有这张对照表。
🟠 其他问题:
- 拒答行为不一致(同类问题有时长篇解释有时直接拒答)
- 输入框发送后不清空
- 来源片段被截断(只显示 200 字)
修复(只改 UI,不碰 RAG)
7 项 UI 修复:来源加 [1][2] 编号、拒答加黄色标签、顶部加能力说明、输入框自动清空、片段 200→400 字 + 展开全文、标题居中、chunk_id 加悬停提示。
⚠️ 关键纪律:RAG 已经在 W6 冻结,不改检索逻辑。 因为这些 UI 问题改了算法,我所有的历史数字都会作废。
八、我学到的最重要三件事
1. 评估集是自己编的,测的是想象力不是需求
从 72.7% 到 5% 可答率,这个差距不是"算法不好",是需求理解错了。
采集真实数据的成本很低(一周,每天 5 分钟),但不做的代价很高。
2. "没报错但数据错了"是最难发现的问题
三个坑:
- 三个 Qdrant 集合(没报错,只是数字不对)
get("chunk_id", idx)静默回落(没报错,只是 id 不对)- PowerShell 自动加 UTF-8 BOM(没报错,只是块数少了 1)
这类 bug 比崩溃难查十倍。 防御方法:关键标识缺失就抛异常,绝不静默兜底。
3. 会说出"我不知道"比会说"我知道"更重要
94.7% 的拒答准确率,意味着系统在 19 个超范围问题里,18 个能正确说"未找到"。
在一个政策问答场景里,编造一个答案的代价,远高于说"我不知道"。
九、目前的状态
能用的:网站(额度小就不展示了)(流式问答,首字 1.6 秒)
指标:
- core(我编的题):24/33 = 72.7%
- user(真实提问):1/20 = 5% 可答率
- 拒答:18/19 = 94.7%
已知缺什么:
- 院校投档线("230分能上公办吗"答不了)
- 专业对照表("我能报什么专业"答不了)
- 招生计划("护理学能填哪些学校"答不了)
下一步:Agent(W8-W11),让系统能查手册、算分数、并行调用多个数据源。
十、给同样在做 RAG 的人三句话
- 先验证数据同源,再做对比实验------三个集合能让你一周半的结论作废
- 自己编的评估集不可信------去抄 20 条真实用户提问,你会看到一个完全不同的分数
- "我不知道"是个功能,不是 bug------尤其在对准确性要求高的场景
项目地址:GitHub ([github.com/frdomis/ls-...](https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Ffrdomis%2Fls-rag "https://github.com/frdomis/ls-rag")) 技术栈:Qdrant + BGE-M3 + DeepSeek-V3 + FastAPI + Docker + Nginx 数据来源:河南省教育考试院公开政策文件