一、项目目标

在上一篇文章中,介绍了智能菜单助手的项目背景和 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")
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_contentmetadata

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 需要明确告诉模型:

  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.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 换行符提示等,以及这些问题的完整排查过程。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐