上一篇聊了数据层和 Chunk 策略。
系统跑起来以后,真正的坑才刚开始。
这一篇重点讲检索链路的演进。
图解:约束过滤改变候选集合;增加 TopK 不能保证修复缺失的结构化条件。
用一条请求做追踪:用户问“万宁、预算有限、适合带小孩”,其中城市可成为硬过滤条件,“预算有限”需要金额或区间,“适合带小孩”通常需要可追溯的属性证据。不要把推断直接写成事实。记录过滤前后候选数,才能区分未召回、误过滤与排序错误。
动手核对: 构造城市缺失、城市冲突、没有符合条件结果三条用例。检查系统是否解释限制,而不是默默放宽用户条件。
五、第一个大坑:TopK 并不是越大越好
最开始我的想法非常自然:
TopK 越大,给模型的信息越多,应该越准确。
于是从:
TopK = 4
调整到:
TopK = 6
甚至尝试召回更多候选。
结果答案并没有稳定变好。
有时候反而更差。
原因其实很简单。
假设用户问:
万宁三天怎么玩?
召回结果可能是:
1. 石梅湾
2. 日月湾
3. 加井岛
4. 大洲岛
5. 南燕湾
6. 神州半岛
这些都相关。
问题是:
它们太相关了。
模型看到六个”值得去的景点”,很容易产生一种朴素逻辑:
都很好,那就都塞进去。
于是三天行程被生成成一天跑四五个地方。
这让我第一次清楚意识到:
RAG 的召回相关性和最终任务质量不是一回事。
旅游规划不是搜索引擎。
六、从 Semantic Search 变成 Constraint-aware Retrieval
后来我把检索链路逐渐改成:
用户问题
↓
Query Parsing
↓
城市过滤
↓
Metadata Filter
↓
BGE-M3 Semantic Retrieval
↓
时间 / 空间约束
↓
候选景点
↓
Context Builder
↓
LLM
比如用户指定:
city = 万宁
那第一件事就不是让 BGE-M3 在整个数据库里面找。
而是:
先缩小城市范围。
否则一个”海景不错、适合情侣”的查询,完全可能召回:
- 三亚;
- 厦门;
- 青岛;
- 北海;
的内容。
语义完全正确。
业务完全错误。
这也是我后来越来越认同的一句话:
能用数据库过滤解决的问题,不要全部交给 Embedding。
七、RAG 最大的敌人有时候不是幻觉,而是”语义正确”
比如:
想找适合老人、不要走太多路的景点。
Embedding 很可能召回:
“风景绝美,非常适合徒步爱好者……”
因为:
- 景点;
- 风景;
- 游玩;
- 户外;
这些语义高度相关。
但业务上却完全相反。
所以旅游 RAG 需要把很多信息结构化:
city
category
duration
suitable_for
activity_level
latitude
longitude
之后:
向量检索负责”像不像”。
结构化过滤负责”能不能”。
这两个概念差别非常大。
八、Context Builder 比我预想的更重要
构建上下文时要保留被筛选掉的原因。否则最终回答缺少某个景点,无法判断是没召回、预算不符、营业时间冲突,还是上下文被截断。把每个阶段的候选数和过滤理由留下来,比只保存最终 prompt 更容易排错。
一开始我把所有召回结果:
"\n\n".join(documents)
直接塞给模型。
后来发现非常粗暴。
于是增加 Context Builder。
它负责:
- 去掉重复景点;
- 保留实体名称;
- 控制 Token Budget;
- 保留来源 URL;
- 尽量留下游玩时长和空间信息;
- 对候选资料进行结构化。
例如最终上下文更接近:
[1]
景点:石梅湾
城市:万宁
类型:海湾
建议时长:3-4小时
特点:沙滩、摄影、情侣
来源:...
[2]
景点:日月湾
城市:万宁
类型:海湾、冲浪
建议时长:半天
特点:冲浪
来源:...
而不是几千字杂乱网页文本。
结果模型的规划稳定性明显比”原文拼接”更好。
下一篇聊模型选型和流式部署:MindTrip RAG 架构演进(三):模型选型与 SSE 流式部署
💬 评论
这套方案在哪一步卡住了?也欢迎补充你的实现方式或踩坑经验。