开发日志(十一):多模态菜单 RAG 系统实战
一、项目目标
在上一篇文章中,介绍了智能菜单助手的项目背景和 RAG 技术路线。
本篇重点介绍系统的具体开发过程。
项目最终需要实现以下完整链路:
Flutter 上传菜单图片
↓
FastAPI 接收图片
↓
Qwen 多模态模型解析菜单
↓
返回结构化菜品 JSON
↓
构造 LangChain Document
↓
Embedding 向量化
↓
写入 Chroma
↓
Flutter 结果页发起问题
↓
后端检索相关菜品
↓
融合用户偏好
↓
LLM 生成回答
↓
Flutter 展示回答
从功能表面来看,用户只是“上传一张图片,再问一个问题”;但在工程内部,这个过程跨越了多个模型、多个服务和多个数据结构。
二、后端模块划分
为了避免把所有逻辑都堆积在接口文件中,我将后端划分为不同职责的模块。
一个典型的目录结构如下:
backend/
├── main.py
├── services/
│ ├── menu_service.py
│ ├── vector_service.py
│ └── qa_service.py
├── models/
├── database/
└── chroma_db/
各模块职责如下:
| 模块 | 主要职责 |
|---|---|
main.py |
定义 API、校验参数、组织调用流程 |
menu_service.py |
调用多模态模型解析菜单 |
vector_service.py |
构造 Document、向量化、写入和检索 |
qa_service.py |
构造 Prompt、融合偏好、生成回答 |
chroma_db |
持久化存储向量数据 |
这种拆分可以减少模块之间的耦合,并方便独立排查模型、数据库或接口问题。
三、菜单处理接口改造
原系统使用的是旧上传接口:
/upload
为了让“菜单识别”和“自动入库”形成统一流程,我将客户端上传地址切换为:
/api/v1/menu/process
新的接口不再只负责保存图片,而是承担以下任务:
- 接收用户上传的菜单图片;
- 校验图片格式;
- 调用 Qwen 多模态模型;
- 解析模型返回内容;
- 标准化菜品字段;
- 将菜品写入向量数据库;
- 将识别结果返回 Flutter。
接口逻辑可以抽象为:
@app.post("/api/v1/menu/process")
async def process_menu(file: UploadFile):
image_bytes = await file.read()
menu_result = await menu_service.parse_menu(image_bytes)
normalized_items = normalize_menu_items(menu_result)
vector_service.add_menu_items(normalized_items)
return {
"success": True,
"items": normalized_items
}
这里最重要的一点是:
菜单识别成功后必须立即完成向量入库。
如果识别接口只返回菜品,但没有执行入库,就会出现一种典型问题:
Flutter 页面可以看到菜品
但用户提问时检索不到任何内容
这说明展示链路是通的,但 RAG 链路已经断裂。
四、使用多模态模型抽取结构化数据
1. 约束模型输出格式
多模态模型的自由输出具有不确定性,因此 Prompt 中必须明确要求返回 JSON。
示例:
请识别菜单图片中的所有菜品,并严格返回 JSON 数组。
每个菜品必须包含以下字段:
- name_original:菜单中的原始名称
- name_zh:中文名称
- description:菜品描述
- price:价格
- tags:菜品标签数组
无法识别的字段请使用空字符串或空数组。
不要输出 Markdown,不要输出额外解释。
理想结果如下:
[
{
"name_original": "Grilled Salmon",
"name_zh": "烤三文鱼",
"description": "Served with vegetables and lemon sauce",
"price": "$18.99",
"tags": ["海鲜", "主菜", "不辣"]
}
]
2. 对模型结果进行二次清洗
即使 Prompt 已经限制格式,实际返回内容仍可能出现:
- JSON 外包裹 Markdown 代码块;
- 字段名称不统一;
tags返回字符串而不是数组;- 价格包含不同货币符号;
- 某些字段缺失;
- JSON 尾部多余逗号;
- 模型输出额外说明文字。
因此,后端不能直接相信模型结果,而要执行标准化处理。
def normalize_item(item: dict) -> dict:
tags = item.get("tags", [])
if isinstance(tags, str):
tags = [tag.strip() for tag in tags.split(",") if tag.strip()]
return {
"name_original": str(item.get("name_original", "")).strip(),
"name_zh": str(item.get("name_zh", "")).strip(),
"description": str(item.get("description", "")).strip(),
"price": str(item.get("price", "")).strip(),
"tags": tags
}
这一步体现了 AI 工程与普通业务开发的区别:
大模型输出是概率性的,后端程序必须通过校验、清洗和默认值机制,把不稳定结果转换成稳定接口数据。
五、将菜品转换为 LangChain Document
识别得到的 JSON 适合前端展示,但不一定适合向量检索。
例如,原始数据可能是:
{
"name_original": "Mushroom Pasta",
"name_zh": "奶油蘑菇意面",
"description": "Creamy pasta with mushroom",
"price": "$13.99",
"tags": ["主食", "不辣", "素食"]
}
需要将其重新组织为语义完整的文本:
from langchain_core.documents import Document
def menu_item_to_document(item: dict, menu_id: str) -> Document:
content = f"""
菜品原名:{item.get('name_original', '')}
中文名称:{item.get('name_zh', '')}
菜品描述:{item.get('description', '')}
价格:{item.get('price', '')}
标签:{', '.join(item.get('tags', []))}
""".strip()
metadata = {
"menu_id": menu_id,
"name_original": item.get("name_original", ""),
"name_zh": item.get("name_zh", ""),
"price": item.get("price", "")
}
return Document(
page_content=content,
metadata=metadata
)
这里需要同时设计好 page_content 和 metadata。
page_content 用于语义相似度检索,metadata 用于菜单隔离、数据定位和后续过滤。
六、Embedding 与 Chroma 向量入库
1. 初始化 Embedding 模型
系统中的聊天模型和向量模型需要分别配置。
需要注意:
聊天模型负责生成回答
Embedding 模型负责生成向量
二者并不是同一个功能,也不能因为聊天模型能够正常调用,就认为向量服务一定能够正常运行。
示例:
from langchain_openai import OpenAIEmbeddings
embeddings = OpenAIEmbeddings(
model="text-embedding-v3",
api_key=QWEN_API_KEY,
base_url=QWEN_BASE_URL
)
实际模型名称和服务地址需要根据供应商支持情况配置。
2. 初始化 Chroma
from langchain_chroma import Chroma
vector_store = Chroma(
collection_name="menu_items",
embedding_function=embeddings,
persist_directory="./chroma_db"
)
persist_directory 非常重要。
如果未配置持久化目录,或者不同模块使用了不同目录,就可能出现:
- 入库时写入了数据库;
- 问答时初始化了另一个空数据库;
- 服务重启后全部数据丢失;
- Windows 相对路径与启动目录不一致。
因此更稳妥的方式是构造绝对路径:
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
CHROMA_DIR = BASE_DIR / "chroma_db"
3. 写入菜品数据
def add_menu_items(items: list[dict], menu_id: str):
documents = [
menu_item_to_document(item, menu_id)
for item in items
]
if not documents:
return
vector_store.add_documents(documents)
对于重复上传或菜单更新,还需要考虑:
- 是否删除旧菜单数据;
- 是否按照
menu_id隔离; - 是否为每个菜品生成稳定 ID;
- 是否执行增量更新;
- 是否避免重复入库。
七、检索增强问答实现
1. 检索相关菜品
用户问题到达后端后,首先执行相似度检索:
def search_menu(question: str, menu_id: str, top_k: int = 4):
return vector_store.similarity_search(
question,
k=top_k,
filter={"menu_id": menu_id}
)
菜单过滤非常关键。
如果系统中保存了多个用户或者多个菜单的数据,却没有通过 menu_id 进行隔离,就可能检索到其他菜单中的菜品。
2. 获取用户偏好
系统读取用户资料中的饮食偏好,例如:
preferences = {
"allergens": ["花生"],
"dietary_restrictions": ["不吃牛肉"],
"spice_level": "不辣",
"preferred_tags": ["清淡", "主食"]
}
然后转换为适合 Prompt 的文字。
def format_preferences(preferences: dict) -> str:
return f"""
过敏原:{', '.join(preferences.get('allergens', [])) or '无'}
饮食限制:{', '.join(preferences.get('dietary_restrictions', [])) or '无'}
辣度偏好:{preferences.get('spice_level', '未设置')}
口味偏好:{', '.join(preferences.get('preferred_tags', [])) or '未设置'}
""".strip()
3. 构造受约束的 Prompt
Prompt 需要明确告诉模型:
- 只能根据检索到的菜单回答;
- 菜单没有相关信息时,要明确说明;
- 不得编造菜名、价格和配料;
- 优先考虑用户过敏原与饮食限制;
- 推荐时应说明理由。
prompt = f"""
你是一名智能菜单助手。
用户饮食偏好:
{preference_text}
当前菜单检索结果:
{context}
用户问题:
{question}
回答要求:
1. 只能依据当前菜单检索结果回答;
2. 不得编造菜单中不存在的菜品、价格或配料;
3. 优先检查过敏原和饮食限制;
4. 推荐菜品时说明推荐理由;
5. 如果菜单信息不足,请明确说明无法判断。
"""
4. 调用聊天模型
response = chat_model.invoke(prompt)
return {
"answer": response.content,
"sources": [
document.metadata
for document in retrieved_documents
]
}
除了返回模型答案,还可以返回检索来源,方便前端展示推荐依据,也方便开发阶段调试。
八、Flutter 结果页改造
原结果页只负责展示识别出的菜单数据。
为了形成完整闭环,需要增加:
- 问题输入框;
- 发送按钮;
- 加载状态;
- 回答展示区域;
- 错误提示;
- 推荐问题;
- 多轮消息列表。
服务层可以封装为:
class MenuRagService {
Future<String> askQuestion({
required String question,
required String menuId,
required String token,
}) async {
final response = await http.post(
Uri.parse('$baseUrl/api/v1/menu/ask'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
body: jsonEncode({
'question': question,
'menu_id': menuId,
}),
);
if (response.statusCode != 200) {
throw Exception('问答请求失败');
}
final data = jsonDecode(utf8.decode(response.bodyBytes));
return data['answer'] ?? '暂时无法生成回答';
}
}
页面发送问题时,需要防止重复点击:
Future<void> _sendQuestion() async {
final question = _questionController.text.trim();
if (question.isEmpty || _isLoading) {
return;
}
setState(() {
_isLoading = true;
});
try {
final answer = await _ragService.askQuestion(
question: question,
menuId: widget.menuId,
token: token,
);
setState(() {
_answer = answer;
});
} catch (e) {
setState(() {
_errorMessage = '问答服务暂时不可用,请稍后重试';
});
} finally {
setState(() {
_isLoading = false;
});
}
}
九、项目的技术难点
1. 多模型链路协同
系统前半段使用 Qwen 多模态模型识别图片,后半段使用 Embedding 模型和聊天模型完成检索问答。
这不是一个模型完成全部功能,而是一条多模型协作链路。
2. 数据结构多次转换
数据需要经历:
图片
→ 多模态模型输出
→ JSON
→ 标准化菜品对象
→ LangChain Document
→ Embedding 向量
→ 检索结果
→ Prompt 上下文
→ LLM 回答
→ Flutter UI 数据
任意一次字段不一致,都可能导致后续模块失败。
3. 向量库生命周期管理
向量数据库需要处理:
- 初始化时机;
- 持久化目录;
- 菜单隔离;
- 重复写入;
- 服务重启;
- 增量更新;
- 空库兜底。
4. 用户偏好的结构化融合
用户偏好不能只作为一句自然语言随意附加,而要区分过敏原、饮食限制和一般口味偏好。
其中,过敏原属于高优先级限制,推荐逻辑必须优先处理。
5. 前后端状态同步
前端页面已经显示菜单,不代表后端向量库一定存在对应数据。
因此,系统必须通过统一接口和菜单 ID,确保识别、展示、入库和问答使用的是同一份菜单数据。
十、项目创新点
创新点一:从菜单识别升级为菜单理解
系统不是简单返回 OCR 文字,而是输出包含翻译、描述、价格和标签的结构化菜品数据。
创新点二:为每次上传动态构建菜单知识库
传统知识库通常提前准备好文档,而本项目会根据用户实时上传的菜单动态构建向量知识库。
创新点三:将用户偏好引入 RAG
系统不仅检索“与问题相关的菜”,还结合用户过敏原、忌口和口味偏好生成个性化回答。
创新点四:限制模型仅依据菜单回答
通过检索范围、菜单 ID 和系统 Prompt 三重约束,降低模型生成菜单外内容的概率。
创新点五:实现真实移动端业务闭环
项目完成了:
图片上传
→ 菜单识别
→ 自动入库
→ 菜品展示
→ 用户提问
→ 个性化回答
这使 RAG 不再是独立演示脚本,而是现有 Flutter 业务系统中的真实功能。
十一、总结
本项目的核心工作不是增加一个聊天页面,而是把一条完整的 AI 后端链路接入现有系统。
它综合使用了:
- Qwen 多模态模型;
- FastAPI;
- LangChain;
- Embedding;
- Chroma;
- RAG;
- 用户画像与偏好;
- Flutter。
真正的工程难度在于让各个模块的数据格式、配置、运行环境和调用顺序保持一致。
下一篇文章将介绍开发过程中遇到的典型问题,包括 API Key 配置错误、虚拟环境不一致、Chroma 依赖缺失、向量库为空、接口切换和 Git 换行符提示等,以及这些问题的完整排查过程。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)