当前位置: 首页 > news >正文

开发日志(十一):多模态菜单 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

新的接口不再只负责保存图片,而是承担以下任务:

  1. 接收用户上传的菜单图片;
  2. 校验图片格式;
  3. 调用 Qwen 多模态模型;
  4. 解析模型返回内容;
  5. 标准化菜品字段;
  6. 将菜品写入向量数据库;
  7. 将识别结果返回 Flutter。

接口逻辑可以抽象为:

@app.post("/api/v1/menu/process")asyncdefprocess_menu(file:UploadFile):image_bytes=awaitfile.read()menu_result=awaitmenu_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 尾部多余逗号;
  • 模型输出额外说明文字。

因此,后端不能直接相信模型结果,而要执行标准化处理。

defnormalize_item(item:dict)->dict:tags=item.get("tags",[])ifisinstance(tags,str):tags=[tag.strip()fortagintags.split(",")iftag.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":["主食","不辣","素食"]}

需要将其重新组织为语义完整的文本:

fromlangchain_core.documentsimportDocumentdefmenu_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","")}returnDocument(page_content=content,metadata=metadata)

这里需要同时设计好page_contentmetadata

page_content用于语义相似度检索,metadata用于菜单隔离、数据定位和后续过滤。


六、Embedding 与 Chroma 向量入库

1. 初始化 Embedding 模型

系统中的聊天模型和向量模型需要分别配置。

需要注意:

聊天模型负责生成回答 Embedding 模型负责生成向量

二者并不是同一个功能,也不能因为聊天模型能够正常调用,就认为向量服务一定能够正常运行。

示例:

fromlangchain_openaiimportOpenAIEmbeddings embeddings=OpenAIEmbeddings(model="text-embedding-v3",api_key=QWEN_API_KEY,base_url=QWEN_BASE_URL)

实际模型名称和服务地址需要根据供应商支持情况配置。

2. 初始化 Chroma

fromlangchain_chromaimportChroma vector_store=Chroma(collection_name="menu_items",embedding_function=embeddings,persist_directory="./chroma_db")

persist_directory非常重要。

如果未配置持久化目录,或者不同模块使用了不同目录,就可能出现:

  • 入库时写入了数据库;
  • 问答时初始化了另一个空数据库;
  • 服务重启后全部数据丢失;
  • Windows 相对路径与启动目录不一致。

因此更稳妥的方式是构造绝对路径:

frompathlibimportPath BASE_DIR=Path(__file__).resolve().parent.parent CHROMA_DIR=BASE_DIR/"chroma_db"

3. 写入菜品数据

defadd_menu_items(items:list[dict],menu_id:str):documents=[menu_item_to_document(item,menu_id)foriteminitems]ifnotdocuments:returnvector_store.add_documents(documents)

对于重复上传或菜单更新,还需要考虑:

  • 是否删除旧菜单数据;
  • 是否按照menu_id隔离;
  • 是否为每个菜品生成稳定 ID;
  • 是否执行增量更新;
  • 是否避免重复入库。

七、检索增强问答实现

1. 检索相关菜品

用户问题到达后端后,首先执行相似度检索:

defsearch_menu(question:str,menu_id:str,top_k:int=4):returnvector_store.similarity_search(question,k=top_k,filter={"menu_id":menu_id})

菜单过滤非常关键。

如果系统中保存了多个用户或者多个菜单的数据,却没有通过menu_id进行隔离,就可能检索到其他菜单中的菜品。

2. 获取用户偏好

系统读取用户资料中的饮食偏好,例如:

preferences={"allergens":["花生"],"dietary_restrictions":["不吃牛肉"],"spice_level":"不辣","preferred_tags":["清淡","主食"]}

然后转换为适合 Prompt 的文字。

defformat_preferences(preferences:dict)->str:returnf""" 过敏原:{', '.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 需要明确告诉模型:

  1. 只能根据检索到的菜单回答;
  2. 菜单没有相关信息时,要明确说明;
  3. 不得编造菜名、价格和配料;
  4. 优先考虑用户过敏原与饮食限制;
  5. 推荐时应说明理由。
prompt=f""" 你是一名智能菜单助手。 用户饮食偏好:{preference_text}当前菜单检索结果:{context}用户问题:{question}回答要求: 1. 只能依据当前菜单检索结果回答; 2. 不得编造菜单中不存在的菜品、价格或配料; 3. 优先检查过敏原和饮食限制; 4. 推荐菜品时说明推荐理由; 5. 如果菜单信息不足,请明确说明无法判断。 """

4. 调用聊天模型

response=chat_model.invoke(prompt)return{"answer":response.content,"sources":[document.metadatafordocumentinretrieved_documents]}

除了返回模型答案,还可以返回检索来源,方便前端展示推荐依据,也方便开发阶段调试。


八、Flutter 结果页改造

原结果页只负责展示识别出的菜单数据。

为了形成完整闭环,需要增加:

  • 问题输入框;
  • 发送按钮;
  • 加载状态;
  • 回答展示区域;
  • 错误提示;
  • 推荐问题;
  • 多轮消息列表。

服务层可以封装为:

classMenuRagService{Future<String>askQuestion({requiredStringquestion,requiredStringmenuId,requiredStringtoken,})async{finalresponse=awaithttp.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){throwException('问答请求失败');}finaldata=jsonDecode(utf8.decode(response.bodyBytes));returndata['answer']??'暂时无法生成回答';}}

页面发送问题时,需要防止重复点击:

Future<void>_sendQuestion()async{finalquestion=_questionController.text.trim();if(question.isEmpty||_isLoading){return;}setState((){_isLoading=true;});try{finalanswer=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 换行符提示等,以及这些问题的完整排查过程。

http://www.jsqmd.com/news/1007809/

相关文章:

  • 26年春季学期学习记录第44天
  • 数据分析工具选型指南:选对工具,效率直接翻倍! - 品牌测评鉴赏家
  • 猫抓浏览器扩展终极指南:三步搞定网页视频音频下载
  • 基于设备标识重置技术的Cursor Pro功能绕过实现深度解析
  • 星露谷物语SMAPI终极指南:5分钟学会安全安装模组框架
  • 鸿蒙数学108篇 第八十二篇:微积分基本定理
  • 深入解析MC92603千兆以太网PHY芯片:8B/10B编码、冗余链路与时钟恢复实战
  • Maya glTF导出插件深度解析:构建高效3D格式转换工作流实战指南
  • 打破常规:NSK“黑科技”如何重塑滚动轴承的寿命预测?
  • 会议视频快速转文字、提取音频!2026实测5款靠谱工具 - 品牌测评鉴赏家
  • WorkshopDL:跨平台玩家的终极Steam创意工坊下载指南
  • 5分钟掌握ncmdump:轻松解锁网易云音乐NCM加密文件
  • JAVA常见API
  • 《我劝你先别创业,除非你先测过这个》
  • 2026无锡瓷砖空鼓翘边拱起根治全攻略|苏易修缮本地工况专属修复指南 - 苏易修缮
  • AI视频工具实测:Seedance/可灵/HappyHorse谁最能打?
  • 聊聊3款不同定位的数据分析工具:百度文库、腾讯文档、Tableau的真实使用场景 - 品牌测评鉴赏家
  • 3步掌握Kazumi硬件解码优化:告别手机视频卡顿的终极指南
  • 【会议征稿通知 | 深圳大学主办 | AP出版 | EI 、Scopus稳定检索】第四届管理创新与经济发展国际学术会议(MIED 2026)
  • 2026年张家港二手手机店大起底:这家企业为何脱颖而出? - 资讯速览
  • 课件资料存在哪里随时查看使用?多款实用工具汇总 - 品牌测评鉴赏家
  • [企业AI落地] Windows 11 下快速搭建 Ollama + Hermes + Codex + Open WebUI 本地 Agent 体系
  • 用户交互体验优化:缩放、拖拽、日志、错误提示
  • 用C语言循环搞定PTA编程题:统计Tom、Jerry和Spike的选票(附完整代码和测试用例)
  • ArcGIS新手必看:用‘渔网’工具搞定土地利用统计,从创建格网到计算占比保姆级教程
  • 告别无效加班!职场日常办公工具测评,选对效率翻倍 - 品牌测评鉴赏家
  • 终极免费视频下载神器:yt-dlp-gui Windows图形界面完整指南
  • 有声书制作配音用什么工具音色多?2026通通无印免费多音色AI配音教程 - 科技大爆炸
  • CANN数学算子库ops-math底层优化原理深度剖析:昇腾NPU上GELU激活函数三种实现方式的性能与精度权衡工程实践
  • 2026年6月超声波点焊机直销工厂哪家专业,炭包超声波封口机/手提袋超声波点焊机,超声波点焊机源头工厂哪家专业 - 品牌推荐师