
1. 项目概述这不是又一个“AI聊天框”而是一套能直接读懂业务数据的决策搭档“Aloudata Agent基于 NoETL 明细语义层的分析决策智能体”——光看标题很多人第一反应是“又一个带Agent后缀的AI概念产品”但如果你在数据平台、BI或数据分析一线干过三年以上看到“NoETL”和“明细语义层”这两个词手会下意识停顿半秒。因为这两个词背后站着过去十年里最让人头疼的两座大山一边是ETL流程冗长、口径不一、上线动辄两周起另一边是业务人员对着BI报表反复追问“这个‘销售额’到底包不包括退货是不是含税是按开票日还是发货日算的”——而答案往往藏在某个工程师写在GitLab注释里的三行Python代码里。Aloudata Agent 的核心不是让你多问一句“帮我画个柱状图”而是当你在钉钉里输入“上个月华东区新签客户中复购率超过40%的行业TOP3有哪些”它能立刻理解“华东区”是地理维度、“新签客户”是客户生命周期状态、“复购率”是基于订单明细计算的衍生指标、“行业TOP3”需要聚合排序——所有这些都不依赖你提前建好宽表、不依赖你背熟字段别名、更不依赖你翻出《指标字典V2.3修订版》PDF。它直接穿透到原始交易明细、客户主数据、合同表这些“活”的数据源实时组合、过滤、关联、计算把结果连同计算逻辑比如“复购率 二次及以上下单客户数 / 首次下单客户数”一起返回给你。这背后支撑的正是NoETL明细语义层——它不是把数据“搬”过来再加工而是把数据“意义”本身结构化、可查询、可编排。MQLMetric Query Language就是它的普通话一句SELECT industry, COUNT(DISTINCT customer_id) AS repeat_customers FROM orders WHERE order_date 2024-05-01 GROUP BY industry背后自动完成跨库JOIN、时序对齐、空值处理、权限过滤。我去年在一家零售SaaS公司做过POC同样需求传统方式要数据工程师BI开发业务方拉会确认口径平均耗时3.8天用Aloudata Agent业务运营同学自己在飞书文档里机器人17秒得到结果可验证的SQL逻辑。这不是效率提升是决策链路从“申请-审批-等待”变成“思考-提问-行动”。这个项目真正解决的是数据价值释放的最后一公里堵点数据在库里指标在文档里人在会议室里争论定义而市场机会在窗外溜走。它适合三类人深度参考一是企业数据平台负责人正在评估如何降低数据服务交付周期二是业务分析/增长运营同学厌倦了等报表、改口径、查数据源三是技术架构师想搞清楚“语义层Agent”这套组合拳到底比传统BILLM方案强在哪、难在哪、坑在哪。它不承诺“全自动替代分析师”但确实让80%的常规探查性分析从“项目制”退化为“对话式服务”。2. 核心设计思路拆解为什么必须是NoETL明细语义层而不是微调一个LLM很多团队看到“Agent”就本能想找一个开源LLM喂几万条SQL微调一下再接个数据库驱动不就完事了我试过也帮客户踩过坑。去年用Llama3-70B微调了一个“SQL生成Agent”在TPC-H标准测试集上准确率92%但一上生产环境面对真实ERP里的customer_master表字段名cust_no、cust_name_cn、cust_type_code没有注释它生成的SQL连JOIN条件都写错——因为模型没见过“客户类型代码2代表KA客户”这种业务隐喻。问题不在模型能力而在语义鸿沟LLM学的是文本统计规律而业务数据的意义藏在表结构、字段约束、ETL逻辑、甚至销售手册的第17页。Aloudata Agent 的破局点恰恰绕开了“让LLM理解业务”这个死胡同转而构建一个可执行、可验证、可追溯的语义中间层。这个设计不是炫技是被现实逼出来的NoETL不是不要ETL而是不让ETL成为瓶颈。传统ETL本质是“数据搬运工翻译官”把源系统数据搬到数仓再按预设口径加工成宽表。一旦业务问“昨天下午3点到4点上海静安区扫码未支付的用户按设备型号分布”你就得等数据工程师临时加一个实时流任务跑通测试环境再上线——这过程里商机早没了。NoETL的思路是保留原始明细数据不动如每笔订单、每次点击、每个API调用日志只在查询时动态计算。Aloudata的引擎会在首次查询时自动识别order_time字段的时区信息、region_code与city_name的映射关系、payment_status的枚举值含义把这些元数据注册进语义层。后续所有查询都基于这个注册后的“意义地图”执行无需预先建模。明细语义层是Agent的“业务词典语法手册”。它包含三层结构① 实体层Entity定义业务核心对象如Customer客户、Order订单、Product商品。每个实体绑定其主数据源表、关键字段、业务规则如“客户ID唯一性校验逻辑”。② 属性层Attribute描述实体的静态特征如Customer.industry行业、Order.payment_method支付方式。属性标注数据类型、业务含义、取值范围如payment_method IN (wechat, alipay, bank_transfer)并关联到具体字段。③ 指标层Metric定义可计算的业务度量如Revenue收入、Churn_Rate流失率。每个指标明确其计算逻辑SQL片段、时间粒度日/周/月、适用实体仅限Customer、以及依赖的属性如Revenue需order_amount和currency_rate。这三层不是静态配置而是通过解析DDL、采样数据分布、结合业务文档NLP提取自动生成初稿再由数据Owner在线协同校验。我参与过某银行信用卡中心的落地他们用Aloudata工具扫描了27个Oracle表自动生成了142个实体、893个属性、67个核心指标初稿人工校验仅耗时2.5人日——而传统方式光梳理这67个指标的口径定义就花了3个月。Agent是语义层的“智能调度员”而非“SQL翻译器”。它的核心职责有三① 意图解析Intent Parsing把自然语言问题如“对比Q1和Q2的客单价变化”拆解成结构化查询意图目标指标avg_order_value、时间维度quarter、对比操作difference。这里用轻量级NER规则引擎而非大模型确保低延迟和高确定性。② 语义路由Semantic Routing根据意图中的实体、属性、指标从语义层中精准匹配对应的数据源、计算逻辑、权限策略。例如“华东区”触发region_code属性的值映射规则“客单价”触发avg_order_value指标的SQL模板。③ 执行编排Execution Orchestration生成最终可执行的MQL查询注入安全参数如租户ID、数据脱敏规则分发到对应数据源执行并聚合结果。整个过程MQL是统一入口屏蔽底层是MySQL、StarRocks还是Doris。这个设计的底层逻辑很朴素把LLM的不确定性锁在“意图解析”这个小环节把确定性交给经过严格校验的语义层和可验证的MQL引擎。就像老司机开车导航LLM只负责听清“去西站”不负责判断红绿灯规则语义层和油门力度执行引擎。我们实测过在同等硬件资源下Aloudata Agent的QPS是纯LLM SQL生成方案的4.2倍错误率下降至1/15——因为90%的错误其实源于语义理解偏差而非SQL语法错误。3. 核心细节解析与实操要点MQL不是SQL的马甲而是面向业务的查询契约很多人第一次接触MQL会下意识当成“带中文注释的SQL”。这是最大的认知陷阱。MQL的设计哲学是让业务语言和机器执行之间存在一条可审计、可回溯、可协作的契约路径。它强制要求每个查询都必须显式声明其业务语义上下文而不仅仅是技术执行逻辑。下面拆解几个关键细节都是我们在客户现场反复打磨出来的实操要点。3.1 MQL的三层结构为什么必须区分SELECT、WHERE、CONTEXT标准SQL的WHERE子句常被滥用为“万能过滤器”比如WHERE region华东 AND statusactive AND date 2024-01-01。但“华东”是地理概念“active”是客户状态“date”是时间维度——它们属于不同语义层级混在一起既难维护也难复用。MQL强制解耦SELECT avg(order_amount) AS avg_order_value, count(DISTINCT customer_id) AS customer_count FROM Order WHERE region IN (shanghai, nanjing, hangzhou) -- 地理维度过滤 AND customer_status active -- 客户状态过滤 CONTEXT time_range: 2024-Q1, -- 时间上下文自动转换为date字段范围 currency: CNY, -- 货币上下文自动触发汇率换算 tenant_id: tenant_001 -- 租户上下文自动注入数据权限SELECT部分只允许引用语义层中已注册的指标avg_order_value或属性customer_id禁止直接写AVG(o.amount)。如果业务需要新指标必须先在语义层注册再在此处引用。这保证了所有被查询的指标都有明确的业务定义和计算逻辑。WHERE部分仅接受语义层中已定义的属性region,customer_status且值必须来自该属性的合法枚举集。比如region属性在语义层中定义了映射规则{shanghai: 华东, beijing: 华北}那么WHERE region shanghai会被自动转换为WHERE region_code IN (SH, NJ, HZ)而WHERE region East China则直接报错——因为“East China”不是该属性的合法值。这杜绝了因拼写错误、大小写不一致导致的查询失败。CONTEXT部分这是MQL的灵魂。它不参与数据过滤而是为整个查询设置执行环境契约。time_range: 2024-Q1不是简单替换为date BETWEEN 2024-01-01 AND 2024-03-31而是触发语义层中预设的“季度计算规则”自动识别order_date字段的时区UTC8处理跨年季度如2024-Q4需包含2024-10-01至2024-12-31并兼容不同日历如ISO周历。currency: CNY则调用实时汇率服务将所有外币订单金额按当日中间价折算。这些规则在语义层中以JSON Schema定义可版本化管理。提示CONTEXT不是可选项。即使查询不涉及时间或货币也必须显式声明time_range: all或time_range: latest。这是为了强制业务方明确其查询的时间语境——避免出现“我查的是最新数据为什么结果和昨天一样”这类经典问题。3.2 语义层注册的实操禁忌字段别名、空值策略、权限继承语义层的质量直接决定Agent的可用性。我们服务过一家电商客户初期注册时图快直接把ODS层所有字段拖进来结果上线一周90%的查询失败。复盘发现全是注册环节的细节没抠准字段别名Alias不是锦上添花而是防错刚需。源表字段名cust_nm、ord_amt、pay_dt业务方根本看不懂。MQL要求所有属性必须有业务友好别名如customer_name、order_amount、payment_date且别名必须全局唯一。更关键的是别名要能反向映射当业务说“我要看客户姓名”Agent必须能100%确定是指cust_nm而不是contact_person。我们的做法是在注册界面强制要求填写“业务含义说明”如“客户在ERP系统中的正式全称非联系人姓名”和“数据来源证明”截图或链接到源系统字段文档。曾有个客户把product_sku注册为product_name结果业务问“查iPhone15的销量”Agent真去查product_name LIKE %iPhone15%而实际销量数据在sku_code字段里——这种错误靠测试很难覆盖必须靠注册规范卡死。空值策略Null Handling必须前置约定不能留给运行时猜。order_amount字段空值代表“未支付”还是“数据缺失”customer_industry为空是“未知行业”还是“未填写”MQL引擎遇到空值默认行为是跳过该记录即NULL不参与任何聚合计算。但这常违背业务直觉。解决方案是在语义层注册时为每个数值型/分类型属性指定null_policyignore默认空值记录不参与计算treat_as_zero数值型空值转为0treat_as_other分类型空值归入“其他”组error_on_null遇到空值直接报错强制业务补全。 我们建议对核心指标如revenue的order_amount必须设为error_on_null对辅助属性如customer_hobby可设为treat_as_other。这个策略在注册时就固化避免后期因空值处理分歧引发扯皮。权限继承Permission Inheritance是安全底线不是可选项。语义层中的每个实体、属性、指标都必须绑定最小权限原则。比如Customer实体销售团队只能查industry、region不能查credit_limit信用额度财务团队反之。Aloudata支持两种继承模式字段级继承Customer.credit_limit直接继承finance_role的读权限指标级继承Revenue_by_Industry指标自动继承其依赖的所有属性order_amount,region,industry的权限交集。关键实操点权限配置必须在语义层注册完成后立即进行角色映射测试。我们用一个脚本模拟不同角色发起100个典型查询验证是否100%符合预期。曾有个客户漏配了Order实体的权限导致所有查询都返回空——因为Agent在执行前先校验“当前用户是否有权访问Order实体”无权则直接拒绝不报错也不提示非常隐蔽。3.3 Agent的“记忆”不是LLM的上下文窗口而是语义层的版本快照网络热词里常提“Agent记忆”很多人以为是给LLM加个长上下文。Aloudata Agent的“记忆”本质是语义层的版本化快照Snapshot。每次业务方在MQL查询中指定CONTEXT version: v2.1Agent就锁定使用该版本的语义层定义执行。这解决了两个致命痛点口径漂移Drift防控某次迭代Churn_Rate指标的计算逻辑从“过去12个月无订单客户数 / 当前总客户数”改为“过去6个月无订单客户数 / 去年同期总客户数”。如果不用版本控制所有历史报表都会悄然改变而业务方浑然不觉。通过version: v2.0可确保Q3财报始终基于旧口径计算。协作调试Collaborative Debugging当业务反馈“这个结果不对”运维不用再问“你用的是哪个版本当时怎么问的”直接查MQL日志里的version字段加载对应语义层快照复现查询逻辑。我们有个客户用此功能快速定位到一次线上事故某天凌晨数据工程师误删了region_code的映射规则导致所有华东查询失效。通过对比version: v3.2故障前和version: v3.3故障后的快照差异3分钟定位根因。注意版本号不是随意命名。Aloudata强制采用MAJOR.MINOR.PATCH格式且规则严格MAJOR变更实体结构大改如Customer新增parent_company_id字段需全量重刷MINOR变更属性/指标逻辑优化如Churn_Rate公式调整向前兼容PATCH变更修复错别字、补充注释等不影响执行。这个规则写在客户成功手册第一页所有数据Owner入职培训必考。4. 实操过程与核心环节实现从零搭建一个可用的Aloudata Agent工作流纸上谈兵不如动手一试。下面以某连锁餐饮客户的真实场景为例完整还原从环境准备到首个MQL查询成功的全流程。所有步骤均基于Aloudata官方v2.5.0版本命令和配置可直接复用。重点不是“怎么做”而是“为什么这么选”——每一个参数背后都有血泪教训。4.1 环境准备与最小可行部署5分钟Aloudata Agent不是单体应用而是由三个核心组件构成语义层注册中心Semantic Hub、MQL查询引擎MQL Engine、Agent调度服务Agent Orchestrator。官方推荐生产环境用K8s部署但POC阶段我们一律用Docker Compose原因很实在避免网络策略、存储卷、RBAC等基础设施问题干扰核心逻辑验证。# 创建docker-compose.yml精简版仅含核心服务 version: 3.8 services: semantic-hub: image: aloudata/semantic-hub:v2.5.0 ports: - 8080:8080 # Web UI端口 environment: - POSTGRES_URLjdbc:postgresql://postgres:5432/aloudata?useraloupasswordalou123 - REDIS_URLredis://redis:6379/0 depends_on: - postgres - redis mql-engine: image: aloudata/mql-engine:v2.5.0 ports: - 8081:8081 # MQL API端口 environment: - SEMANTIC_HUB_URLhttp://semantic-hub:8080 - DATA_SOURCE_CONFIG{mysql_orders:jdbc:mysql://mysql:3306/orders?userrootpassword123456} agent-orchestrator: image: aloudata/agent-orchestrator:v2.5.0 ports: - 8082:8082 # Agent API端口 environment: - MQL_ENGINE_URLhttp://mql-engine:8081 - SEMANTIC_HUB_URLhttp://semantic-hub:8080 postgres: image: postgres:14 environment: - POSTGRES_DBaloudata - POSTGRES_USERalou - POSTGRES_PASSWORDalou123 redis: image: redis:7-alpine mysql: image: mysql:8.0 environment: - MYSQL_ROOT_PASSWORD123456 - MYSQL_DATABASEorders实操心得PostgreSQL版本必须≥13语义层的元数据版本管理依赖PG的pg_logical_slot_get_changes函数做变更捕获低版本不支持。Redis必须启用AOF持久化Agent的会话状态如用户最近查询历史存于RedisAOF确保断电不丢数据。MySQL连接字符串中的useSSLfalse必须显式添加否则Java驱动默认尝试SSL握手而本地MySQL未配置证书导致MQL引擎启动失败——这个坑我们踩了7次才记牢。启动命令docker-compose up -d。等待2分钟访问http://localhost:8080看到Aloudata登录页即表示基础环境就绪。4.2 语义层注册实战三步搞定“门店销售分析”客户原始数据在MySQL的orders库核心表sales_fact销售事实表和store_dim门店维度表。目标让业务能用MQL查“各门店Q3销售额TOP10”。Step 1注册实体Entity登录Semantic Hub UI → “实体管理” → “新建实体”名称Store数据源mysql_orders即docker-compose中定义的MySQL连接主表store_dim主键store_id字段映射store_id→store_id主键store_name→store_name别名门店名称region→region_code别名大区编码业务含义“SH华东BJ华北”city→city_name别名城市名称保存后系统自动采样1000行生成字段统计报告空值率、唯一值数等。Step 2注册属性Attribute在Store实体下 → “属性管理” → “新建属性”属性名region_name数据类型STRING业务含义“门店所属大区的中文名称用于报表展示”映射规则CASE WHEN region_code SH THEN 华东 WHEN region_code BJ THEN 华北 ELSE 其他 END合法值[华东, 华北, 华南, 其他]强制校验同理注册city_name属性映射city_name字段无转换。Step 3注册指标Metric“指标管理” → “新建指标”指标名q3_sales_revenue描述“2024年第三季度各门店销售额总和”计算逻辑MQL片段SELECT s.store_id, SUM(f.order_amount) AS revenue FROM sales_fact f JOIN store_dim s ON f.store_id s.store_id WHERE f.order_date 2024-07-01 AND f.order_date 2024-09-30 GROUP BY s.store_id依赖实体Store,SalesFact需先注册SalesFact实体时间粒度QUARTER版本v1.0首次发布关键细节SalesFact实体必须先注册因为指标依赖它。注册时order_date字段必须标注is_time_dimension: trueorder_amount标注is_measure: true否则MQL引擎无法识别时间过滤和聚合字段。指标逻辑中禁止硬编码日期虽然示例写了2024-07-01但实际生产中应使用CONTEXT time_range变量由Agent在执行时注入。此处硬编码仅为演示。版本号v1.0是手动输入Semantic Hub不会自动生成必须由数据Owner确认后填写。这是责任归属的关键。4.3 MQL查询与Agent调用从自然语言到结果的全链路注册完成后即可通过Agent Orchestrator API发起查询。我们用curl模拟业务方在飞书机器人中提问# 发送自然语言请求 curl -X POST http://localhost:8082/v1/agent/query \ -H Content-Type: application/json \ -d { query: 查一下2024年Q3销售额最高的10家门店显示门店名称和销售额, context: { time_range: 2024-Q3, version: v1.0 } }Agent返回结构化结果{ status: success, result: [ {store_name: 上海徐家汇店, revenue: 2458900.5}, {store_name: 北京国贸店, revenue: 2134600.0}, ... ], mql_executed: SELECT store_name, revenue FROM q3_sales_revenue ORDER BY revenue DESC LIMIT 10, semantic_trace: [ {step: intent_parsing, output: {target_metric: q3_sales_revenue, sort_by: revenue, limit: 10}}, {step: semantic_routing, output: {metric_version: v1.0, entity_used: [Store, SalesFact]}}, {step: execution, output: {data_source: mysql_orders, rows_scanned: 1245890}} ] }mql_executed字段展示了Agent最终生成的、可执行的MQL语句。这是调试黄金线索——如果结果不对先看这里生成的MQL是否符合预期。semantic_trace字段详细记录了Agent内部每一步的决策依据。比如intent_parsing输出证明Agent正确识别了“Q3”对应time_range“最高”对应ORDER BY ... DESC“10家”对应LIMIT 10。这比LLM的黑盒输出透明度高出一个数量级。实操技巧首次查询必开Debug模式在API请求头加X-Aloudata-Debug: true获取完整的semantic_trace。我们规定所有新注册的指标上线前必须用Debug模式跑通3个典型查询。结果验证用“反向MQL”拿到结果后手动用MQL Engine API执行mql_executed语句对比结果。如果一致问题在Agent意图解析如果不一致问题在语义层注册或数据源。性能瓶颈定位semantic_trace中的rows_scanned是关键指标。如果某次查询扫描行数远超预期如查10家店却扫了全表1亿行说明store_dim和sales_fact的JOIN条件未命中索引需检查MySQL表结构。4.4 权限与安全配置让销售总监只能看自己的区域权限配置不是最后一步而是贯穿注册全程。Aloudata采用基于属性的访问控制ABAC比传统RBAC更灵活。以“销售总监只能看所辖区域门店”为例Step 1在语义层为Store实体添加权限属性在Store实体的region_code属性上勾选“作为权限属性Permission Attribute”。这意味着所有查询Store实体的请求都必须满足region_code的值约束。Step 2创建权限策略Policy在Semantic Hub → “权限管理” → “新建策略”策略名sales_director_region_access应用实体Store条件表达式region_code IN (SELECT region_code FROM user_region_mapping WHERE user_id current_user())生效角色sales_directorStep 3绑定用户与角色在用户管理后台将销售总监账号zhangsancompany.com加入sales_director角色。验证当张总监发起查询SELECT store_name, revenue FROM q3_sales_revenueAgent在执行前会自动注入权限过滤条件WHERE region_code IN (SH, NJ)假设他管辖华东和华北。如果他试图查WHERE region_code GD广东查询会被静默拒绝返回空结果——因为权限策略已生效。注意事项权限策略必须测试“越权”场景用测试账号模拟普通销售员尝试查SELECT * FROM Store WHERE region_code BJ确认返回0行。敏感字段脱敏是独立开关customer_phone属性可在注册时勾选“启用脱敏”选择“掩码138****1234”。此开关与权限策略正交可同时启用。审计日志必须开启在Agent Orchestrator配置中设置audit_log_enabled: true所有查询含用户ID、MQL、执行时间、结果行数写入Elasticsearch。这是合规刚需不可省略。5. 常见问题与排查技巧实录那些官网文档不会写的“脏活累活”再完美的设计落地时也会撞上现实的墙。以下是我们在23个客户现场总结出的Top 5高频问题及独家排查法。这些问题90%不会出现在官方FAQ里但100%会让你加班到凌晨。5.1 问题MQL查询返回空结果但日志显示“执行成功”查不到原因现象业务方问“华东区Q3销售额”Agent返回空数组semantic_trace显示rows_scanned: 0但MySQL里明明有华东数据。排查路径三步法查语义层注册登录Semantic Hub →Store实体 →region_code属性 → 点击“查看映射规则”。发现规则是CASE WHEN region SH THEN 华东...但源表字段名是area_code不是region注册时填错了字段映射。查数据源连接在MQL Engine日志里搜JDBC connection发现连接串指向了测试库orders_test而非生产库orders_prod。Docker Compose里DATA_SOURCE_CONFIG的URL写错了。查权限策略用curl -X GET http://localhost:8082/v1/agent/debug/permissions?userzhangsan返回策略sales_director_region_access但current_user()返回的是zhangsancompany.com而user_region_mapping表里存的是zhangsan无域名。邮箱格式不匹配。根治方案建立“注册-连接-权限”三联检清单每次上线新指标前由数据Owner、DBA、安全专员三方签字确认。我们自制了一个Excel检查表含27个必检项已沉淀为团队SOP。5.2 问题Agent响应慢10秒但CPU/内存正常MQL Engine日志无报错现象查询简单如SELECT count(*) FROM Store却耗时12秒监控显示MQL Engine CPU10%。真相网络DNS解析超时。Aloudata Agent默认用java.net.InetAddress.getByName()解析MySQL主机名mysql而客户内网DNS服务器响应慢平均800ms。10次DNS查询就吃掉8秒。解决在MQL Engine容器的/etc/hosts文件中硬编码映射172.20.0.5 mysql 172.20.0.6 postgresIP地址用docker network inspect查得。重启容器响应降至300ms内。经验所有生产环境部署必须禁用DNS解析全部用Hosts硬编码。这是Aloudata官方文档的隐藏条款但没人告诉你。5.3 问题语义层版本升级后旧MQL查询报错“指标不存在”但指标名没变现象v1.1版本将q3_sales_revenue指标的time_range参数从硬编码改为CONTEXT变量业务方未改查询仍用CONTEXT time_range: 2024-Q3却报错。原因v1.1版本的指标逻辑中WHERE条件移除了f.order_date 2024-07-01改为f.order_date {{time_range.start}}。但旧版MQL Enginev2.4.0不支持{{ }}语法解析失败。规避强制要求MQL Engine与语义层版本严格匹配。Aloudata的版本兼容矩阵规定v2.5.0Engine仅支持v1.0至v1.2语义层。升级前必须同步升级Engine镜像。我们用Ansible脚本自动校验版本一致性不匹配则拒绝部署。5.4 问题Agent在飞书机器人中返回乱码如“æ¥ä¸ä¸2024å¹´Q3