一、项目目标
在上一篇文章中,介绍了智能菜单助手的项目背景和 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_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_content和metadata。
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 需要明确告诉模型:
- 只能根据检索到的菜单回答;
- 菜单没有相关信息时,要明确说明;
- 不得编造菜名、价格和配料;
- 优先考虑用户过敏原与饮食限制;
- 推荐时应说明理由。
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 换行符提示等,以及这些问题的完整排查过程。