一、项目目标在上一篇文章中介绍了智能菜单助手的项目背景和 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)asyncdefprocess_menu(file:UploadFile):image_bytesawaitfile.read()menu_resultawaitmenu_service.parse_menu(image_bytes)normalized_itemsnormalize_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:tagsitem.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:contentf 菜品原名{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_contentcontent,metadatametadata)这里需要同时设计好page_content和metadata。page_content用于语义相似度检索metadata用于菜单隔离、数据定位和后续过滤。六、Embedding 与 Chroma 向量入库1. 初始化 Embedding 模型系统中的聊天模型和向量模型需要分别配置。需要注意聊天模型负责生成回答 Embedding 模型负责生成向量二者并不是同一个功能也不能因为聊天模型能够正常调用就认为向量服务一定能够正常运行。示例fromlangchain_openaiimportOpenAIEmbeddings embeddingsOpenAIEmbeddings(modeltext-embedding-v3,api_keyQWEN_API_KEY,base_urlQWEN_BASE_URL)实际模型名称和服务地址需要根据供应商支持情况配置。2. 初始化 Chromafromlangchain_chromaimportChroma vector_storeChroma(collection_namemenu_items,embedding_functionembeddings,persist_directory./chroma_db)persist_directory非常重要。如果未配置持久化目录或者不同模块使用了不同目录就可能出现入库时写入了数据库问答时初始化了另一个空数据库服务重启后全部数据丢失Windows 相对路径与启动目录不一致。因此更稳妥的方式是构造绝对路径frompathlibimportPath BASE_DIRPath(__file__).resolve().parent.parent CHROMA_DIRBASE_DIR/chroma_db3. 写入菜品数据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:int4):returnvector_store.similarity_search(question,ktop_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. 构造受约束的 PromptPrompt 需要明确告诉模型只能根据检索到的菜单回答菜单没有相关信息时要明确说明不得编造菜名、价格和配料优先考虑用户过敏原与饮食限制推荐时应说明理由。promptf 你是一名智能菜单助手。 用户饮食偏好{preference_text}当前菜单检索结果{context}用户问题{question}回答要求 1. 只能依据当前菜单检索结果回答 2. 不得编造菜单中不存在的菜品、价格或配料 3. 优先检查过敏原和饮食限制 4. 推荐菜品时说明推荐理由 5. 如果菜单信息不足请明确说明无法判断。 4. 调用聊天模型responsechat_model.invoke(prompt)return{answer:response.content,sources:[document.metadatafordocumentinretrieved_documents]}除了返回模型答案还可以返回检索来源方便前端展示推荐依据也方便开发阶段调试。八、Flutter 结果页改造原结果页只负责展示识别出的菜单数据。为了形成完整闭环需要增加问题输入框发送按钮加载状态回答展示区域错误提示推荐问题多轮消息列表。服务层可以封装为classMenuRagService{FutureStringaskQuestion({requiredStringquestion,requiredStringmenuId,requiredStringtoken,})async{finalresponseawaithttp.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(问答请求失败);}finaldatajsonDecode(utf8.decode(response.bodyBytes));returndata[answer]??暂时无法生成回答;}}页面发送问题时需要防止重复点击Futurevoid_sendQuestion()async{finalquestion_questionController.text.trim();if(question.isEmpty||_isLoading){return;}setState((){_isLoadingtrue;});try{finalanswerawait_ragService.askQuestion(question:question,menuId:widget.menuId,token:token,);setState((){_answeranswer;});}catch(e){setState((){_errorMessage问答服务暂时不可用请稍后重试;});}finally{setState((){_isLoadingfalse;});}}九、项目的技术难点1. 多模型链路协同系统前半段使用 Qwen 多模态模型识别图片后半段使用 Embedding 模型和聊天模型完成检索问答。这不是一个模型完成全部功能而是一条多模型协作链路。2. 数据结构多次转换数据需要经历图片 → 多模态模型输出 → JSON → 标准化菜品对象 → LangChain Document → Embedding 向量 → 检索结果 → Prompt 上下文 → LLM 回答 → Flutter UI 数据任意一次字段不一致都可能导致后续模块失败。3. 向量库生命周期管理向量数据库需要处理初始化时机持久化目录菜单隔离重复写入服务重启增量更新空库兜底。4. 用户偏好的结构化融合用户偏好不能只作为一句自然语言随意附加而要区分过敏原、饮食限制和一般口味偏好。其中过敏原属于高优先级限制推荐逻辑必须优先处理。5. 前后端状态同步前端页面已经显示菜单不代表后端向量库一定存在对应数据。因此系统必须通过统一接口和菜单 ID确保识别、展示、入库和问答使用的是同一份菜单数据。十、项目创新点创新点一从菜单识别升级为菜单理解系统不是简单返回 OCR 文字而是输出包含翻译、描述、价格和标签的结构化菜品数据。创新点二为每次上传动态构建菜单知识库传统知识库通常提前准备好文档而本项目会根据用户实时上传的菜单动态构建向量知识库。创新点三将用户偏好引入 RAG系统不仅检索“与问题相关的菜”还结合用户过敏原、忌口和口味偏好生成个性化回答。创新点四限制模型仅依据菜单回答通过检索范围、菜单 ID 和系统 Prompt 三重约束降低模型生成菜单外内容的概率。创新点五实现真实移动端业务闭环项目完成了图片上传 → 菜单识别 → 自动入库 → 菜品展示 → 用户提问 → 个性化回答这使 RAG 不再是独立演示脚本而是现有 Flutter 业务系统中的真实功能。十一、总结本项目的核心工作不是增加一个聊天页面而是把一条完整的 AI 后端链路接入现有系统。它综合使用了Qwen 多模态模型FastAPILangChainEmbeddingChromaRAG用户画像与偏好Flutter。真正的工程难度在于让各个模块的数据格式、配置、运行环境和调用顺序保持一致。下一篇文章将介绍开发过程中遇到的典型问题包括 API Key 配置错误、虚拟环境不一致、Chroma 依赖缺失、向量库为空、接口切换和 Git 换行符提示等以及这些问题的完整排查过程。
开发日志(十一):多模态菜单 RAG 系统实战
发布时间:2026/6/13 21:40:11
一、项目目标在上一篇文章中介绍了智能菜单助手的项目背景和 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)asyncdefprocess_menu(file:UploadFile):image_bytesawaitfile.read()menu_resultawaitmenu_service.parse_menu(image_bytes)normalized_itemsnormalize_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:tagsitem.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:contentf 菜品原名{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_contentcontent,metadatametadata)这里需要同时设计好page_content和metadata。page_content用于语义相似度检索metadata用于菜单隔离、数据定位和后续过滤。六、Embedding 与 Chroma 向量入库1. 初始化 Embedding 模型系统中的聊天模型和向量模型需要分别配置。需要注意聊天模型负责生成回答 Embedding 模型负责生成向量二者并不是同一个功能也不能因为聊天模型能够正常调用就认为向量服务一定能够正常运行。示例fromlangchain_openaiimportOpenAIEmbeddings embeddingsOpenAIEmbeddings(modeltext-embedding-v3,api_keyQWEN_API_KEY,base_urlQWEN_BASE_URL)实际模型名称和服务地址需要根据供应商支持情况配置。2. 初始化 Chromafromlangchain_chromaimportChroma vector_storeChroma(collection_namemenu_items,embedding_functionembeddings,persist_directory./chroma_db)persist_directory非常重要。如果未配置持久化目录或者不同模块使用了不同目录就可能出现入库时写入了数据库问答时初始化了另一个空数据库服务重启后全部数据丢失Windows 相对路径与启动目录不一致。因此更稳妥的方式是构造绝对路径frompathlibimportPath BASE_DIRPath(__file__).resolve().parent.parent CHROMA_DIRBASE_DIR/chroma_db3. 写入菜品数据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:int4):returnvector_store.similarity_search(question,ktop_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. 构造受约束的 PromptPrompt 需要明确告诉模型只能根据检索到的菜单回答菜单没有相关信息时要明确说明不得编造菜名、价格和配料优先考虑用户过敏原与饮食限制推荐时应说明理由。promptf 你是一名智能菜单助手。 用户饮食偏好{preference_text}当前菜单检索结果{context}用户问题{question}回答要求 1. 只能依据当前菜单检索结果回答 2. 不得编造菜单中不存在的菜品、价格或配料 3. 优先检查过敏原和饮食限制 4. 推荐菜品时说明推荐理由 5. 如果菜单信息不足请明确说明无法判断。 4. 调用聊天模型responsechat_model.invoke(prompt)return{answer:response.content,sources:[document.metadatafordocumentinretrieved_documents]}除了返回模型答案还可以返回检索来源方便前端展示推荐依据也方便开发阶段调试。八、Flutter 结果页改造原结果页只负责展示识别出的菜单数据。为了形成完整闭环需要增加问题输入框发送按钮加载状态回答展示区域错误提示推荐问题多轮消息列表。服务层可以封装为classMenuRagService{FutureStringaskQuestion({requiredStringquestion,requiredStringmenuId,requiredStringtoken,})async{finalresponseawaithttp.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(问答请求失败);}finaldatajsonDecode(utf8.decode(response.bodyBytes));returndata[answer]??暂时无法生成回答;}}页面发送问题时需要防止重复点击Futurevoid_sendQuestion()async{finalquestion_questionController.text.trim();if(question.isEmpty||_isLoading){return;}setState((){_isLoadingtrue;});try{finalanswerawait_ragService.askQuestion(question:question,menuId:widget.menuId,token:token,);setState((){_answeranswer;});}catch(e){setState((){_errorMessage问答服务暂时不可用请稍后重试;});}finally{setState((){_isLoadingfalse;});}}九、项目的技术难点1. 多模型链路协同系统前半段使用 Qwen 多模态模型识别图片后半段使用 Embedding 模型和聊天模型完成检索问答。这不是一个模型完成全部功能而是一条多模型协作链路。2. 数据结构多次转换数据需要经历图片 → 多模态模型输出 → JSON → 标准化菜品对象 → LangChain Document → Embedding 向量 → 检索结果 → Prompt 上下文 → LLM 回答 → Flutter UI 数据任意一次字段不一致都可能导致后续模块失败。3. 向量库生命周期管理向量数据库需要处理初始化时机持久化目录菜单隔离重复写入服务重启增量更新空库兜底。4. 用户偏好的结构化融合用户偏好不能只作为一句自然语言随意附加而要区分过敏原、饮食限制和一般口味偏好。其中过敏原属于高优先级限制推荐逻辑必须优先处理。5. 前后端状态同步前端页面已经显示菜单不代表后端向量库一定存在对应数据。因此系统必须通过统一接口和菜单 ID确保识别、展示、入库和问答使用的是同一份菜单数据。十、项目创新点创新点一从菜单识别升级为菜单理解系统不是简单返回 OCR 文字而是输出包含翻译、描述、价格和标签的结构化菜品数据。创新点二为每次上传动态构建菜单知识库传统知识库通常提前准备好文档而本项目会根据用户实时上传的菜单动态构建向量知识库。创新点三将用户偏好引入 RAG系统不仅检索“与问题相关的菜”还结合用户过敏原、忌口和口味偏好生成个性化回答。创新点四限制模型仅依据菜单回答通过检索范围、菜单 ID 和系统 Prompt 三重约束降低模型生成菜单外内容的概率。创新点五实现真实移动端业务闭环项目完成了图片上传 → 菜单识别 → 自动入库 → 菜品展示 → 用户提问 → 个性化回答这使 RAG 不再是独立演示脚本而是现有 Flutter 业务系统中的真实功能。十一、总结本项目的核心工作不是增加一个聊天页面而是把一条完整的 AI 后端链路接入现有系统。它综合使用了Qwen 多模态模型FastAPILangChainEmbeddingChromaRAG用户画像与偏好Flutter。真正的工程难度在于让各个模块的数据格式、配置、运行环境和调用顺序保持一致。下一篇文章将介绍开发过程中遇到的典型问题包括 API Key 配置错误、虚拟环境不一致、Chroma 依赖缺失、向量库为空、接口切换和 Git 换行符提示等以及这些问题的完整排查过程。