从通用到专属:文迹(WenJi)引入 RAG 向量库的技术复盘
承接
https://blog.csdn.net/anonymous_zb/article/details/159006754?spm=1001.2014.3001.5502继续往下构思项目仓库:
https://github.com/bingege-0729/WenJi
1. 引言
上篇引入 Redis 中间件结合 MySQL 通过序列化与反序列化解决存储内存存储用户对话等个人信息的各项问题
但是在测试、使用项目过程中发现
单纯依靠大语言模型存在一定的局限性:
- 知识滞后:无法回答最新的历史知识,模型训练截止日期之后的信息一无所知
- AI 幻觉 :面对提出的非遗细节问题,模型
一本正经乱说,比如把景泰蓝的掐丝工艺说成是"用铁丝缠绕" - 数据黑盒:我们准备了一批高质量的非遗文档资料,却无法让模型"学习"这些私有知识
为了解决这些问题,在项目中正式引入 检索增强生成(RAG) 技术,并基于 Redis 构建向量知识库,让 AI 导游「智游」从"能聊天的机器人"进化为"懂文化的业务专家"。
2. 什么是 RAG?为什么要选它
通俗理解:开卷考试(翻书学习)
想象一下考试场景:
- 不开卷(纯 LLM):全靠脑子里的知识答题,遇到没学过的就瞎编 → 产生幻觉
- 开卷(RAG):允许带一本参考资料进考场,先翻书找到相关内容再作答 → 有据可依
核心价值:
| 特性 | 说明 | 文迹项目中的体现 |
|---|---|---|
| 回答准确 | 回答基于检索到的真实文档,降低幻觉 | 非遗工艺参数、历史年代有据可查 |
| 数据实时 | 更新知识库不需要重新训练模型,即传即用 | 新增非遗条目后立即可被检索到 |
| 私有安全 | 数据存储在自己的向量库中,不用于模型训练 | 非遗资料不外泄给第三方 |
在项目中可以通过外挂知识库实现最新非遗知识的问答,降低大模型的幻觉,提高准确率。
3. 如何落实项目
(1)知识库的构建
数据加载
文迹项目中支持多种方式将非遗知识导入向量库:
java
// 单条入库
POST /admin/rag/ingest?content=xxx&title=景泰蓝制作技艺&source=国家级非遗名录
// 批量入库(预置示例数据)
POST /admin/rag/batch-ingest
批量入库时我们预置了 3 条典型非遗知识作为示例:
java
// RagKnowledgeController.java - batchIngest()
List<DocumentInput> documents = Arrays.asList(
new DocumentInput(
"景泰蓝,又称'铜胎掐丝珐琅',是一种在铜质的胎型上,用柔软的扁铜丝...",
"景泰蓝制作技艺", "国家级非物质文化遗产名录"
),
new DocumentInput(
"青花瓷,又称白地青花瓷...钴料烧成后呈蓝色,具有着色力强、发色鲜艳...",
"青花瓷烧制技艺", "国家级非物质文化遗产名录"
),
new DocumentInput(
"京剧,曾称平剧...角色分为生、旦、净、丑四种行当...",
"京剧", "联合国教科文组织人类非物质文化遗产"
)
);
文本分块(Chunking)
考虑到文本切割会对语义有一定的影响,我们在当前阶段选择了按文档粒度分块------即一条非遗知识作为一个 Chunk。这个选择基于以下考量:
| 分块策略 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 固定字符切分(如 512 字) | 实现简单,均匀分布 | 可能切断语义边界 | 通用长文本 |
| 段落/文档级(✅ 我们的选择) | 语义完整,上下文保留好 | 块较大可能引入噪声 | 短文档、结构化知识 |
| 递归分割 | 智能识别边界 | 实现复杂 | 混合格式文档 |
对于文迹项目,每条非遗知识本身就在 100~500 字之间,作为单个 Chunk 既不会太大导致检索噪声过多,也不会太小丢失关键信息。后续如果需要导入长篇 PDF 文档,会考虑升级为递归分割 + 重叠窗口的策略。
向量化(Embedding)
调用 Embedding 模型将文本转化为高维向量,这是整个 RAG 的基石:
java
// RagEnhancedChatService.java - ingestDocument()
TextSegment segment = TextSegment.from(content, metadata);
Embedding embedding = embeddingModel.embed(segment).content();
String id = embeddingStore.add(embedding, segment);
技术选型:阿里云 text-embedding-v3
yaml
# application.yml
langchain4j:
open-ai:
embedding-model:
base-url: https://dashscope.aliyuncs.com/compatible-mode/v1
api-key: ${QWEN_API_KEY}
model-name: text-embedding-v3
dimension: 1024 # 1024 维向量空间
为什么个人选 1024 维?一开始没有概念,AI补了2048,后面进一步了解综合选择1024,这是一个精度与成本的平衡点:
| 维度 | 语义区分能力 | 存储成本(单条) | 适用场景 |
|---|---|---|---|
| 256 | 较弱 | ~1KB | 简单分类 |
| 768 | 中等 | ~3KB | 通用场景 |
| 1024 ✅ | 强 | ~4KB | 中文文化领域(同义词多、语义细腻) |
| 1536 | 很强 | ~6KB | 高精需求 |
再高的维度针对我的个人项目而言存储成本上升更多,没有必要
(2)向量数据的选型与存储
为什么选 Redis 而非专用向量数据库?
这是项目中一个重要的架构决策:
| 方案 | 优点 | 缺点 | 选择理由 |
|---|---|---|---|
| Milvus / Qdrant | 专为向量设计,性能极致 | 需要额外部署新组件,运维成本高 | ❌ 团队资源有限 |
| PgVector | 复用 MySQL 生态 | 需要安装扩展,向量索引维护复杂 | ❌ 不想耦合数据库 |
| Redis (HNSW) ✅ | 已有 Redis 基础设施,零新增依赖 | 大规模(百万级)性能略逊 | ✅ 项目知识量可控,复用现有设施 |
文迹项目的非遗知识预计在几百到几千条级别,Redis 的 HNSW 索引完全够用,而且不用多维护一套基础设施。还有一部分是确实只了解REDIS的向量数据库架构
存储结构
Redis 中使用独立的 DB(DB4)隔离向量数据,与业务缓存(DB3)互不干扰:
Redis DB3(业务缓存)
├── captcha:{uuid} → 验证码
├── user:login:{userId} → 用户登录 Token
├── lock:blog:{userId} → 分布式锁
└── chat:history:{chatId} → AI 对话记忆(List 结构)
Redis DB4(向量存储) ← LangChain4j community-redis 自动管理
├── {vector_hash} → HNSW 索引 + 向量数据 + 元数据
│ ├── vector: [0.123, -0.456, ...] (1024 维 float)
│ ├── metadata: {"title": "景泰蓝", "source": "国家级非遗"}
│ └── payload: "景泰蓝,又称铜胎掐丝珐琅..."
└── ...
配置如下:
yaml
# application.yml
langchain4j:
community:
redis:
host: localhost
port: 6379
password: ""
database: 4 # 独立 DB,和业务缓存 (db=3) 隔离
LangChain4j 的 community-redis-spring-boot-starter 会自动完成向量的索引创建、存储和检索,开发者只需注入 RedisEmbeddingStore Bean 即可使用。
(3)检索与生成
这是 RAG 最核心的部分,完整链路如下:
语义检索
用户提问 → 向量化 → 相似度搜索 → 召回相关知识片段
java
// RagEnhancedChatService.java - chatWithRAG()
public Flux<String> chatWithRAG(String chatId, String question) {
// Step 1: 向量化用户问题
Embedding queryEmbedding = embeddingModel.embed(question).content();
// Step 2: 在 EmbeddingStore 中检索相关知识
EmbeddingSearchRequest searchRequest = EmbeddingSearchRequest.builder()
.queryEmbedding(queryEmbedding)
.maxResults(5) // 召回 Top-5 条
.minScore(MIN_SIMILARITY_THRESHOLD) // 最低相似度 0.6
.build();
EmbeddingSearchResult<TextSegment> searchResult = embeddingStore.search(searchRequest);
List<Content> contents = searchResult.matches().stream()
.map(match -> Content.from(match.embedded()))
.toList();
}
检索参数的设计逻辑:
| 参数 | 值 | 设计依据 |
|---|---|---|
maxResults |
5 | 太少可能遗漏关键信息,太多会增加 Prompt 长度和噪声 |
minScore |
0.6 | 余弦相似度 60% 为阈值,过滤掉不相关的结果 |
HIGH_SIMILARITY |
0.85 | 超过此值视为"高度相关",Prompt 中提示 LLM 优先采信 |
提示词增强(Augmented Prompt)
检索到的知识不是直接丢给 LLM,而是经过精心组装的增强 Prompt:
java
// RagEnhancedChatService.java - buildAugmentedPrompt()
private String buildAugmentedPrompt(String question, String context, double maxSimilarity) {
// 根据最高相似度动态调整提示语
String sourceHint = maxSimilarity > HIGH_SIMILARITY_THRESHOLD
? "以下参考资料与你的问题**高度相关**,请优先基于这些资料回答:"
: "以下参考资料可能与你的问题相关,请参考并结合你的知识回答:";
return String.format("""
%s
【参考资料】
%s
【回答要求】
1. 如果参考资料足以回答问题,请基于参考资料回答
2. 如果参考资料不足,可以补充你的通用知识
3. 回答要简洁明了,符合导游"智游"的风格
4. 可以适当引用资料来源(如"根据相关资料...")
【用户问题】
%s
""", sourceHint, context, question);
}
这里有一个关键的工程设计 ------相似度分级提示:
相似度 > 0.85 → "高度相关,优先采信" → LLM 会更倾向于引用资料
相似度 0.6~0.85 → "相关,参考使用" → LLM 结合资料+自身知识综合判断
这避免了两个极端:既不会盲目信任低质量检索结果,也不会浪费高质量的知识。
流式生成
最终交给通义千问生成流式响应:
java
return consultantService.chatStream(chatId, augmentedPrompt)
.doOnComplete(() -> log.info("RAG 聊天完成,总耗时: {}ms", totalTime))
.onErrorResume(error -> {
log.error("RAG 聊天失败,降级为普通聊天", error);
return consultantService.chatStream(chatId, question); // 降级兜底
});
完整的 RAG 执行流程图:
markdown
用户提问:"景泰蓝的制作流程是怎样的?"
│
▼
┌─────────────────────────────────┐
│ Step 1: 向量化 │
│ text-embedding-v3 │
│ "景泰蓝的制作流程是怎样的?" │
│ ↓ │
│ [0.12, -0.34, 0.56, ...] │ ← 1024维向量
└──────────────┬──────────────────┘
│
▼
┌─────────────────────────────────┐
│ Step 2: Redis HNSW 相似度搜索 │
│ │
│ Top-5 结果: │
│ ┌──────────┬────────┐ │
│ │ 景泰蓝制作 │ 0.92 │ ← 高度相关│
│ │ 掐丝珐琅 │ 0.78 │ │
│ │ 铜胎工艺 │ 0.65 │ │
│ │ 青花瓷烧制 │ 0.42 │ ← 过滤掉 │
│ │ 京剧脸谱 │ 0.31 │ ← 过滤掉 │
│ └──────────┴────────┘ │
│ minScore = 0.6 过滤后剩 3 条 │
└──────────────┬──────────────────┘
│
▼
┌─────────────────────────────────┐
│ Step 3: 组装增强 Prompt │
│ │
│ "以下参考资料与你的问题高度相关, │
│ 请优先基于这些资料回答: │
│ │
│ 【参考资料】 │
│ 景泰蓝,又称'铜胎掐丝珐琅'... │
│ │
│ 【用户问题】 │
│ 景泰蓝的制作流程是怎样的?" │
└──────────────┬──────────────────┘
│
▼
┌─────────────────────────────────┐
│ Step 4: 通义千问流式生成 │
│ qwen-plus │
│ ↓ │
│ Flux<String> SSE 流式输出 │
│ data: {"content":"景泰蓝"} │
│ data: {"content":"的制作"} │
│ data: {"content":"流程分为..."}│
│ data: [DONE] │
└─────────────────────────────────┘
降级兜底机制
RAG 不是万能的,必须考虑检索失败的情况:
改完后我觉得出现网络异常情况会导致多次检索,出现垃圾检索,于是在写完后规划降级策略
java
try {
// 正常 RAG 流程...
// 判断是否检索到足够相关的知识
if (contents.isEmpty()) {
log.info("未检索到相关知识,降级为普通 LLM 回答");
return consultantService.chatStream(chatId, question); // ← 降级路径 1
}
return consultantService.chatStream(chatId, augmentedPrompt);
} catch (Exception e) {
log.error("RAG 处理异常,降级为普通聊天", e);
return consultantService.chatStream(chatId, question); // ← 降级路径 2
}
三层防护保证用户体验:
| 层级 | 触发条件 | 处理方式 |
|---|---|---|
| 无结果降级 | contents.isEmpty() | 直接走纯 LLM |
| 异常降级 | try-catch 捕获异常 | 降级为纯 LLM |
| LLM 异常降级 | onErrorResume | 再次尝试纯 LLM |
核心原则:宁可回答不够精准,也不能让用户面对报错页面。
4. 效果对比
有 RAG vs 无 RAG
| 场景 | 无 RAG (纯 LLM) | 有 RAG (文迹新版) |
|---|---|---|
| 非遗知识提问 | "景泰蓝是一种传统的金属工艺品..."(泛泛而谈,可能编造工艺细节) | "根据国家级非遗名录资料,景泰蓝正名'铜胎掐丝珐琅',制作需经制胎、掐丝、点蓝、烧蓝、磨光、镀金六道工序..."(精确引用知识库内容) |
| 时效性提问 | (可能编造或回答旧数据) | (精准引用最新上传的文档内容) |
| 私有知识提问 | "我不知道您提到的具体非遗条目。" | "根据知识库中《景泰蓝制作技艺》的记载..." |
| 回答可信度 | 容易产生幻觉,无法验证 | 答案有据可依,标注来源 |
实际对话示例
无 RAG 时:
用户:景泰蓝的"点蓝"工序具体怎么做?
AI: 点蓝是将颜料填入掐好的花纹中,通常使用矿物颜料...
(泛泛而谈,没有具体的操作细节)
有 RAG 后:
用户:景泰蓝的"点蓝"工序具体怎么做?
AI: 根据非遗资料记载,点蓝是景泰蓝制作的核心工序之一:
用特制的吸管(俗称"蓝枪")将珐琅釉料填入铜丝纹饰的空格内。
釉料需填至与铜丝平齐,厚薄均匀,一般要填二到三次才能达到要求,
每次填充后还需进行烧制固色。这道工序直接决定了成品的光泽度与色彩饱满度。
(有具体工具名称、操作步骤、质量标准)
5. 踩坑与优化
坑 1:langchain4j.community.redis 配置格式错误
现象 :启动报错 Redis host is empty
原因 :最初配置时多写了一层 embedding-store:
yaml
# ❌ 错误配置(多了 embedding-store 一层)
langchain4j:
community:
redis:
embedding-store: # ← 这一层多余!
host: localhost
port: 6379
# ✅ 正确配置
langchain4j:
community:
redis:
host: localhost # ← 直接在这里配置
port: 6379
根因分析 :LangChain4j 的 community-redis-spring-boot-starter 使用 Spring Boot 的 @ConfigurationProperties 绑定配置,期望的前缀是 langchain4j.community.redis.*,而不是 langchain4j.community.redis.embedding-store.*。多了一层导致属性绑定失败,host 字段为 null。
教训 :引入新的 Spring Boot Starter 时,一定要先查阅官方文档或源码中的 @ConfigurationProperties 定义,确认配置前缀的正确格式。
坑 2:相似度阈值的调优博弈
现象:初期 minScore 设为 0.8,导致大量有效知识被过滤;降到 0.3 后又引入了大量噪声。
解决过程:
| minScore | 召回率 | 精准率 | 问题 |
|---|---|---|---|
| 0.8 | 低(漏掉很多) | 高 | 用户问"景泰蓝",只召回标题完全匹配的 |
| 0.5 | 中 | 中偏下 | 偶尔召回不太相关的内容 |
| 0.6 ✅ | 较高 | 较好 | 平衡点 |
| 0.3 | 很高 | 低 | 噪声严重,干扰 LLM 判断 |
最终选定 minScore = 0.6 作为最低阈值,同时引入 HIGH_SIMILARITY = 0.85 作为高分阈值,在 Prompt 中做分级提示。这种双阈值 + 动态提示的策略比单一阈值效果更好。
坑 3:Redis ChatMemoryStore 的写入竞态
现象:极端情况下,同一 chatId 的并发请求可能导致消息顺序错乱或丢失。
代码回顾:
java
// RedisChatMemoryStore.java - updateMessages()
public void updateMessages(Object memoryId, List<ChatMessage> messages) {
redisTemplate.delete(key); // 先删
redisTemplate.opsForValue().rightPushAll(key, jsonList); // 再写
}
风险窗口:delete 和 rightPushAll 之间不是原子操作,如果此时有另一个线程同时 getMessages,可能读到空列表。
当前缓解措施:
- 同一 chatId 的请求通常是串行的(用户逐条发送)
- 即使出现不一致,下次 updateMessages 会覆盖修正
未来优化方向(如果并发量增大):
考虑使用Redisson实现分布式锁
lua
-- 使用 Lua 脚本保证原子性
local key = KEYS[1]
local messages = ARGV
redis.call('DEL', key)
for i = 1, #messages do
redis.call('RPUSH', key, messages[i])
end
redis.call('EXPIRE', key, TTL)
进阶思考:如果继续优化,还能做什么?
目前的话我可能会:
进一步提高匹配率,同时靠Langchain4j的Tools决定调用几次检索知识功能。
除了检索知识问答,我还想提供像历史文物知识问答的功能,进一步提高用户留存率
。。。
| 优化方向 | 方案 | 预期收益 |
|---|---|---|
| 混合检索 | 关键词检索(BM25)+ 向量检索融合 | 解决专有名词(如"掐丝珐琅")向量匹配不佳的问题 |
| 重排序(Rerank) | 召回 Top-20 后用 Cross-Encoder 精排 | 提升排序准确性,减少噪声进入 Prompt |
| 查询改写 | 将用户口语化问题改写为更适合检索的形式 | 提高召回率,尤其是复杂问句 |
| 多模态 RAG | 支持图片检索(文物照片 → 向量) | 用户拍照即可触发相关知识讲解 |
| Agent 代理 | 让 AI 自主决定是否需要检索、检索几次 | 更灵活的知识获取策略 |
6. 总结与展望
技术架构全景
经过 RAG 改造后,文迹项目的 AI 引擎形成了清晰的分层架构:
┌─────────────────────────────────────────────────┐
│ ChatController (SSE 出口) │
└──────────────────────┬──────────────────────────┘
│
┌──────────────────────▼──────────────────────────┐
│ RagEnhancedChatService (RAG 编排层) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 向量化 │ → │ 语义检索 │ → │Prompt 增强│ │
│ │ Embedding│ │ Top-5 │ │ 分级提示 │ │
│ └──────────┘ └──────────┘ └─────┬────┘ │
│ │ │
│ ┌──────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ ConsultantService (@AiService) │ │
│ │ 通义千问 qwen-plus (Flux<String>) │ │
│ └─────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Redis DB4│ │ Redis DB3│ │ 阿里云千问│
│ 向量存储 │ │ 对话记忆 │ │ LLM API │
└──────────┘ └──────────┘ └──────────┘
总结
RAG 的加入让文迹项目从一个"聊天机器人"进化为了一个真正的问答助理:
- 从幻觉到可信:每个非遗相关回答都有知识库来源支撑
- 从通用到专属:通过导入自定义知识库,AI 懂得了文迹特有的文化遗产领域
- 从脆弱到鲁棒:多层降级机制确保任何情况下用户都能得到响应
下一步计划
| 阶段 | 目标 | 具体工作 |
|---|---|---|
| 近期 | 知识库扩容 | 导入更多非遗文档(PDF/TXT),完善批量导入接口 |
| 中期 | 检索优化 | 引入 BM25 混合检索 + Rerank 重排序,提升召回质量 |
| 长期 | 多模态 Agent | 支持图片/语音输入的 RAG,结合 Agent 让 AI 自主规划检索策略 |