<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>Ruby China 社区 Rails 节点</title>
    <link>https://ruby-china.org/</link>
    <description>Ruby China 社区 Rails 节点最新发帖。</description>
    <item>
      <title>新书《Rails 8 现代单体架构实战》发布</title>
      <description>&lt;p&gt;2024 年，我在 RubyConf China 上做了《下一个十年的 Monolith》讲演：&lt;a href="https://www.bilibili.com/video/BV1R2cceWEvS" rel="nofollow" target="_blank"&gt;https://www.bilibili.com/video/BV1R2cceWEvS&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;但是大会一个主题的时间毕竟有限，所以感觉还有很多东西没有讲到或讲透，加上这两年里 Rails 8、8.1 的接连推出，让 Rails 在这个领域不断发展。因此我觉得有必要写一本书来对这个主题——我称之为“现代单体架构（Modern Monolith）”——做一些更深入的阐述和探讨。&lt;/p&gt;

&lt;p&gt;经过近半年的时间，我的这本书终于完成，正式发布在 Leanpub。&lt;/p&gt;

&lt;p&gt;购买链接：&lt;a href="https://leanpub.com/therails8modernmonolithinaction" rel="nofollow" target="_blank"&gt;https://leanpub.com/therails8modernmonolithinaction&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/apexy/12897218-ee7e-48c4-a545-55b7454132cb.png!large" title="" alt=""&gt;&lt;/p&gt;
&lt;h2 id="新书介绍"&gt;新书介绍&lt;/h2&gt;
&lt;p&gt;俗话说“天下大势，合久必分，分久必合”。&lt;/p&gt;

&lt;p&gt;对于 Web 应用来说，经过微服务、前后端分离的十多年流行之后，随着硬件和网络的进步，回归单体架构的趋势开始出现了。越来越多的开发者、团队或公司，都在逐渐认识到这一变化。&lt;/p&gt;

&lt;p&gt;Rails 8 通过一系列新特性，填平了旧时代单体架构面临的技术鸿沟，构筑起了现代单体架构（Modern Monolith）的新体系。&lt;/p&gt;

&lt;p&gt;阅读这本书，通过一个实际 Rails 应用的完整开发到上线的全过程，你将会深刻理解并掌握到：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Rails 8 两种不同的现代单体架构（标准现代单体、组件驱动单体）的详细实战运用；&lt;/li&gt;
&lt;li&gt;从产品设计到部署上线的 Rails 应用产品的真实迭代开发流程全貌；&lt;/li&gt;
&lt;li&gt;涵盖测试金字塔、持续集成、持续部署、Storybook 等业界真正工业级的软件工程实践模式；&lt;/li&gt;
&lt;li&gt;各种 Ruby/Rails 开发的高级经验和技巧；&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;如果你热爱 Ruby，想了解 Rails 8 的最新特性，并且愿意看看现代单体架构如何实战落地，这本书就是为你写的。&lt;/p&gt;
&lt;h2 id="新书样章"&gt;新书样章&lt;/h2&gt;
&lt;p&gt;在 &lt;a href="https://leanpub.com/therails8modernmonolithinaction" rel="nofollow" target="_blank" title=""&gt;Leanpub 新书页面&lt;/a&gt;有新书试读样章可以直接免费下载。&lt;/p&gt;

&lt;p&gt;另外还有内容更多一些的社区版（Community Edition），可以通过邮件免费获取。&lt;/p&gt;
&lt;h2 id="社区折扣"&gt;社区折扣&lt;/h2&gt;
&lt;p&gt;既为了庆祝新书发布，也为了庆祝世界杯开赛，我为 Ruby China 社区准备了 20 个名额的五折优惠券。&lt;/p&gt;

&lt;p&gt;有兴趣的朋友可以私信联系我（备注 rubychina book）。&lt;/p&gt;

&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/apexy/87874599-5d0a-44cf-afbc-4b1c61be7d8b.jpg!large" title="" alt=""&gt;&lt;/p&gt;</description>
      <author>apexy</author>
      <pubDate>Wed, 10 Jun 2026 11:50:47 +0800</pubDate>
      <link>https://ruby-china.org/topics/44597</link>
      <guid>https://ruby-china.org/topics/44597</guid>
    </item>
    <item>
      <title>rails 项目里用 kamal 部署阿里云 ssl 证书流程总结</title>
      <description>&lt;h3 id="背景"&gt;背景&lt;/h3&gt;
&lt;p&gt;在我的部署过程中，有些域名总是无法成功自动申请 ssl 证书，暂时我也没搞清为什么无法自动申请成功，先尝试在阿里云申请 ssl 证书然后部署，现流程跑通特记录加深印象。&lt;/p&gt;
&lt;h2 id="以下是使用 Kamal 将阿里云SSL 证书自定义 部署的完整流程："&gt;以下是使用 Kamal 将阿里云 SSL 证书自定义 部署的完整流程：&lt;/h2&gt;&lt;h3 id="一、准备 SSL 证书文件"&gt;一、准备 SSL 证书文件&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;获取证书：从阿里云获取 SSL 证书

&lt;ul&gt;
&lt;li&gt;证书文件（ .pem）&lt;/li&gt;
&lt;li&gt;私钥文件（ .key）
&lt;img src="https://l.ruby-china.com/photo/flchenhp/12103573-a33c-4dfd-8520-03933d185fd1.png!large" title="" alt=""&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;注意：只需要申请主域名如 caibaoying.fun 的 ssl 证书，不需要同时申请 www.caibaoying.fun 的，我就踩了一次坑，哈哈。&lt;/p&gt;
&lt;h3 id="二、创建本地证书存储"&gt;二、创建本地证书存储&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;创建存放证书的文件夹和文件：&lt;/li&gt;
&lt;li&gt;config/ssl&lt;/li&gt;
&lt;li&gt;config/ssl/certificate.crt - 合并后的证书链&lt;/li&gt;
&lt;li&gt;config/ssl/private.key - 私钥文件
&lt;img src="https://l.ruby-china.com/photo/flchenhp/7b2708f5-c665-421b-b3fe-4020f3bc0f9f.png!large" title="" alt=""&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;将证书和私钥分别复制粘贴至文件内
&lt;img src="https://l.ruby-china.com/photo/flchenhp/45a71a11-fe44-4961-8bdb-c700a437309e.png!large" title="" alt=""&gt;&lt;/p&gt;
&lt;h3 id="三、配置 .kamal/secrets"&gt;三、配置 .kamal/secrets&lt;/h3&gt;
&lt;p&gt;在 .kamal/secrets 文件中添加环境变量定义：
&lt;img src="https://l.ruby-china.com/photo/flchenhp/a1c80e12-8ab9-47e3-baf9-80f601b0c63a.png!large" title="" alt=""&gt;&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CERTIFICATE_PEM=$(cat config/ssl/certificate.crt)
PRIVATE_KEY_PEM=$(cat config/ssl/private.key)
&lt;/code&gt;&lt;/pre&gt;&lt;h3 id="四、配置 config/deploy.yml"&gt;四、配置 config/deploy.yml&lt;/h3&gt;&lt;pre class="highlight plaintext"&gt;&lt;code&gt;proxy:
  ssl:
    certificate_pem: CERTIFICATE_PEM    # 对应 secrets 中的变量名
    private_key_pem: PRIVATE_KEY_PEM    # 对应 secrets 中的变量名
  hosts:
    - www.caibaoying.fun
    - caibaoying.fun
&lt;/code&gt;&lt;/pre&gt;&lt;h3 id="五、部署验证"&gt;五、部署验证&lt;/h3&gt;&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bin/kamal deploy
&lt;/code&gt;&lt;/pre&gt;&lt;h3 id="六、打开网页测试"&gt;六、打开网页测试&lt;/h3&gt;
&lt;p&gt;看到网站可以通过 https 方式安全打开，成功！
&lt;img src="https://l.ruby-china.com/photo/flchenhp/7f3f4416-7ef1-4944-8c18-7f57c47f6ffb.png!large" title="" alt=""&gt;&lt;/p&gt;</description>
      <author>flchenhp</author>
      <pubDate>Wed, 03 Jun 2026 15:45:16 +0800</pubDate>
      <link>https://ruby-china.org/topics/44590</link>
      <guid>https://ruby-china.org/topics/44590</guid>
    </item>
    <item>
      <title>讓 ActiveRecord model 只能在特定 service class 裏被更新</title>
      <description>&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Post&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationRecord&lt;/span&gt;
  &lt;span class="kp"&gt;include&lt;/span&gt; &lt;span class="no"&gt;ActiveRecordReadOnly&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="c1"&gt;# Anywhere else in the codebase — blocked&lt;/span&gt;
&lt;span class="no"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;title: &lt;/span&gt;&lt;span class="s2"&gt;"x"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# =&amp;gt; ActiveRecord::ReadOnlyRecord&lt;/span&gt;

&lt;span class="c1"&gt;# In a service class — allowed&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PostService&lt;/span&gt;
  &lt;span class="kp"&gt;include&lt;/span&gt; &lt;span class="no"&gt;Post&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Writable&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;published: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# works&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;實驗後發現這是可行的，可以通過在 model 的&lt;code&gt;readonly?&lt;/code&gt;方法裏檢查&lt;code&gt;caller_locations&lt;/code&gt;來判斷能否處在一個已經&lt;code&gt;include Post::Writable&lt;/code&gt;的環境，不過也有不少限制。&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/onyxblade/active_record_read_only" rel="nofollow" target="_blank"&gt;https://github.com/onyxblade/active_record_read_only&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Just for fun&lt;/p&gt;</description>
      <author>mizuhashi</author>
      <pubDate>Fri, 22 May 2026 19:56:56 +0800</pubDate>
      <link>https://ruby-china.org/topics/44579</link>
      <guid>https://ruby-china.org/topics/44579</guid>
    </item>
    <item>
      <title>早说了转语言了身体却很诚实...</title>
      <description>&lt;h2 id="前情提要"&gt;前情提要&lt;/h2&gt;
&lt;p&gt;&lt;a href="https://ruby-china.org/topics/26838" rel="nofollow" target="_blank"&gt;https://ruby-china.org/topics/26838&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;现在我们还有 Rails 项目呢，兜兜转转这么多年看到自己写的这个帖子...&lt;/p&gt;

&lt;p&gt;没有哪种技术能让我像对 Rails 和 Ruby 一样，长长的年头里，总是会忍不住在某个需求里面落地。&lt;/p&gt;

&lt;p&gt;Rails/Ruby to da moon!&lt;/p&gt;</description>
      <author>shawnyu</author>
      <pubDate>Fri, 22 May 2026 12:35:12 +0800</pubDate>
      <link>https://ruby-china.org/topics/44578</link>
      <guid>https://ruby-china.org/topics/44578</guid>
    </item>
    <item>
      <title>gems.ruby-china.com 缺少 bigdecimal 4.1.1 版本</title>
      <description>&lt;p&gt;在使用 source "&lt;a href="https://gems.ruby-china.com" rel="nofollow" target="_blank"&gt;https://gems.ruby-china.com&lt;/a&gt;" 作为 Bundler 源时，发现 bigdecimal (4.1.1) 无法获取。&lt;/p&gt;

&lt;p&gt;具体情况如下：&lt;/p&gt;

&lt;p&gt;在升级 Rails 从 8.1.1 到 8.1.2 过程中，依赖解析需要使用 bigdecimal (4.1.1)
该版本在 &lt;a href="https://rubygems.org" rel="nofollow" target="_blank"&gt;https://rubygems.org&lt;/a&gt; 上是存在的，并且可以正常安装
但在 gems.ruby-china.com 镜像源中无法获取，导致 bundle install 失败，进而影响部署&lt;/p&gt;

&lt;p&gt;报错类似：&lt;/p&gt;

&lt;p&gt;Your bundle is locked to bigdecimal (4.1.1) ... but that version can no longer be found in that source.&lt;/p&gt;

&lt;p&gt;请问是否可以帮忙同步或刷新该版本的索引？&lt;/p&gt;

&lt;p&gt;感谢支持！&lt;/p&gt;</description>
      <author>shin</author>
      <pubDate>Sun, 05 Apr 2026 19:43:08 +0800</pubDate>
      <link>https://ruby-china.org/topics/44539</link>
      <guid>https://ruby-china.org/topics/44539</guid>
    </item>
    <item>
      <title>官网宣传语变成“省 Token” “方便 Agent 编写”</title>
      <description>&lt;p&gt;如图 😆
&lt;img src="https://l.ruby-china.com/photo/tistest/012edf78-3c8c-47ba-8bca-622c54023329.jpg!large" title="" alt=""&gt;&lt;/p&gt;</description>
      <author>tistest</author>
      <pubDate>Thu, 12 Mar 2026 10:14:25 +0800</pubDate>
      <link>https://ruby-china.org/topics/44516</link>
      <guid>https://ruby-china.org/topics/44516</guid>
    </item>
    <item>
      <title>发现 Rails 在 AI 时代好像挺适合做实验性质的项目的</title>
      <description>&lt;p&gt;最近组里在做一些数据生产端 AI 的探索，结果一个很直接的感受是：Rails 好像特别适合开发企业内部的 AI 实验项目，用起来贼爽。&lt;/p&gt;

&lt;p&gt;目前体感比较明显的点有两个：&lt;/p&gt;

&lt;p&gt;1 自带完善的测试框架，fixtures、test db 这些都很顺手，挺适合 vibe coding 的时候自己快速迭代&lt;/p&gt;

&lt;p&gt;因为这种项目本身就是实验性质，主打的就是一个又糙又快，老板恨不得 1 天之内就能试出结论，所以 Vibe Coding 基本是必不可少的。&lt;/p&gt;

&lt;p&gt;但问题在于，很多别的语言或框架，光是想让 AI 具备一套像样的自我验证能力，前面就得先折腾一轮。Rails 在这点上基本算是开箱即用，优势还是挺大的。&lt;/p&gt;

&lt;p&gt;2 Solid Queue 本地就能提供消息队列能力，碰到大模型 API 限流这类问题，处理起来也比较简单&lt;/p&gt;

&lt;p&gt;本地开箱即用、而且基本不需要额外依赖的消息队列，这一点其实也特别适合大模型相关场景。很多实验项目一开始并不想把基础设施铺得太重，能先在本机或者开发机上把流程跑通就已经很舒服了。&lt;/p&gt;</description>
      <author>willx</author>
      <pubDate>Wed, 11 Mar 2026 21:35:57 +0800</pubDate>
      <link>https://ruby-china.org/topics/44515</link>
      <guid>https://ruby-china.org/topics/44515</guid>
    </item>
    <item>
      <title>AI 开发 Rails，如果遇到 bug 看不懂咋办？</title>
      <description>&lt;p&gt;不懂 Rails，让 AI 写 rails。万一复杂度上来了，遇到问题了，又看不懂代码，咋办？&lt;/p&gt;</description>
      <author>ironboxer</author>
      <pubDate>Tue, 10 Mar 2026 12:04:13 +0800</pubDate>
      <link>https://ruby-china.org/topics/44512</link>
      <guid>https://ruby-china.org/topics/44512</guid>
    </item>
    <item>
      <title>一個用 DIDComm 實現服務器間通信的 Rails demo</title>
      <description>&lt;p&gt;&lt;a href="https://identity.foundation/didcomm-messaging/spec/v2.1/" rel="nofollow" target="_blank" title=""&gt;DIDComm&lt;/a&gt;是一個基於 W3C DID（去中心化 ID）的通信協議。如果一個服務器想和另一個實現了 DIDComm 的服務器通信，它會需要在/.well-known/did.json 提供一個 DID 文檔，文檔裏包含了公鑰以及 DIDComm 的 service endpoint。我的 demo 服務器的 DID 文檔長這樣：&lt;a href="https://dc.mbkr.ca/.well-known/did.json" rel="nofollow" target="_blank"&gt;https://dc.mbkr.ca/.well-known/did.json&lt;/a&gt; 。&lt;/p&gt;

&lt;p&gt;擁有了這個文檔之後，&lt;code&gt;did:web:dc.mbkr.ca&lt;/code&gt;這個 DID 就會對應我的服務器，你只需要這個 id 就可以給我發信息，發信方會根據這個 id 解析到 DID 文檔，獲取公鑰構建信息，並推到 service endpoint 上。&lt;/p&gt;

&lt;p&gt;我也做了一個公開的 demo 服務器， &lt;a href="https://dc-public.mbkr.ca/" rel="nofollow" target="_blank"&gt;https://dc-public.mbkr.ca/&lt;/a&gt; ，你可以用&lt;code&gt;public&lt;/code&gt;做密碼登錄。&lt;/p&gt;

&lt;p&gt;Demo 的 repo: &lt;a href="https://github.com/onyxblade/didcomm-rails-demo" rel="nofollow" target="_blank"&gt;https://github.com/onyxblade/didcomm-rails-demo&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;實現上，我本來是讓 AI 生成了一個 Ruby 版本的 DIDComm 實現，不過密碼學的內容很多，我最後還是決定直接用&lt;a href="https://github.com/sicpa-dlab/didcomm-rust" rel="nofollow" target="_blank" title=""&gt;didcomm-rust&lt;/a&gt;這個標準的參考實現。這個 Rust 的實現可以輸出 wasm，但 wasmtime-rb 好像還沒成熟到能直接用，所以最終我做了一個 HTTP 服務器&lt;a href="https://github.com/onyxblade/didcomm-http" rel="nofollow" target="_blank" title=""&gt;didcomm-http&lt;/a&gt;，來把 wasm 接口包裝成 HTTP API。&lt;/p&gt;

&lt;p&gt;Happy Hacking!&lt;/p&gt;</description>
      <author>mizuhashi</author>
      <pubDate>Wed, 04 Mar 2026 07:14:39 +0800</pubDate>
      <link>https://ruby-china.org/topics/44502</link>
      <guid>https://ruby-china.org/topics/44502</guid>
    </item>
    <item>
      <title>                                                                                                                                                       内容审核（头像审核、昵称审核）技术对比，阿里云内容安全审核 api 和大模型 LLM 的 PK 对比</title>
      <description>&lt;h2 id="一、背景与目标"&gt;一、背景与目标&lt;/h2&gt;&lt;h3 id="1.1 为什么需要做对比"&gt;1.1 为什么需要做对比&lt;/h3&gt;
&lt;p&gt;内容审核是社区、电商等业务的基础能力，需对用户上传的&lt;strong&gt;头像/图片&lt;/strong&gt;和&lt;strong&gt;昵称/文本&lt;/strong&gt;进行合规检测。我们之前一直使用的是阿里云的内容审核 api 来检测的，但是偶尔会有检测出错的情况，尤其是头像的广告引流，偶尔会有识别不出来的情况。因此考虑使用大模型能力来做个测试对比：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;传统审核 API&lt;/strong&gt;：如阿里云 profilePhotoCheck、text_scan 等&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LLM 多模态审核&lt;/strong&gt;：通过通义、GPT、Claude 等大模型的提示词限制来判断（我们是通过 Dify 工作流来提供的 api 接口来服务业务系统）&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;不同方案在&lt;strong&gt;耗时、费用、准确度&lt;/strong&gt;上差异显著，需要系统对比后做出技术选型。&lt;/p&gt;
&lt;h3 id="1.2 对比目标"&gt;1.2 对比目标&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;梳理各方案（阿里云、通义、GPT、Claude 等）的&lt;strong&gt;价格与准确性&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;通过实际测试，验证&lt;strong&gt;阿里云 API&lt;/strong&gt; 与 &lt;strong&gt;Dify + LLM&lt;/strong&gt; 的耗时、费用、准确度&lt;/li&gt;
&lt;li&gt;给出最终选型与实施建议&lt;/li&gt;
&lt;/ul&gt;

&lt;hr&gt;
&lt;h2 id="二、先直接给出结论"&gt;二、先直接给出结论&lt;/h2&gt;&lt;table class="table table-bordered table-striped"&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;th&gt;场景&lt;/th&gt;
&lt;th&gt;选型&lt;/th&gt;
&lt;th&gt;理由&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;图片审核&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Dify + qwen-vl-flash&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;费用约 7 元/万张，低于阿里云 15 元；耗时优于 qwen-vl-plus；准确度满足需求&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;昵称审核&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Dify + qwen-plus-latest&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;费用约 2.6 元/万次，低于阿里云 7.5 元；对引流、联系方式等更敏感&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;兜底/强合规&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;阿里云&lt;/td&gt;
&lt;td&gt;延迟低、稳定，可作为主流程失败或高并发时的补充&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h2 id="三、方案调研"&gt;三、方案调研&lt;/h2&gt;&lt;h3 id="3.1 昵称/文本审核：准确性与价格"&gt;3.1 昵称/文本审核：准确性与价格&lt;/h3&gt;&lt;table class="table table-bordered table-striped"&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;th&gt;方案&lt;/th&gt;
&lt;th&gt;准确性&lt;/th&gt;
&lt;th&gt;价格 (元/万次)&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;通义 qwen-plus&lt;/td&gt;
&lt;td&gt;高&lt;/td&gt;
&lt;td&gt;约 3.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;通义 qwen-max&lt;/td&gt;
&lt;td&gt;最高&lt;/td&gt;
&lt;td&gt;约 50–60（按 250 input + 80 output token 估算）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;通义 qwen-turbo&lt;/td&gt;
&lt;td&gt;中高&lt;/td&gt;
&lt;td&gt;约 1.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepSeek-V3&lt;/td&gt;
&lt;td&gt;中&lt;/td&gt;
&lt;td&gt;约 7.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-4o&lt;/td&gt;
&lt;td&gt;精确率高、召回率低&lt;/td&gt;
&lt;td&gt;约 4–5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;&lt;h3 id="3.2 头像/图片审核：准确性与价格"&gt;3.2 头像/图片审核：准确性与价格&lt;/h3&gt;&lt;table class="table table-bordered table-striped"&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;th&gt;方案&lt;/th&gt;
&lt;th&gt;准确性&lt;/th&gt;
&lt;th&gt;价格 (元/万张)&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen-VL-Max&lt;/td&gt;
&lt;td&gt;最高&lt;/td&gt;
&lt;td&gt;约 19.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;阿里云 profilePhotoCheck&lt;/td&gt;
&lt;td&gt;高（专项）&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen-VL-Plus&lt;/td&gt;
&lt;td&gt;中高&lt;/td&gt;
&lt;td&gt;约 9.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen-VL-Flash&lt;/td&gt;
&lt;td&gt;中高&lt;/td&gt;
&lt;td&gt;约 7（实测）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude 3.5 Sonnet&lt;/td&gt;
&lt;td&gt;高&lt;/td&gt;
&lt;td&gt;约 200–300（按单张约 $0.003–0.004 换算）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-4o vision&lt;/td&gt;
&lt;td&gt;高&lt;/td&gt;
&lt;td&gt;约 20–45（随分辨率与 token 波动）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;&lt;h3 id="3.3 阿里云现有方案（对比基准）"&gt;3.3 阿里云现有方案（对比基准）&lt;/h3&gt;&lt;table class="table table-bordered table-striped"&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;th&gt;类型&lt;/th&gt;
&lt;th&gt;价格 (元/万次或元/万张)&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;文本审核（增强版）&lt;/td&gt;
&lt;td&gt;约 7.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;图片/头像审核（profilePhotoCheck）&lt;/td&gt;
&lt;td&gt;约 15&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h2 id="四、我们的实际对比测试"&gt;四、我们的实际对比测试&lt;/h2&gt;&lt;h3 id="4.1 测试设计"&gt;4.1 测试设计&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;图片&lt;/strong&gt;：来自我们实际生产数据的用户头像&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;昵称&lt;/strong&gt;：30 组预设样本（含正常、敏感、边界）&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="4.2 图片审核实测"&gt;4.2 图片审核实测&lt;/h3&gt;&lt;h4 id="4.2.1 qwen-vl-plus vs qwen-vl-flash（50 张图，统一 q100 jpg）"&gt;4.2.1 qwen-vl-plus vs qwen-vl-flash（50 张图，统一 q100 jpg）&lt;/h4&gt;
&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/yfscret/29ee1274-bbb8-49f1-975e-07e8233e57bc.png!large" title="" alt=""&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/yfscret/4c74040a-76be-48e2-b0f4-4e4b025f147b.png!large" title="" alt=""&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/yfscret/52fb40d1-ebad-4435-a994-41a49c99da1a.png!large" title="" alt=""&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;注意这张图，引流内容不容易被发现，阿里云 api 就检测通过了，大模型精准识别&lt;/li&gt;
&lt;/ul&gt;
&lt;table class="table table-bordered table-striped"&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;th&gt;方案&lt;/th&gt;
&lt;th&gt;总耗时 (ms)&lt;/th&gt;
&lt;th&gt;总费用 (元)&lt;/th&gt;
&lt;th&gt;单次平均耗时 (ms)&lt;/th&gt;
&lt;th&gt;万张预估费用 (元)&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;阿里云&lt;/td&gt;
&lt;td&gt;37,781&lt;/td&gt;
&lt;td&gt;0.075&lt;/td&gt;
&lt;td&gt;756&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;qwen-vl-plus-latest&lt;/td&gt;
&lt;td&gt;157,416&lt;/td&gt;
&lt;td&gt;0.048&lt;/td&gt;
&lt;td&gt;3,148&lt;/td&gt;
&lt;td&gt;9.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;qwen-vl-flash&lt;/td&gt;
&lt;td&gt;127,056&lt;/td&gt;
&lt;td&gt;0.035&lt;/td&gt;
&lt;td&gt;2,541&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;结论&lt;/strong&gt;：qwen-vl-flash 费用更低、耗时更短、准确率和 qwen-vl-plus-latest 相当，选型为图片审核方案。&lt;/p&gt;
&lt;h4 id="4.2.2 原图 vs 800px宽格式化为jpg(image/resize,w_800/quality,q_90/format,jpg"&gt;4.2.2 原图 vs 800px 宽格式化为 jpg(image/resize,w_800/quality,q_90/format,jpg&lt;/h4&gt;
&lt;p&gt;)（80 张图，同一 Dify 工作流）&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;做这个对比是因为现在用户的手机拍照随便都是 5M 起步，如果用原图的话，大模型先下载图片再转成输入 token 就比较大，比较慢了，所以先格式化为宽 800 的 jpg（十几 M 的图片变几百 K）用作大模型输入。
&lt;img src="https://l.ruby-china.com/photo/yfscret/4be68387-63c8-4a43-bb68-16f7124cedea.png!large" title="" alt=""&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;table class="table table-bordered table-striped"&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;th&gt;方案&lt;/th&gt;
&lt;th&gt;总耗时 (ms)&lt;/th&gt;
&lt;th&gt;总费用 (元)&lt;/th&gt;
&lt;th&gt;单次平均耗时 (ms)&lt;/th&gt;
&lt;th&gt;万张预估费用 (元)&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;阿里云&lt;/td&gt;
&lt;td&gt;66,126&lt;/td&gt;
&lt;td&gt;0.12&lt;/td&gt;
&lt;td&gt;827&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dify 原图&lt;/td&gt;
&lt;td&gt;253,539&lt;/td&gt;
&lt;td&gt;0.081&lt;/td&gt;
&lt;td&gt;3,169&lt;/td&gt;
&lt;td&gt;10.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dify 800px jpg(q90)&lt;/td&gt;
&lt;td&gt;281,114&lt;/td&gt;
&lt;td&gt;0.078&lt;/td&gt;
&lt;td&gt;3,514&lt;/td&gt;
&lt;td&gt;9.7&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;ul&gt;
&lt;li&gt; &lt;strong&gt;说明：&lt;/strong&gt; 上面这个测试的原图我们保存时候已经转过格式压缩过了，所以这个对比其实是失败的，没有测试出 几兆那种原图和格式化后的图在费用上的差异，写出来只是给大家提供一下思路。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;结论&lt;/strong&gt;：最终采用 800px jpg q90 jpg 作为统一输入，兼顾清晰度与体积。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="4.3 昵称审核实测（30 组，使用 qwen-plus-latest）"&gt;4.3 昵称审核实测（30 组，使用 qwen-plus-latest）&lt;/h3&gt;
&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/yfscret/35880fb5-0018-4da3-bf26-575c67d50d24.png!large" title="" alt=""&gt;&lt;/p&gt;
&lt;table class="table table-bordered table-striped"&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;th&gt;方案&lt;/th&gt;
&lt;th&gt;总耗时 (ms)&lt;/th&gt;
&lt;th&gt;总费用 (元)&lt;/th&gt;
&lt;th&gt;单次平均耗时 (ms)&lt;/th&gt;
&lt;th&gt;万次预估费用 (元)&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;阿里云&lt;/td&gt;
&lt;td&gt;7,370&lt;/td&gt;
&lt;td&gt;0.023&lt;/td&gt;
&lt;td&gt;246&lt;/td&gt;
&lt;td&gt;7.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dify + qwen-plus-latest&lt;/td&gt;
&lt;td&gt;53,419&lt;/td&gt;
&lt;td&gt;0.008&lt;/td&gt;
&lt;td&gt;1,781&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.6&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;&lt;h3 id="4.4 准确度对比"&gt;4.4 准确度对比&lt;/h3&gt;&lt;h4 id="图片审核"&gt;图片审核&lt;/h4&gt;&lt;table class="table table-bordered table-striped"&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;th&gt;能力&lt;/th&gt;
&lt;th&gt;阿里云&lt;/th&gt;
&lt;th&gt;LLM (qwen-vl)&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;政治敏感（领导人等）&lt;/td&gt;
&lt;td&gt;识别&lt;/td&gt;
&lt;td&gt;识别（含 inappropriate content 拒绝）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;平台水印/引流（抖音、小红书）&lt;/td&gt;
&lt;td&gt;识别&lt;/td&gt;
&lt;td&gt;识别&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;饰品/合规展示&lt;/td&gt;
&lt;td&gt;通过&lt;/td&gt;
&lt;td&gt;通过，可附带说明&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;复杂场景/边界&lt;/td&gt;
&lt;td&gt;规则为主&lt;/td&gt;
&lt;td&gt;语义理解更细&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;&lt;h4 id="昵称审核"&gt;昵称审核&lt;/h4&gt;&lt;table class="table table-bordered table-striped"&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;th&gt;测试用例&lt;/th&gt;
&lt;th&gt;阿里云&lt;/th&gt;
&lt;th&gt;Dify + qwen-plus-latest&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;小明、翠翠、珠宝爱好者&lt;/td&gt;
&lt;td&gt;通过&lt;/td&gt;
&lt;td&gt;通过&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VX123456、加我微信&lt;/td&gt;
&lt;td&gt;不通过&lt;/td&gt;
&lt;td&gt;不通过（联系方式引流）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;xhs_abc&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;通过&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;不通过&lt;/strong&gt;（含引流信息）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;闲鱼号 123&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;通过&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;不通过&lt;/strong&gt;（联系方式引流）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;结论&lt;/strong&gt;：qwen-plus-latest 对引流、联系方式类昵称更敏感，拦截更严格。&lt;/p&gt;

&lt;hr&gt;
&lt;h3 id="4.5 我们的 dify 工作流"&gt;4.5 我们的 dify 工作流&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;其实也可以不用 dify 工作流，直接在代码里调用大模型来识别，不过 dify 的好处就是切换大模型简单，可以随时更新其他大模型。
&lt;img src="https://l.ruby-china.com/photo/yfscret/287c59c3-b20e-4849-9125-3f57f4b49c0a.png!large" title="" alt=""&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;说明&lt;/strong&gt;：大模型的识别能力非常依赖提示词，但我们这种识别其实提示词很简单，后续如果有识别不出来的，把提示词修改一下即可。举例：比如昵称是”你们公司真下头“，这个调用传统内容审核 api 是通过的，但我们不想让他通过，使用传统内容审核就不好定制，但使用大模型的话，我们只用在提示词加一句，如果内容包含”下头“则视为审核不通过。大模型就会按审核不通过处理。是不是&lt;strong&gt;特别灵活&lt;/strong&gt;。&lt;/p&gt;
&lt;h3 id="4.6 关于费用怎么对比"&gt;4.6 关于费用怎么对比&lt;/h3&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;阿里云内容审核 api 有明确的文档说明每次调用多少钱&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;大模型是按输入输出 token 计费的，我们用来 format w800 jpg 格式的图片的话，那输入 token 就是图片和提示词，基本可以确定，输出我们也是严格限制了格式，所以也是确定的。另外 dify 每次调用都会返回 useage，我们保存到数据库即可。方便我们选型和统计
&lt;img src="https://l.ruby-china.com/photo/yfscret/a14162e0-7526-4ea1-8e8c-4e53f2ef3916.png!large" title="" alt=""&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="五、阿里云 API vs LLM 优劣势"&gt;五、阿里云 API vs LLM 优劣势&lt;/h2&gt;&lt;h3 id="5.1 阿里云内容审核 API"&gt;5.1 阿里云内容审核 API&lt;/h3&gt;&lt;table class="table table-bordered table-striped"&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;th&gt;优势&lt;/th&gt;
&lt;th&gt;劣势&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;延迟低&lt;/strong&gt;：图片约 800 ms，文本约 250 ms&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;费用高&lt;/strong&gt;：图片 15 元/万张，文本 7.5 元/万次&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;稳定&lt;/strong&gt;：成熟商用接口，SLA 保障&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;规则化&lt;/strong&gt;：依赖预设规则，边界场景易漏检&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;部署简单&lt;/strong&gt;：HTTP 调用即可&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;定制难&lt;/strong&gt;：无法按业务语义微调策略&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;合规背书&lt;/strong&gt;：符合监管要求&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;结果简单&lt;/strong&gt;：多为 pass/block，缺少详细说明&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;&lt;h3 id="5.2 LLM 多模态审核"&gt;5.2 LLM 多模态审核&lt;/h3&gt;&lt;table class="table table-bordered table-striped"&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;th&gt;优势&lt;/th&gt;
&lt;th&gt;劣势&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;成本低&lt;/strong&gt;：图片约 7 元/万张，文本约 2.6 元/万次，还有更低成本的大模型可选&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;延迟高&lt;/strong&gt;：图片 2.5–3 s，文本约 1.8 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;语义理解好&lt;/strong&gt;：能识别「饰品展示」「联系方式引流」等&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;依赖模型&lt;/strong&gt;：不当内容可能返回 400，需专门处理&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;结果更细&lt;/strong&gt;：可返回原因说明，便于运营排查&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;需自建工作流&lt;/strong&gt;：需维护 Dify 与模型配置&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;可定制&lt;/strong&gt;：prompt 可调整审核策略&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;稳定性&lt;/strong&gt;：依赖第三方模型服务可用性&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h2 id="六、最终选型与实施建议"&gt;六、最终选型与实施建议&lt;/h2&gt;&lt;h3 id="6.1 选型结论"&gt;6.1 选型结论&lt;/h3&gt;&lt;table class="table table-bordered table-striped"&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;th&gt;场景&lt;/th&gt;
&lt;th&gt;选型&lt;/th&gt;
&lt;th&gt;理由&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;图片审核&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Dify + qwen-vl-flash&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;费用最低（约 7 元/万张），耗时优于 plus，准确度满足需求&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;昵称审核&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Dify + qwen-plus-latest&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;费用低，对引流/联系方式更敏感&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;兜底/强合规&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;阿里云&lt;/td&gt;
&lt;td&gt;低延迟、稳定，可作为主流程失败或高并发时的补充&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;&lt;h3 id="6.2 实施建议"&gt;6.2 实施建议&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;图片输入&lt;/strong&gt;：统一使用 format q90 jpg（oss-process），平衡清晰度与体积&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;异常处理&lt;/strong&gt;：Dify 返回 400 且含「inappropriate content」时，按不通过处理&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;混合策略&lt;/strong&gt;：高并发或要求极低延迟时，可考虑阿里云；长尾审核、成本敏感时用 LLM&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;持续监控&lt;/strong&gt;：记录耗时、费用、通过率，定期对比不同方案表现&lt;/li&gt;
&lt;/ol&gt;</description>
      <author>yfscret</author>
      <pubDate>Fri, 13 Feb 2026 16:08:53 +0800</pubDate>
      <link>https://ruby-china.org/topics/44480</link>
      <guid>https://ruby-china.org/topics/44480</guid>
    </item>
    <item>
      <title>Rails 技术实战之 AI 找货小助手设计</title>
      <description>&lt;h2 id="AI 找货助手技术实现指南"&gt;AI 找货助手技术实现指南&lt;/h2&gt;
&lt;p&gt;最近阿里云千问的"点奶茶"技能很火，碾压了微信红包的 AI 玩法。这波 AI 热潮下，我们在 APP 里也做了一个 AI 找货功能。&lt;/p&gt;

&lt;p&gt;用户搜"温润的手镯"，传统搜索只认"温润"和"手镯"两个字。但用户真正想要的是和田玉——因为"温润"是和田玉的特征。&lt;/p&gt;

&lt;p&gt;这篇讲讲怎么用向量搜索 + AI 对话，让系统理解用户真正想要什么。淘宝、京东、美团、携程都有类似功能，我们这个还比较粗浅，讲个大概，体验看起来还有待优化，还请包涵。&lt;/p&gt;
&lt;h2 id="需求预览"&gt;需求预览&lt;/h2&gt;
&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/ThxFly/2be068bc-c934-4141-a36b-bc6ff941a9cf.png!large" width="40%"&gt;
&lt;img src="https://l.ruby-china.com/photo/ThxFly/6327870c-2c7f-49b4-b12d-bedad2ed38e6.png!large" width="40%"&gt;&lt;/p&gt;

&lt;hr&gt;
&lt;h2 id="目录"&gt;目录&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="#%E6%A0%B8%E5%BF%83%E6%A6%82%E5%BF%B5" title=""&gt;核心概念&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#%E4%B8%80%E6%8A%80%E6%9C%AF%E6%9E%B6%E6%9E%84%E6%80%BB%E8%A7%88" title=""&gt;一、技术架构总览&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#%E4%BA%8Csse-%E4%BA%8B%E4%BB%B6%E8%AE%BE%E8%AE%A1" title=""&gt;二、SSE 事件设计&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#%E4%B8%89dify-%E5%AF%B9%E8%AF%9D%E6%B5%81%E8%AE%BE%E8%AE%A1%E8%AF%A6%E8%A7%A3" title=""&gt;三、Dify 对话流设计详解&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#%E5%9B%9B%E5%90%91%E9%87%8F%E6%90%9C%E7%B4%A2%E6%8A%80%E6%9C%AF%E8%AF%A6%E8%A7%A3" title=""&gt;四、向量搜索技术详解&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#%E4%BA%94%E5%95%86%E5%93%81%E8%AF%AD%E4%B9%89%E6%90%9C%E7%B4%A2%E6%A8%A1%E5%9D%97%E8%AF%A6%E8%A7%A3" title=""&gt;五、商品语义搜索模块详解&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#%E5%85%ADknn-%E6%8C%89%E5%88%86%E7%B1%BB%E9%85%8D%E7%BD%AE" title=""&gt;六、KNN 按分类配置&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#%E4%B8%83%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98%E4%B8%8E%E6%95%85%E9%9A%9C%E6%8E%92%E6%9F%A5" title=""&gt;七、常见问题与故障排查&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;hr&gt;
&lt;h2 id="核心概念"&gt;核心概念&lt;/h2&gt;&lt;table class="table table-bordered table-striped"&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;th&gt;概念&lt;/th&gt;
&lt;th&gt;说明&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dify&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;开源 LLM 应用开发平台，我们用它做工作流编排。Dify 部署成独立服务，Rails 后端通过 HTTP API 调用。&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SSE&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Server-Sent Events，服务器主动推送数据给前端，比 WebSocket 简单，适合这种单向实时推送的场景。&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;向量搜索&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;把文本转成 1024 维向量，算向量相似度来找商品。比如"温润的手镯"能匹配到"和田玉手镯"。&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;KNN&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;K 最近邻算法，在向量空间里找最相似的 K 个商品。K 越大结果越多，但也越慢。&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Embedding&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;把文本转成向量的过程，语义相近的文本向量也相近。&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;HNSW&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;一种高效的向量索引算法，ES 8.x 原生支持，用来加速 KNN 搜索。&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h2 id="一、技术架构总览"&gt;一、技术架构总览&lt;/h2&gt;&lt;h3 id="1.1 整体架构图"&gt;1.1 整体架构图&lt;/h3&gt;
&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/ThxFly/dbcdcb6e-dcd6-464a-b80c-e5fb40bc937e.png!large" width="80%"&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;整体流程&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;用户在前端输入需求 → SSE 建立长连接 → Rails 把请求转给 Dify → Dify 识别意图、提取参数 → Dify 调用 Rails 的搜索接口 → Rails 生成查询向量 → ES 做 KNN 向量搜索 → 结果沿路返回 → 前端展示。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;几个关键点&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;前端和 Rails 之间用 SSE，服务器主动推送，不用轮询。&lt;/li&gt;
&lt;li&gt;Dify 是外部服务，Rails 只负责转发和调用搜索接口。&lt;/li&gt;
&lt;li&gt;搜索用的是向量搜索，不是传统的关键词匹配。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;核心配置&lt;/strong&gt;&lt;/p&gt;
&lt;table class="table table-bordered table-striped"&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;th&gt;配置项&lt;/th&gt;
&lt;th&gt;环境变量&lt;/th&gt;
&lt;th&gt;作用&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dify API 地址&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DIFY_API_BASE_URL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Dify 服务的 URL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dify API Key&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DIFY_CHATFLOW_API_KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;调用工作流的认证密钥&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;核心接口&lt;/strong&gt;&lt;/p&gt;
&lt;table class="table table-bordered table-striped"&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;th&gt;接口&lt;/th&gt;
&lt;th&gt;路径&lt;/th&gt;
&lt;th&gt;谁调用&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSE 对话接口&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POST /api/v1/chat/stream&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;前端&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;商品语义搜索接口&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POST /api/v1/products/ai_search&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Dify 工作流&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;&lt;h3 id="1.2 商品列表接口"&gt;1.2 商品列表接口&lt;/h3&gt;
&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/ThxFly/cb249fea-8de7-43fe-bbd1-bc4126dc6fef.png!large" width="80%"&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;GET /api/v1/products&lt;/code&gt; 这个接口支持两种搜索方式：普通搜索（传 q、category_id、price_min/max 等参数），和 ai_search_id 搜索（传之前搜索的 ID，复用搜索参数）。&lt;/p&gt;

&lt;p&gt;使用场景是这样的：用户在对话里看到商品列表，想看更多，点"查看更多"按钮，前端把之前的 ai_search_id 传过来，后端直接从数据库捞出之前的搜索参数，做分页查询。&lt;/p&gt;
&lt;h3 id="1.3 数据流转"&gt;1.3 数据流转&lt;/h3&gt;
&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/ThxFly/f05cd6c7-8ab3-475a-a8cf-1729eff4cec0.png!large" width="100%"&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;两个关键点&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;商品向量是上架时生成的，存在 ES 里；查询向量是每次搜索实时生成的。&lt;/li&gt;
&lt;li&gt;向量生成失败了怎么办？降级处理，返回空结果或者转关键词搜索。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="1.4 技术栈"&gt;1.4 技术栈&lt;/h3&gt;&lt;table class="table table-bordered table-striped"&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;th&gt;组件&lt;/th&gt;
&lt;th&gt;选型&lt;/th&gt;
&lt;th&gt;说明&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;工作流引擎&lt;/td&gt;
&lt;td&gt;Dify&lt;/td&gt;
&lt;td&gt;最新版，做 AI 流程编排&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM&lt;/td&gt;
&lt;td&gt;通义千问 qwen-plus-latest&lt;/td&gt;
&lt;td&gt;意图理解、参数提取&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;搜索引擎&lt;/td&gt;
&lt;td&gt;Elasticsearch 8.x&lt;/td&gt;
&lt;td&gt;商品索引 + KNN 向量搜索&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;后端框架&lt;/td&gt;
&lt;td&gt;Ruby on Rails 8.x&lt;/td&gt;
&lt;td&gt;业务逻辑&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h2 id="二、SSE 事件设计"&gt;二、SSE 事件设计&lt;/h2&gt;&lt;h3 id="2.1 事件流程图"&gt;2.1 事件流程图&lt;/h3&gt;
&lt;p&gt;&lt;img src="https://l.ruby-china.com/photo/ThxFly/b7b1e0cf-fde5-48f5-b97e-163c07e9653d.png!large" width="100%"&gt;&lt;/p&gt;
&lt;h3 id="2.2 事件类型"&gt;2.2 事件类型&lt;/h3&gt;&lt;table class="table table-bordered table-striped"&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;th&gt;事件类型&lt;/th&gt;
&lt;th&gt;触发时机&lt;/th&gt;
&lt;th&gt;作用&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;system_message&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;连接建立后、stream_end 前&lt;/td&gt;
&lt;td&gt;系统提示，比如"正在匹配货品中"，或者错误提示&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;message_start&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;第一次收到 Dify 的 message&lt;/td&gt;
&lt;td&gt;告诉前端准备接收消息&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;message&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;收到 Dify 的 message&lt;/td&gt;
&lt;td&gt;真正推送内容，可能拆成多个片段&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;thought&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Dify 节点开始处理&lt;/td&gt;
&lt;td&gt;显示"正在处理：xxx"，让用户知道进度&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;message_end&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;收到 Dify 的 message_end&lt;/td&gt;
&lt;td&gt;一条消息发送完成&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;stream_end&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;整个流程结束&lt;/td&gt;
&lt;td&gt;前端可以关闭连接了&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;msg_type 的取值&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;message_start 和 message 的 msg_type：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Dify 返回 &lt;code&gt;PRODUCT_CARD&lt;/code&gt; → &lt;code&gt;product_card&lt;/code&gt;（商品卡片）&lt;/li&gt;
&lt;li&gt;Dify 返回 &lt;code&gt;CLARIFY_CARD&lt;/code&gt; → &lt;code&gt;clarify_card&lt;/code&gt;（追问卡片）&lt;/li&gt;
&lt;li&gt;Dify 返回 &lt;code&gt;TEXT&lt;/code&gt; → &lt;code&gt;text&lt;/code&gt;（普通文本）&lt;/li&gt;
&lt;li&gt;其他情况默认 &lt;code&gt;text&lt;/code&gt;，结构化内容可能是 &lt;code&gt;blocks&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;system_message 的 msg_type：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;show_thinking&lt;/code&gt;：显示思考过程&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;hide_thinking&lt;/code&gt;：隐藏思考过程&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;text&lt;/code&gt;：普通系统文本&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;retry&lt;/code&gt;：可重试的错误&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="2.3 事件数据格式"&gt;2.3 事件数据格式&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;message_start&lt;/strong&gt;&lt;/p&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"event"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"message_start"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"msg_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"uuid"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"msg_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"assistant"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"task_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"uuid"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;message&lt;/strong&gt;&lt;/p&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"event"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"msg_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"uuid"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"这是消息内容"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"task_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"uuid"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;message_end&lt;/strong&gt;&lt;/p&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"event"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"message_end"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"msg_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"uuid"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"success"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"usage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"prompt_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"completion_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"total_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;stream_end&lt;/strong&gt;&lt;/p&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"event"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stream_end"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"conversation_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"uuid"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"success"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;system_message&lt;/strong&gt;&lt;/p&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"event"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"system_message"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"uuid"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"msg_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"system"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"正在匹配货品中"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"user_message_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;123&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;thought&lt;/strong&gt;&lt;/p&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"event"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"thought"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"msg_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"uuid"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"正在处理：属性提取"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"task_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"uuid"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;h3 id="2.4 事件顺序和处理规范"&gt;2.4 事件顺序和处理规范&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;标准顺序&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;连接建立后，先发 &lt;code&gt;system_message&lt;/code&gt;（show_thinking）告诉用户"正在匹配"&lt;/li&gt;
&lt;li&gt;收到 Dify 的 message，第一次发 &lt;code&gt;message_start&lt;/code&gt;，然后发 &lt;code&gt;message&lt;/code&gt;（可能多次）&lt;/li&gt;
&lt;li&gt;收到 &lt;code&gt;node_started&lt;/code&gt; 时可以发 &lt;code&gt;thought&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;收到 &lt;code&gt;message_end&lt;/code&gt; 时发 &lt;code&gt;message_end&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;有系统消息（错误或提示）发 &lt;code&gt;system_message&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;最后发 &lt;code&gt;stream_end&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;错误处理&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;错误不走单独的 error 事件，统一用 &lt;code&gt;system_message&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;msg_type&lt;/code&gt; 是 &lt;code&gt;retry&lt;/code&gt; 表示可重试的错误&lt;/li&gt;
&lt;li&gt;不管成功失败，最后都会发 &lt;code&gt;stream_end&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;前端注意事项&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;message&lt;/code&gt; 是流式的，前端要累积内容直到收到 &lt;code&gt;message_end&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;根据 &lt;code&gt;msg_type&lt;/code&gt; 决定怎么渲染&lt;/li&gt;
&lt;li&gt;收到 &lt;code&gt;stream_end&lt;/code&gt; 就关闭连接&lt;/li&gt;
&lt;/ul&gt;

&lt;hr&gt;
&lt;h2 id="三、Dify 对话流设计详解"&gt;三、Dify 对话流设计详解&lt;/h2&gt;&lt;h3 id="3.1 工作流核心结构"&gt;3.1 工作流核心结构&lt;/h3&gt;
&lt;p&gt;我们用 Dify 的节点式编排来做对话流，核心节点如下：&lt;/p&gt;
&lt;table class="table table-bordered table-striped"&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;th&gt;节点类型&lt;/th&gt;
&lt;th&gt;作用&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Start&lt;/td&gt;
&lt;td&gt;接收用户输入和认证信息&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Question Classifier&lt;/td&gt;
&lt;td&gt;意图识别，分成 4 种意图&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM&lt;/td&gt;
&lt;td&gt;属性提取，把用户输入转成结构化参数&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;If-Else&lt;/td&gt;
&lt;td&gt;判断要不要追问用户&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM&lt;/td&gt;
&lt;td&gt;生成追问话术和选项按钮&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP Request&lt;/td&gt;
&lt;td&gt;调用后端搜索接口&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Code&lt;/td&gt;
&lt;td&gt;结果合并，各分支互斥，只返回其中一个结果&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;工作流里的变量：环境变量 &lt;code&gt;HTTP_DOMAIN&lt;/code&gt; 存 API 域名。&lt;/p&gt;
&lt;h3 id="3.2 Start 节点"&gt;3.2 Start 节点&lt;/h3&gt;
&lt;p&gt;起点，接收前端传来的变量：&lt;/p&gt;
&lt;table class="table table-bordered table-striped"&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;th&gt;变量&lt;/th&gt;
&lt;th&gt;类型&lt;/th&gt;
&lt;th&gt;说明&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;intent&lt;/td&gt;
&lt;td&gt;string&lt;/td&gt;
&lt;td&gt;用户意图标识&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;authorization&lt;/td&gt;
&lt;td&gt;string&lt;/td&gt;
&lt;td&gt;Bearer Token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;version_date&lt;/td&gt;
&lt;td&gt;string&lt;/td&gt;
&lt;td&gt;版本日期（可选）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;&lt;h3 id="3.3 意图识别"&gt;3.3 意图识别&lt;/h3&gt;
&lt;p&gt;用 Dify 的 Question Classifier 节点，把用户请求分成 4 类：&lt;/p&gt;
&lt;table class="table table-bordered table-striped"&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;th&gt;分类 ID&lt;/th&gt;
&lt;th&gt;名称&lt;/th&gt;
&lt;th&gt;例子&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;找货意图&lt;/td&gt;
&lt;td&gt;"找翡翠手镯"、"想买和田玉"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;知识问答&lt;/td&gt;
&lt;td&gt;"怎么鉴别翡翠 A 货"、"如何保养和田玉"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;App 问答&lt;/td&gt;
&lt;td&gt;"怎么注册账号"、"如何发布商品"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;闲聊&lt;/td&gt;
&lt;td&gt;"你好"、"谢谢"、"再见"、"今天天气怎么样"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;模型配置：通义千问 qwen-plus-latest，Temperature 设 0.3 保证分类稳定，对话记忆保留最近 10 轮。&lt;/p&gt;
&lt;h3 id="3.4 属性提取"&gt;3.4 属性提取&lt;/h3&gt;
&lt;p&gt;从用户输入里提取搜索参数，用 LLM 节点，模型也是通义千问，Temperature 0.3。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;q 字段（核心搜索描述）&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;要包含明确的商品品类词&lt;/li&gt;
&lt;li&gt;50-100 字的自然语言描述&lt;/li&gt;
&lt;li&gt;有些默认规则：用户说"玉石"没具体说是和田玉还是翡翠，默认当和田玉处理；说"高货"默认当翡翠处理。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;价格范围&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;price_min&lt;/code&gt; 只有满足"品类 + 高价值信号 + 高价值形态 + 无瑕疵"时才推断。比如用户说"高品质玻璃种翡翠手镯，无纹无裂"，才可能推断最低价。&lt;/p&gt;

&lt;p&gt;&lt;code&gt;price_max&lt;/code&gt; 按用户字面描述来，用户说"5000 元左右"就设 5000。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;分类映射&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;1=&amp;gt;翡翠，2=&amp;gt;玉石，3=&amp;gt;钻石，4=&amp;gt;彩宝，39=&amp;gt;书画，40=&amp;gt;黄金，107=&amp;gt;黄金饰品，109=&amp;gt;文玩古玩，110=&amp;gt;钱币邮票&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;输出字段&lt;/strong&gt;&lt;/p&gt;
&lt;table class="table table-bordered table-striped"&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;th&gt;字段&lt;/th&gt;
&lt;th&gt;类型&lt;/th&gt;
&lt;th&gt;必填&lt;/th&gt;
&lt;th&gt;说明&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;q&lt;/td&gt;
&lt;td&gt;string&lt;/td&gt;
&lt;td&gt;是&lt;/td&gt;
&lt;td&gt;核心搜索描述&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;price_min/max&lt;/td&gt;
&lt;td&gt;number/null&lt;/td&gt;
&lt;td&gt;否&lt;/td&gt;
&lt;td&gt;价格范围（元）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;category_id&lt;/td&gt;
&lt;td&gt;number/null&lt;/td&gt;
&lt;td&gt;否&lt;/td&gt;
&lt;td&gt;商品分类 ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;inner_circle_size_min/max&lt;/td&gt;
&lt;td&gt;number/null&lt;/td&gt;
&lt;td&gt;否&lt;/td&gt;
&lt;td&gt;圈口尺寸（mm）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;heat_min/max&lt;/td&gt;
&lt;td&gt;number/null&lt;/td&gt;
&lt;td&gt;否&lt;/td&gt;
&lt;td&gt;参与热度范围&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;is_uncertain&lt;/td&gt;
&lt;td&gt;boolean&lt;/td&gt;
&lt;td&gt;否&lt;/td&gt;
&lt;td&gt;是否模糊需追问&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;has_discount&lt;/td&gt;
&lt;td&gt;boolean&lt;/td&gt;
&lt;td&gt;否&lt;/td&gt;
&lt;td&gt;是否要优惠商品&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;negative_filters&lt;/td&gt;
&lt;td&gt;array[string]&lt;/td&gt;
&lt;td&gt;否&lt;/td&gt;
&lt;td&gt;排除关键词&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;&lt;h3 id="3.5 条件判断"&gt;3.5 条件判断&lt;/h3&gt;
&lt;p&gt;检查 &lt;code&gt;is_uncertain&lt;/code&gt; 字段，&lt;code&gt;true&lt;/code&gt; 就进入追问分支，&lt;code&gt;false&lt;/code&gt; 直接搜索。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;触发追问的例子&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"想买个手镯" → 缺少价格、材质，追问&lt;/li&gt;
&lt;li&gt;"5000 元的" → 缺少商品类型，追问&lt;/li&gt;
&lt;li&gt;"翡翠手镯，5000-8000 元" → 信息完整，直接搜&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="3.6 生成追问"&gt;3.6 生成追问&lt;/h3&gt;
&lt;p&gt;当需要追问时，用 LLM 生成话术和选项。Temperature 设 0.7 高一点，让回答更有创造性。&lt;/p&gt;

&lt;p&gt;生成规则：&lt;code&gt;message&lt;/code&gt; 字段要共情 + 归因 + 引导，&lt;code&gt;suggested_questions&lt;/code&gt; 至少 3 个肯定式选项。&lt;/p&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"追问话术"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"suggested_questions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"问题标题"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"intent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"find_item"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;h3 id="3.7 HTTP 请求节点"&gt;3.7 HTTP 请求节点&lt;/h3&gt;
&lt;p&gt;真正调用 Rails 搜索接口的地方：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;POST 请求&lt;/li&gt;
&lt;li&gt;URL：&lt;code&gt;{{#env.HTTP_DOMAIN#}}/api/v1/products/ai_search&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;请求头带 &lt;code&gt;X-Authorization&lt;/code&gt;（从 Start 节点取）&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;超时和重试：连接/读取/写入都设 10 秒，失败自动重试 1 次。&lt;/p&gt;
&lt;h3 id="3.8 结果合并"&gt;3.8 结果合并&lt;/h3&gt;
&lt;p&gt;根据条件判断走不同的分支，各分支互斥，只返回其中一个：追问分支返回追问话术，搜索分支返回商品列表结果，无结果时返回默认话术。&lt;/p&gt;

&lt;hr&gt;
&lt;h2 id="四、向量搜索技术详解"&gt;四、向量搜索技术详解&lt;/h2&gt;&lt;h3 id="4.1 为什么用向量搜索"&gt;4.1 为什么用向量搜索&lt;/h3&gt;
&lt;p&gt;传统搜索的局限：&lt;/p&gt;
&lt;table class="table table-bordered table-striped"&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;th&gt;搜索方式&lt;/th&gt;
&lt;th&gt;问题&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;关键词搜索&lt;/td&gt;
&lt;td&gt;无法理解语义，"温润的手镯"匹配不到和田玉&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;短语匹配&lt;/td&gt;
&lt;td&gt;要求太精确，用户不会打完整短语&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;布尔搜索&lt;/td&gt;
&lt;td&gt;AND/OR/NOT 组合太复杂，用户不会用&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;向量搜索能理解语义：&lt;/p&gt;
&lt;table class="table table-bordered table-striped"&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;th&gt;搜什么&lt;/th&gt;
&lt;th&gt;关键词搜索&lt;/th&gt;
&lt;th&gt;向量搜索&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"温润的手镯"&lt;/td&gt;
&lt;td&gt;匹配含"温润"和"手镯"的商品&lt;/td&gt;
&lt;td&gt;匹配和田玉手镯&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"高货"&lt;/td&gt;
&lt;td&gt;匹配含"高货"的商品&lt;/td&gt;
&lt;td&gt;匹配翡翠高品&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"送妈妈的礼物"&lt;/td&gt;
&lt;td&gt;几乎没结果&lt;/td&gt;
&lt;td&gt;匹配适合送长辈的手镯&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;&lt;h3 id="4.2 向量搜索原理"&gt;4.2 向量搜索原理&lt;/h3&gt;
&lt;p&gt;流程：文本 → Embedding 模型 → 1024 维向量 → KNN 搜索 → 返回结果。&lt;/p&gt;

&lt;p&gt;步骤：1) 把用户输入转成向量；2) 在 ES 里用余弦相似度找最相似的 20 个商品；3) 用 &lt;code&gt;min_score&lt;/code&gt; 阈值过滤。&lt;/p&gt;
&lt;h3 id="4.3 EmbeddingService 实现"&gt;4.3 EmbeddingService 实现&lt;/h3&gt;
&lt;p&gt;我们用阿里云百炼的 Embedding 接口。一次请求把文本转成 1024 维向量，耗时大概 100-200ms。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;缓存策略&lt;/strong&gt;：相同文本 10 分钟内重复查询直接返回缓存。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;错误处理&lt;/strong&gt;：向量生成失败了返回空数组，后面的 ES 查询就不做了，避免浪费资源。&lt;/p&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;vector&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;EmbeddingService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"翡翠手镯，种水细腻，色泽温润"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# =&amp;gt; [0.023, -0.156, 0.089, ..., 0.012] (1024个浮点数)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;h3 id="4.4 商品向量同步 Job"&gt;4.4 商品向量同步 Job&lt;/h3&gt;
&lt;p&gt;商品上架后，AI 总结生成完成时自动触发向量生成。流程：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;取商品的 AI 总结（必须是已生成的）&lt;/li&gt;
&lt;li&gt;组合文本：商品标题 + AI 总结内容&lt;/li&gt;
&lt;li&gt;调用 EmbeddingService 生成 1024 维向量&lt;/li&gt;
&lt;li&gt;验证向量维度&lt;/li&gt;
&lt;li&gt;同步到 ES 的 &lt;code&gt;ai_summary_vector&lt;/code&gt; 字段&lt;/li&gt;
&lt;li&gt;标记生成时间 &lt;code&gt;vector_generated_at&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;注意事项&lt;/strong&gt;：版本冲突自动重试 3 次，失败了要报警。向量是预先生成的，搜索时直接用，不用实时生成。&lt;/p&gt;
&lt;h3 id="4.5 ES KNN 查询构建"&gt;4.5 ES KNN 查询构建&lt;/h3&gt;
&lt;p&gt;核心方法：&lt;/p&gt;
&lt;table class="table table-bordered table-striped"&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;th&gt;方法&lt;/th&gt;
&lt;th&gt;作用&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;build_ai_knn_search_body&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;构建 KNN 查询 Body&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;extract_knn_filters&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;提取过滤条件（排除关键词、圈口尺寸等）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;apply_filters_to_knn_query&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;应用过滤条件&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;关键参数&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;k&lt;/code&gt;：返回多少条结果。翡翠设 30，玉石 25，钻石和彩宝设 20。翡翠商品多，设大一点有足够候选。&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;min_score&lt;/code&gt;：相似度阈值。翡翠设 0.85，玉石 0.8。翡翠商品描述比较标准化，设高一点不容易跑偏。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;核心词加权&lt;/strong&gt;：如果提取到核心词（如"翡翠"），生成向量时重复 3 次，让这个词权重更高。"翡翠手镯" → "翡翠 翡翠 翡翠 手镯" → 生成向量。&lt;/p&gt;
&lt;h3 id="4.6 召回、排序、重排"&gt;4.6 召回、排序、重排&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;召回流程&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;生成查询向量&lt;/li&gt;
&lt;li&gt;用 HNSW 在向量空间里找最近的 k 个&lt;/li&gt;
&lt;li&gt;应用 &lt;code&gt;knn.filter&lt;/code&gt; 过滤条件&lt;/li&gt;
&lt;li&gt;计算余弦相似度，过滤低于 &lt;code&gt;min_score&lt;/code&gt; 的&lt;/li&gt;
&lt;li&gt;返回商品 ID 列表&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;排序&lt;/strong&gt;：目前只用 &lt;code&gt;_score&lt;/code&gt;（相似度分数）降序，简单的做法，后续可以加其他排序维度。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;重排&lt;/strong&gt;：没有额外的重排逻辑，ES 返回什么顺序就是什么顺序。&lt;/p&gt;
&lt;h3 id="4.7 完整 ES KNN 查询示例"&gt;4.7 完整 ES KNN 查询示例&lt;/h3&gt;&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"knn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"field"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ai_summary_vector"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"query_vector"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;0.023&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;-0.156&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.089&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.234&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;-0.067&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.178&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.012&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"k"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"filter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"bool"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"filter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"term"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"category_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"term"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"onsale"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"term"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"hide_in_miniprogram"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"range"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"confirmed_price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"gte"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"lte"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;7000&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"range"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"inner_circle_size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"gte"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;55&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"lte"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;58&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"exists"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"field"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ai_summary_vector"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"must_not"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"match_phrase"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"goods_description_text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"query"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"镶嵌"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"analyzer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ik_max_word"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"min_score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"from"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sort"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"_score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"order"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"desc"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"updated_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"order"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"desc"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;说明&lt;/strong&gt;：过滤条件都在 &lt;code&gt;knn.filter&lt;/code&gt; 里处理，在向量搜索阶段就过滤，减少计算量。&lt;/p&gt;

&lt;hr&gt;
&lt;h2 id="五、商品语义搜索模块详解"&gt;五、商品语义搜索模块详解&lt;/h2&gt;&lt;h3 id="5.1 AiSearch 数据模型"&gt;5.1 AiSearch 数据模型&lt;/h3&gt;
&lt;p&gt;AiSearch 表记录每次搜索的完整参数，搜索条件用 JSONB 存。为什么要冗余存一份 keywords_text？因为运营同学要统计数据，JSONB 查起来麻烦。每条记录关联 ai_chat_id 和 ai_chat_message_id，方便回溯"这条搜索结果是谁发的"。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;主要字段&lt;/strong&gt;&lt;/p&gt;
&lt;table class="table table-bordered table-striped"&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;th&gt;字段名&lt;/th&gt;
&lt;th&gt;类型&lt;/th&gt;
&lt;th&gt;说明&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;msg_id&lt;/td&gt;
&lt;td&gt;string&lt;/td&gt;
&lt;td&gt;消息 ID（唯一索引）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;search_params&lt;/td&gt;
&lt;td&gt;jsonb&lt;/td&gt;
&lt;td&gt;完整搜索参数&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;keywords_text&lt;/td&gt;
&lt;td&gt;string&lt;/td&gt;
&lt;td&gt;搜索关键词文本（冗余字段）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;category&lt;/td&gt;
&lt;td&gt;string&lt;/td&gt;
&lt;td&gt;商品分类（冗余字段）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;price_min/max&lt;/td&gt;
&lt;td&gt;decimal&lt;/td&gt;
&lt;td&gt;价格范围（冗余字段）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ai_chat_id&lt;/td&gt;
&lt;td&gt;bigint&lt;/td&gt;
&lt;td&gt;关联的对话 ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ai_chat_message_id&lt;/td&gt;
&lt;td&gt;bigint&lt;/td&gt;
&lt;td&gt;关联的消息 ID&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;使用场景&lt;/strong&gt;：记录搜索参数支持"再次搜索"，通过 ai_search_id 复用参数实现"查看更多"，也支持搜索行为分析和统计。&lt;/p&gt;
&lt;h3 id="5.2 API Controller 实现"&gt;5.2 API Controller 实现&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;POST /api/v1/products/ai_search&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;处理流程&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;参数校验：q 或 category_id 至少一个不为空&lt;/li&gt;
&lt;li&gt;创建 AiSearch 记录，生成 ai_search_id&lt;/li&gt;
&lt;li&gt;执行搜索：生成查询向量 → 构建 KNN 查询 → ES 查询&lt;/li&gt;
&lt;li&gt;数据组装：从数据库捞商品详情，用 Presenter 格式化&lt;/li&gt;
&lt;li&gt;返回结果：带 ai_search_id、total、products&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;成功返回&lt;/strong&gt;&lt;/p&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"success"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ai_search_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"uuid"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"products"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12345&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"翡翠手镯"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;6000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;错误返回&lt;/strong&gt;&lt;/p&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"success"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"参数错误：q 或 category_id 不能为空"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;curl 调用示例&lt;/strong&gt;&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.example.com/api/v1/products/ai_search"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Authorization: Bearer token"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"is_ai_search": true, "q": "翡翠手镯，5000-8000元", "category_id": 1, "price_min": 5000, "price_max": 8000}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;错误处理&lt;/strong&gt;&lt;/p&gt;
&lt;table class="table table-bordered table-striped"&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;th&gt;错误类型&lt;/th&gt;
&lt;th&gt;处理方式&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;参数校验失败&lt;/td&gt;
&lt;td&gt;返回 400，提示具体错误信息&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;向量生成失败&lt;/td&gt;
&lt;td&gt;记录日志，返回空结果&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ES 查询失败&lt;/td&gt;
&lt;td&gt;返回 500，提示"搜索失败，请稍后重试"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h2 id="六、KNN 按分类配置"&gt;六、KNN 按分类配置&lt;/h2&gt;
&lt;p&gt;配置表：ai_search_knn_category_config&lt;/p&gt;
&lt;table class="table table-bordered table-striped"&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;th&gt;分类 ID&lt;/th&gt;
&lt;th&gt;分类名称&lt;/th&gt;
&lt;th&gt;k&lt;/th&gt;
&lt;th&gt;min_score&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;翡翠&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;0.85&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;玉石（和田玉等）&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;0.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;钻石&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;0.82&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;彩宝&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;0.8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;k&lt;/strong&gt;：返回多少条结果。翡翠商品多，设 30；其他品类设 20-25。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;min_score&lt;/strong&gt;：相似度阈值。翡翠设 0.85 因为商品描述标准化，不容易跑偏；其他品类设 0.8。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;调参建议&lt;/strong&gt;：搜索结果太少就降 min_score 或增 k；结果不相关就提高 min_score。如果某个分类经常没结果，可以把 min_score 降 0.05-0.1。&lt;/p&gt;

&lt;hr&gt;
&lt;h2 id="七、常见问题与故障排查"&gt;七、常见问题与故障排查&lt;/h2&gt;&lt;h3 id="7.1 SSE 连接问题"&gt;7.1 SSE 连接问题&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：SSE 连接建立失败或频繁断开&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;排查步骤&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;检查请求头：&lt;code&gt;Accept: text/event-stream&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;检查 token：&lt;code&gt;X-Authorization: Bearer &amp;lt;token&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;检查网络和代理设置&lt;/li&gt;
&lt;li&gt;看 Rails 日志里的错误信息&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;常见原因&lt;/strong&gt;：token 过期或无效、网络超时（默认 60 秒）、服务器主动关闭连接。&lt;/p&gt;
&lt;h3 id="7.2 向量生成失败"&gt;7.2 向量生成失败&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：搜索返回空结果&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;排查步骤&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;检查 Embedding 服务 API 是否可访问&lt;/li&gt;
&lt;li&gt;检查 API 密钥是否有效&lt;/li&gt;
&lt;li&gt;看 Rails 日志的错误信息&lt;/li&gt;
&lt;li&gt;检查缓存是否正常（失败的请求也可能被缓存）&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;解决&lt;/strong&gt;：向量失败会降级为返回空结果，检查 Embedding 服务配置和网络，清理缓存后重试。&lt;/p&gt;
&lt;h3 id="7.3 Dify 工作流调用失败"&gt;7.3 Dify 工作流调用失败&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：调用超时或返回错误&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;排查步骤&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;检查 &lt;code&gt;DIFY_API_BASE_URL&lt;/code&gt; 环境变量&lt;/li&gt;
&lt;li&gt;检查 &lt;code&gt;DIFY_CHATFLOW_API_KEY&lt;/code&gt; 是否有效&lt;/li&gt;
&lt;li&gt;检查 Dify 服务是否正常&lt;/li&gt;
&lt;li&gt;看 Rails 日志的详细错误&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;常见错误&lt;/strong&gt;：&lt;code&gt;ConnectionError&lt;/code&gt;（连不上 Dify）、&lt;code&gt;ResponseError&lt;/code&gt;（Dify 返回错误）、&lt;code&gt;ParseError&lt;/code&gt;（响应解析失败）。&lt;/p&gt;
&lt;h3 id="7.4 ES 查询慢"&gt;7.4 ES 查询慢&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：查询超时或延迟高&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;排查步骤&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;检查 ES 集群状态和负载&lt;/li&gt;
&lt;li&gt;检查索引分片和副本配置&lt;/li&gt;
&lt;li&gt;检查 &lt;code&gt;k&lt;/code&gt; 值是否过大（建议不超过 50）&lt;/li&gt;
&lt;li&gt;检查 HNSW 参数&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;优化&lt;/strong&gt;：适当降低 &lt;code&gt;k&lt;/code&gt;、缩小 &lt;code&gt;knn.filter&lt;/code&gt; 范围、检查 ES 集群资源。&lt;/p&gt;
&lt;h3 id="7.5 搜索结果不相关"&gt;7.5 搜索结果不相关&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：返回的商品和用户需求不匹配&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;排查步骤&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;检查 Dify 提取的 &lt;code&gt;q&lt;/code&gt; 字段是否正确&lt;/li&gt;
&lt;li&gt;检查 ES 里的 &lt;code&gt;ai_summary_vector&lt;/code&gt; 是否正常&lt;/li&gt;
&lt;li&gt;检查 &lt;code&gt;min_score&lt;/code&gt; 阈值是否合适&lt;/li&gt;
&lt;li&gt;检查核心词加权是否生效&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;解决&lt;/strong&gt;：提高 &lt;code&gt;min_score&lt;/code&gt;、检查商品 AI 总结质量、优化属性提取规则。&lt;/p&gt;

&lt;hr&gt;
&lt;h2 id="技术架构总结"&gt;技术架构总结&lt;/h2&gt;&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────────────────────────────
│                    AI 找货助手技术架构                         
├─────────────────────────────────────────────────────────────
│  前端层 → SSE 对话接口 → Dify 工作流 → 商品语义搜索接口       
│                              ↓                               
│                      Elasticsearch KNN 搜索                   
│                              ↓                               
│                      商品向量同步 + 查询向量生成               
├─────────────────────────────────────────────────────────────
│  核心流程：Dify 对话流 → 意图识别 → 属性提取 → 追问 → 搜索     
│  核心技术：SSE 实时推送 + 向量语义搜索 + HNSW 高性能索引       
└─────────────────────────────────────────────────────────────
&lt;/code&gt;&lt;/pre&gt;</description>
      <author>ThxFly</author>
      <pubDate>Tue, 10 Feb 2026 19:16:13 +0800</pubDate>
      <link>https://ruby-china.org/topics/44477</link>
      <guid>https://ruby-china.org/topics/44477</guid>
    </item>
    <item>
      <title>1</title>
      <description>&lt;p&gt;1&lt;/p&gt;</description>
      <author>willx</author>
      <pubDate>Wed, 14 Jan 2026 20:38:35 +0800</pubDate>
      <link>https://ruby-china.org/topics/44448</link>
      <guid>https://ruby-china.org/topics/44448</guid>
    </item>
    <item>
      <title>分享一个 Rails AI Agent 开发库：ActiveAgent</title>
      <description>&lt;p&gt;初步看过代码，质量很高，作者是一个 15 年经验的 Rails 工程师：&lt;/p&gt;

&lt;p&gt;官网：&lt;a href="https://www.activeagents.ai/" rel="nofollow" target="_blank"&gt;https://www.activeagents.ai/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Rails 原生方式构建 AI 功能&lt;/p&gt;

&lt;p&gt;Active Agent 是一个免费的开源框架，让任何 Rails 开发者都可以使用控制器、视图和后台作业交付真正面向用户的 AI 功能。&lt;/p&gt;

&lt;p&gt;无需代码粘合。没有复杂性。只有 Rails 和满满的乐趣。😎&lt;/p&gt;</description>
      <author>lyfi2003</author>
      <pubDate>Wed, 24 Dec 2025 16:35:53 +0800</pubDate>
      <link>https://ruby-china.org/topics/44424</link>
      <guid>https://ruby-china.org/topics/44424</guid>
    </item>
    <item>
      <title>有没有预置字符串的汉化包？</title>
      <description>&lt;p&gt;&lt;a href="https://github.com/rails/rails/blob/main/activesupport/lib/active_support/locale/en.yml" rel="nofollow" target="_blank"&gt;https://github.com/rails/rails/blob/main/activesupport/lib/active_support/locale/en.yml&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/rails/rails/blob/main/activemodel/lib/active_model/locale/en.yml" rel="nofollow" target="_blank"&gt;https://github.com/rails/rails/blob/main/activemodel/lib/active_model/locale/en.yml&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Active Support 与 Active Model 的汉化包，有没有质量尚可的？&lt;/p&gt;</description>
      <author>tistest</author>
      <pubDate>Fri, 05 Dec 2025 17:45:26 +0800</pubDate>
      <link>https://ruby-china.org/topics/44407</link>
      <guid>https://ruby-china.org/topics/44407</guid>
    </item>
    <item>
      <title>response content-type negotiation 的一些问题</title>
      <description>&lt;p&gt;学习 controller 的 respond_to 方法时遇到的一个问题&lt;/p&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/controllers/test_controller.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TestController&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationController&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;
    &lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expect&lt;/span&gt; &lt;span class="ss"&gt;user: &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="ss"&gt;:name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:email&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nb"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:email&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;respond_to&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="nb"&gt;format&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
      &lt;span class="nb"&gt;format&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;html&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;render&lt;/span&gt; &lt;span class="ss"&gt;html: &lt;/span&gt;&lt;span class="s2"&gt;"这是html: name=&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="nb"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; email=&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nb"&gt;format&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;render&lt;/span&gt; &lt;span class="ss"&gt;json: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;msg: &lt;/span&gt;&lt;span class="s2"&gt;"这是json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="c1"&gt;# config/route.rb&lt;/span&gt;
&lt;span class="no"&gt;Rails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;application&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;routes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;draw&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="s2"&gt;"/test"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;to: &lt;/span&gt;&lt;span class="s2"&gt;"test#create"&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;用 httpie 发送 post 请求，注意请求头带有&lt;code&gt;Accept: application/json, */*;q=0.5&lt;/code&gt;，按理说应该返回 json 响应，但是 rails 返回了 html 响应&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ http POST http://localhost:3000/test user[name]=foo user[email]=foo@example.com --verbose
POST /test HTTP/1.1
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 53
User-Agent: HTTPie/3.2.4
Accept: application/json, */*;q=0.5
Content-Type: application/json
Host: localhost:3000

{"user": {"name": "foo", "email": "foo@example.com"}}

HTTP/1.1 200 OK
x-frame-options: SAMEORIGIN
x-xss-protection: 0
x-content-type-options: nosniff
x-permitted-cross-domain-policies: none
referrer-policy: strict-origin-when-cross-origin
content-type: text/html; charset=utf-8
etag: W/"644544dc838ec7f5f9d146a6efaf52af"
cache-control: max-age=0, private, must-revalidate
x-request-id: ef6be23e-f655-4368-ad0e-5561c1b975ac
x-runtime: 0.002610
server-timing: start_processing.action_controller;dur=0.01, render_template.action_view;dur=0.01, process_action.action_controller;dur=1.32
content-length: 42

这是html: name=foo email=foo@example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;把&lt;code&gt;Accept: application/json, */*;q=0.5&lt;/code&gt;改成&lt;code&gt;Accept: application/json&lt;/code&gt;就行了，也就是说，如果请求头的&lt;code&gt;Accept&lt;/code&gt;带有&lt;code&gt;*/*&lt;/code&gt;，那么 rails 一律返回 html 响应，无视优先级&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ http POST http://localhost:3000/test user[name]=foo user[email]=foo@example.com Accept:'application/json' --verbose
POST /test HTTP/1.1
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 53
User-Agent: HTTPie/3.2.4
Accept: application/json
Content-Type: application/json
Host: localhost:3000

{"user": {"name": "foo", "email": "foo@example.com"}}

HTTP/1.1 200 OK
x-frame-options: SAMEORIGIN
x-xss-protection: 0
x-content-type-options: nosniff
x-permitted-cross-domain-policies: none
referrer-policy: strict-origin-when-cross-origin
content-type: application/json; charset=utf-8
vary: Accept
etag: W/"f1ca37f45944a0eb8cc6bc093b6fc5c8"
cache-control: max-age=0, private, must-revalidate
x-request-id: cdf64112-4d57-4569-af76-ce712b165fe9
x-runtime: 0.001742
server-timing: start_processing.action_controller;dur=0.01, process_action.action_controller;dur=0.31
content-length: 59

{"msg":"这是json","name":"foo","email":"foo@example.com"}
&lt;/code&gt;&lt;/pre&gt;</description>
      <author>hmsk86</author>
      <pubDate>Tue, 11 Nov 2025 21:33:54 +0800</pubDate>
      <link>https://ruby-china.org/topics/44388</link>
      <guid>https://ruby-china.org/topics/44388</guid>
    </item>
    <item>
      <title>zsh prezto 的这些 shell alias 感觉挺实用的</title>
      <description>&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if ! zstyle -t ':prezto:module:rails:alias' skip; then
  alias ror='bundle exec rails'
  alias rorc='bundle exec rails console'
  alias rordc='bundle exec rails dbconsole'
  alias rordm='bundle exec rake db:migrate'
  alias rordM='bundle exec rake db:migrate db:test:clone'
  alias rordr='bundle exec rake db:rollback'
  alias rorg='bundle exec rails generate'
  alias rorl='tail -f "$(ruby-app-root)/log/development.log"'
  alias rorlc='bundle exec rake log:clear'
  alias rorp='bundle exec rails plugin'
  alias rorr='bundle exec rails runner'
  alias rors='bundle exec rails server'
  alias rorsd='bundle exec rails server --debugger'
  alias rorx='bundle exec rails destroy'
fi
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;a href="https://github.com/sorin-ionescu/prezto/blob/7b3b798eb5038eb05938399f245fa643c630a7f1/modules/rails/init.zsh#L22" rel="nofollow" target="_blank"&gt;https://github.com/sorin-ionescu/prezto/blob/7b3b798eb5038eb05938399f245fa643c630a7f1/modules/rails/init.zsh#L22&lt;/a&gt;&lt;/p&gt;</description>
      <author>hmsk86</author>
      <pubDate>Mon, 10 Nov 2025 06:02:24 +0800</pubDate>
      <link>https://ruby-china.org/topics/44385</link>
      <guid>https://ruby-china.org/topics/44385</guid>
    </item>
    <item>
      <title>Rails 8.1 发布</title>
      <description>&lt;p&gt;&lt;a href="https://rubyonrails.org/2025/10/22/rails-8-1" rel="nofollow" target="_blank"&gt;https://rubyonrails.org/2025/10/22/rails-8-1&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;要点：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Active Job 长任务中断恢复&lt;/li&gt;
&lt;li&gt;结构化事件&lt;/li&gt;
&lt;li&gt;本地 CI&lt;/li&gt;
&lt;li&gt;Markdown 渲染&lt;/li&gt;
&lt;li&gt;命令行获取密钥&lt;/li&gt;
&lt;li&gt;关联弃用声明&lt;/li&gt;
&lt;li&gt;Kamal 可以使用本地 docker registry&lt;/li&gt;
&lt;/ul&gt;</description>
      <author>Rei</author>
      <pubDate>Wed, 22 Oct 2025 19:27:52 +0800</pubDate>
      <link>https://ruby-china.org/topics/44348</link>
      <guid>https://ruby-china.org/topics/44348</guid>
    </item>
    <item>
      <title>关于 Postgresql 使用 NULLS NOT DISTINCT 还是无法唯一索引允许多个 NULL</title>
      <description>&lt;p&gt;因为原帖关闭了回复，所以单独发一个，我喜欢用 PostgreSQL 的一个原因就是条件索引（Partial Indexes）&lt;/p&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;add_index&lt;/span&gt; &lt;span class="ss"&gt;:users&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:auth_token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;unique: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;where: &lt;/span&gt;&lt;span class="s2"&gt;"auth_token IS NOT NULL"&lt;/span&gt;
&lt;span class="n"&gt;add_index&lt;/span&gt; &lt;span class="ss"&gt;:users&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:password_reset_token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;unique: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;where: &lt;/span&gt;&lt;span class="s2"&gt;"password_reset_token IS NOT NULL"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;</description>
      <author>ratazzi</author>
      <pubDate>Wed, 17 Sep 2025 21:23:44 +0800</pubDate>
      <link>https://ruby-china.org/topics/44317</link>
      <guid>https://ruby-china.org/topics/44317</guid>
    </item>
    <item>
      <title>Rails + Postgresql17 使用 NULLS NOT DISTINCT 还是无法唯一索引允许多个 NULL</title>
      <description>&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;create_table&lt;/span&gt; &lt;span class="ss"&gt;:users&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt; &lt;span class="ss"&gt;:email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;null: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt; &lt;span class="ss"&gt;:username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;null: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt; &lt;span class="ss"&gt;:display_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;null: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt; &lt;span class="ss"&gt;:password_digest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;null: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;integer&lt;/span&gt; &lt;span class="ss"&gt;:status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;default: &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;null: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt; &lt;span class="ss"&gt;:security_question&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt; &lt;span class="ss"&gt;:security_answer_digest&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt; &lt;span class="ss"&gt;:password_reset_token&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;datetime&lt;/span&gt; &lt;span class="ss"&gt;:password_reset_sent_at&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt; &lt;span class="ss"&gt;:auth_token&lt;/span&gt;

  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timestamps&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="n"&gt;add_index&lt;/span&gt; &lt;span class="ss"&gt;:users&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;unique: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;
&lt;span class="n"&gt;add_index&lt;/span&gt; &lt;span class="ss"&gt;:users&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;unique: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;
&lt;span class="n"&gt;add_index&lt;/span&gt; &lt;span class="ss"&gt;:users&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:auth_token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;unique: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;nulls_not_distinct: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;
&lt;span class="n"&gt;add_index&lt;/span&gt; &lt;span class="ss"&gt;:users&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:password_reset_token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;unique: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;nulls_not_distinct: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;测试一直跑不过，看了日志，数据库报错 duplicate key value。然后，直接在 Pg 里面去试试。&lt;/p&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="o"&gt;--&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt; &lt;span class="n"&gt;创建测试表&lt;/span&gt;
&lt;span class="no"&gt;DROP&lt;/span&gt; &lt;span class="no"&gt;TABLE&lt;/span&gt; &lt;span class="no"&gt;IF&lt;/span&gt; &lt;span class="no"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;test_nulls_distinct&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="no"&gt;CREATE&lt;/span&gt; &lt;span class="no"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;test_nulls_distinct&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="no"&gt;SERIAL&lt;/span&gt; &lt;span class="no"&gt;PRIMARY&lt;/span&gt; &lt;span class="no"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="no"&gt;TEXT&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="o"&gt;--&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt; &lt;span class="n"&gt;创建带&lt;/span&gt; &lt;span class="no"&gt;NULLS&lt;/span&gt; &lt;span class="no"&gt;NOT&lt;/span&gt; &lt;span class="no"&gt;DISTINCT&lt;/span&gt; &lt;span class="n"&gt;的唯一索引&lt;/span&gt;
&lt;span class="no"&gt;CREATE&lt;/span&gt; &lt;span class="no"&gt;UNIQUE&lt;/span&gt; &lt;span class="no"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_test_data_unique&lt;/span&gt;
&lt;span class="no"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;test_nulls_distinct&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="no"&gt;NULLS&lt;/span&gt; &lt;span class="no"&gt;NOT&lt;/span&gt; &lt;span class="no"&gt;DISTINCT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="o"&gt;--&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt; &lt;span class="n"&gt;插入测试数据&lt;/span&gt;
&lt;span class="o"&gt;--&lt;/span&gt; &lt;span class="n"&gt;插入唯一非&lt;/span&gt; &lt;span class="no"&gt;NULL&lt;/span&gt; &lt;span class="n"&gt;值&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;应该成功&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="no"&gt;INSERT&lt;/span&gt; &lt;span class="no"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;test_nulls_distinct&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="no"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'A'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="no"&gt;INSERT&lt;/span&gt; &lt;span class="no"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;test_nulls_distinct&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="no"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'B'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="o"&gt;--&lt;/span&gt; &lt;span class="n"&gt;插入多个&lt;/span&gt; &lt;span class="no"&gt;NULL&lt;/span&gt; &lt;span class="n"&gt;值&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;这应该成功&lt;/span&gt;&lt;span class="err"&gt;，&lt;/span&gt;&lt;span class="n"&gt;因为&lt;/span&gt; &lt;span class="no"&gt;NULLS&lt;/span&gt; &lt;span class="no"&gt;NOT&lt;/span&gt; &lt;span class="no"&gt;DISTINCT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="no"&gt;INSERT&lt;/span&gt; &lt;span class="no"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;test_nulls_distinct&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="no"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="no"&gt;INSERT&lt;/span&gt; &lt;span class="no"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;test_nulls_distinct&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="no"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="o"&gt;--&lt;/span&gt; &lt;span class="n"&gt;执行上面这行应该会报错&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="o"&gt;--&lt;/span&gt; &lt;span class="no"&gt;ERROR&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="n"&gt;duplicate&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="n"&gt;violates&lt;/span&gt; &lt;span class="n"&gt;unique&lt;/span&gt; &lt;span class="n"&gt;constraint&lt;/span&gt; &lt;span class="s2"&gt;"idx_test_data_unique"&lt;/span&gt;
&lt;span class="o"&gt;--&lt;/span&gt; &lt;span class="no"&gt;DETAIL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="no"&gt;Key&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;already&lt;/span&gt; &lt;span class="n"&gt;exists&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;有没有同学解决过这个问题，网上搜索了半天资料，都是说会遇到错误都是 Pg 版本低，我一开始用 16，最后换到 latest，还是一样。&lt;/p&gt;</description>
      <author>chuckaiyu</author>
      <pubDate>Mon, 15 Sep 2025 22:32:31 +0800</pubDate>
      <link>https://ruby-china.org/topics/44314</link>
      <guid>https://ruby-china.org/topics/44314</guid>
    </item>
    <item>
      <title>一句 has_many 在背后做了哪些事情？</title>
      <description>&lt;p&gt;RT&lt;/p&gt;</description>
      <author>zzz6519003</author>
      <pubDate>Fri, 05 Sep 2025 12:09:12 +0800</pubDate>
      <link>https://ruby-china.org/topics/44288</link>
      <guid>https://ruby-china.org/topics/44288</guid>
    </item>
  </channel>
</rss>
