NJUPT柚子助手发展历程

NJUPTer 项目发展历程

第一章:初心 —— 一次选课失误的教训

那是大二下学期的事了。我当时正准备选下学期的课,看着培养方案上密密麻麻的课程列表,觉得"计算机科学导论"应该是一门基础课,随便选就行了。

结果开学才发现 —— 我选错了!那门课是给非专业的通识课,不是我要的专业必修课。更尴尬的是,这门课的时间和我真正需要选的"数据结构"冲突了。

我去问辅导员,辅导员说:"你自己去培养方案里看清楚啊,上面都写着呢。"

我去翻培养方案 PDF —— 100 多页的文档,全是密密麻麻的文字,手动翻找简直是折磨。用 Ctrl+F 搜"数据结构",搜到了 10 处结果,每次都要跳过去看上下文,看得眼睛都花了。

当时我就想:为什么不能有一个东西,只要我问"数据结构课什么时候上、多少学分",它就能直接告诉我答案?

这就是做这个项目的初心 —— 把培养方案"喂"给 AI,让它替我从几十页的文档里找答案。


第二章:初版 —— 一切从简单开始

一开始,我啥也不懂,只会最简单的 Spring Boot。

第一版架构:

用户提问

向量检索(只用向量相似度)

取 Top-3 文档片段

喂给 LLM → 回答

用了什么:

  • Spring AI(刚出不久,还在 SNAPSHOT 版本)
  • DeepSeek V3 做聊天
  • BGE-M3 做向量化
  • PostgreSQL + PgVector 存向量

结果:能用,但不够好。


第三章:第一次危机 —— 搜索效果太差

问题表现

用户问:"CS101 这门课的学分是多少?"

我期望的答案:"CS101 计算机科学导论,3 学分"

实际得到的答案:"课程学分从 1 到 4 学分不等,具体看课程安排..."

我查了日志,发现检索回来的文档片段是:
"本专业要求完成总学分 150 分,其中必修课 120 分,选修课 30 分。
必修课程包括计算机科学导论、数据结构、操作系统等..."

问题来了:这个片段里有"计算机科学导论",但没写"CS101"这个编号,也没写具体学分!

更严重的问题

用户问:"保研政策编号 X-2023-001 的内容是什么?"

向量检索返回的片段:
"保研需要 GPA 达到 3.5 以上,无挂科记录..."

完全不匹配! 向量检索觉得"保研政策编号"和"保研需要 GPA"语义相近,但实际上用户问的是一个精确的编号,不是泛泛的保研要求。

根本原因分析

我研究了很久,发现向量检索的本质缺陷:

┌───────────────────────┬──────────────┬───────────────────────────────────┐
│ 查询类型 │ 向量检索表现 │ 问题 │
├───────────────────────┼──────────────┼───────────────────────────────────┤
│ "保研需要什么条件?" │ ✅ 好 │ 语义匹配准确 │
├───────────────────────┼──────────────┼───────────────────────────────────┤
│ "CS101 课程的学分?" │ ⚠️ 一般 │ "CS101"是专有名词,向量可能不认识 │
├───────────────────────┼──────────────┼───────────────────────────────────┤
│ "政策编号 X-2023-001" │ ❌ 差 │ 编号无语义,向量检索失效 │
└───────────────────────┴──────────────┴───────────────────────────────────┘

核心问题:向量检索擅长"语义理解",但不擅长"精确匹配"。

  • "CS101" 对向量模型来说,就是个随机的字符串
  • "X-2023-001" 更是如此,模型根本不知道这代表什么

第四章:第一次优化 —— 加入关键词检索

我查了很多资料,发现业界都在用混合检索(Hybrid Search)。

混合检索原理

用户问题

├─ 向量检索(语义相似度)
└─ 关键词检索(精确匹配)

RRF 算法融合

Top-K 结果

我怎么做?

  1. 加 PostgreSQL 全文检索

-- 添加 tsvector 列
ALTER TABLE vector_store ADD COLUMN tsvector_content tsvector;

-- 创建 GIN 索引(倒排索引)
CREATE INDEX idx_vector_store_tsvector ON vector_store
USING GIN (tsvector_content);

  1. 写 HybridSearchService

public List hybridSearch(String query) {
// 向量检索
List vectorResults = vectorStore.similaritySearch(query);

  // 全文检索
  List<Document> fullTextResults = fullTextSearch(query);

  // RRF 融合
  return reciprocalRankFusion(vectorResults, fullTextResults);

}

  1. 用 RRF 算法合并结果

// 公式:score = sum(1 / (k + rank))
// k = 60

效果对比

用户问:"CS101 课程的学分?"

┌────────────┬─────────────────────────────────────────────────┐
│ 方法 │ 返回结果 │
├────────────┼─────────────────────────────────────────────────┤
│ 纯向量检索 │ "本专业要求完成总学分 150 分..."(不相关) │
├────────────┼─────────────────────────────────────────────────┤
│ 纯全文检索 │ "CS101 计算机科学导论,3 学分..."(精确匹配)✅ │
├────────────┼─────────────────────────────────────────────────┤
│ 混合检索 │ "CS101 计算机科学导论,3 学分..."(排名第一)✅ │
└────────────┴─────────────────────────────────────────────────┘

问题解决了 80%!


第五章:第二次危机 —— 中文切分有问题

问题表现

用户问:"保研流程的第 3 步是什么?"

我得到答案:"保研需要 GPA 达到 3.5 以上..."

查日志,检索回来的片段:
"保研需要 GPA 达到 3.5 以上,无挂科记录。满足条件的同学..."

这完全不对! 用户问的是"流程",不是"条件"。

原因分析

我把检索回来的文档片段打印出来看,发现:

片段 1: "保研需要 GPA 达到 3.5 以上,无挂科记录。满足条件的同学可以申请..."
片段 2: "推荐免试研究生流程如下:\n\n第一步:提交申请材料\n\n第二步:审核材料\n\n第三步:公示名单"

问题来了:用户问"第 3 步",应该是匹配片段 2,但为什么检索觉得片段 1 相关?

我用 embedding 模型测了一下相似度:

  • 查询"保研流程的第 3 步" vs 片段 1:0.75(居然很高!)
  • 查询"保研流程的第 3 步" vs 片段 2:0.68(反而低!)

为什么?

后来我明白了 —— 片段 2 太长了! 里面既有"第一步",也有"第二步",还有"第三步",信息太杂乱,向量模型被"稀释"了。

而片段 1 虽然内容不对,但它的主题更"纯粹"(只讲条件),向量反而更"聚焦"。

解决方案:优化文本切分

  1. 原来是这样切的:
    // 固定长度切分,500 字符一块
    String[] chunks = text.splitEvery(500);

  2. 改成按语义边界切分:
    // 优先级:段落 > 行 > 句子 > 逗号
    separators = ["\n\n", "\n", "。", ",", " "];

  3. 加上重叠:
    // 每个块和下一个块重叠 50 字符
    chunkOverlap = 50;

效果

重新切分后:
片段 1: "保研需要 GPA 达到 3.5 以上,无挂科记录。"
片段 2: "推荐免试研究生流程如下:\n\n第一步:提交申请材料\n\n第二步:审核材料"
片段 3: "第二步:审核材料\n\n第三步:公示名单" // 重叠部分

现在检索"第 3 步",能精准返回片段 3 了!


第六章:第三次危机 —— 用户的口语化问题

问题表现

用户问:"我想知道怎么保研?"

检索结果:很差。

查日志,发现检索用的是用户的原话"我想知道怎么保研?",这个查询太口语化,向量模型找不到精确匹配。

解决方案:查询改写

我加了一个 QueryRewriteService,先改写查询,再检索:

// 原始查询
String originalQuery = "我想知道怎么保研?";

// 改写后
String rewrittenQuery = queryRewriteService.rewriteQuery(originalQuery);
// 结果:"保研的条件和流程是什么?"

// 用改写后的查询检索
List docs = hybridSearchService.hybridSearch(rewrittenQuery);

效果

┌───────────────────────┬────────────────────────────┬───────────┐
│ 原始查询 │ 改写后 │ 检索效果 │
├───────────────────────┼────────────────────────────┼───────────┤
│ "我想知道怎么保研?" │ "保研的条件和流程是什么?" │ ✅ 变好了 │
├───────────────────────┼────────────────────────────┼───────────┤
│ "那个 CS101 几学分?" │ "CS101 课程的学分是多少?" │ ✅ 变好了 │
└───────────────────────┴────────────────────────────┴───────────┘


第七章:现在还有哪些不足?

虽然项目能用起来了,但还有很多问题:

  1. 多文档检索效果不好

如果我有 3 个文档(保研政策、选课指南、培养方案),用户问"保研",应该只从保研政策里检索,但现在会从 3 个文档里都检索,噪声太多。

问题根源:没有实现元数据过滤。

改进方向:让用户可以选择"只从保研政策里搜索",或者在检索前自动判断问题属于哪个领域。


  1. 超长文档的检索有偏置

如果文档很长(比如 1000 个文本块),向量检索会有"位置偏置" —— 更倾向于检索前面的块,后面的块很难被检索到。

问题根源:向量索引的局限性。

改进方向:用 Maximal Marginal Relevance (MMR) 算法,增加结果的多样性。


  1. 没有答案来源追溯

用户得到答案后,不知道这个答案来自文档的哪一部分,无法验证答案的准确性。

改进方向:在每个答案后显示"来源:培养方案.pdf 第 5 页",让用户可以点进去查看原文。


  1. 中文分词还是不够好

全文检索用的 plainto_tsquery('simple', ?) 对中文支持有限,分词不够智能。

问题示例:

  • 用户搜"保送研究生"
  • 但文档里写的是"推荐免试研究生"
  • 匹配不上!

改进方向:接入 zhparser 或 jieba 分词库,实现真正的中文分词。


  1. 没有反馈机制

如果用户觉得答案不对,没有办法反馈,模型学不会。

改进方向:添加"点赞/点踩"功能,收集用户反馈,用反馈数据优化检索策略。


  1. 并发处理能力有限

如果多个用户同时提问,每次都要调用 DeepSeek API,响应会变慢。

改进方向:加缓存层,对相似问题复用答案;或者用 @Async 异步处理。


尾声:项目还在路上

从"避免选课失误"的一个小想法,到现在能用的 RAG 应用,这个项目经历了很多波折。

但我最满意的是 —— 现在自己选课时,会先问"柚子助手",再决定。

希望这个项目能帮到更多南邮同学,让大家不再像我当年一样,对着几十页的 PDF 发愁。

posted @ 2026-03-04 15:37  zkoko  阅读(2)  评论(0)    收藏  举报