阿塞拜疆时尚市场生产系统处理 4 语言、2 字母表、107 颜色别称。含 script folding、transliteration、103 同义词组的工程细节和失败教训。
来自我在阿塞拜疆时尚市场的真实生产查询:
гара йупка # "black skirt" — 阿塞拜疆语词汇,西里尔字母
pembe yubka # "pink skirt" — 半土耳其语,半俄语
roziviy platya # "pink dress" — 俄语音译,带有打字错误
我的商品目录用阿塞拜疆语编写(qara ətək、çəhrayı don)。原生 Elasticsearch 对这三个查询都返回空结果——或自信错误的结果。以下是完整的架构来解决这个问题,包含实际技术和过程中的失败案例。技术栈:Django、Elasticsearch 8、PostgreSQL + pgvector、Gemini embeddings。团队:一位创始人 + AI 配对编程。
想先看看它的实际运作?90 秒实时引擎演示:
一切始于分析时的字符折叠和音译:ə→e、ş→s、西里尔→拉丁(гара→qara)。此外:103 个精心策划的同义词组映射街语而非字典语言——一个拖鞋组读作 tərlik、terlik、тапочки、шлёпки、slippers、səndəl(五种语言/方言→一个商品目录概念),麂皮组包含用户实际输入的俚语拼写(zamuj、zamıj、zamsha→zamşa)。
额外机制:同义词键本身是模糊匹配的(阈值 0.78,可调)。"yupka"不在任何组中——但"yubka"在;这座桥连接了这个拼写错误到键,键展开为目录术语:yupka → yubka → ətək。两步跳跃,零用户摩擦。将阈值提高到 0.82,这座桥会无声地消失——可测量的旋钮,不是常数。
一项分析器规则能为你节省数月的"为什么这个会匹配":edge_ngram 仅在索引时。索引时的 custom_analyzer 构建前缀(自动完成召回);查询时使用单独的 search_analyzer,无 ngram——否则查询的每个 2 字母片段都成为前缀匹配器,结果充满垃圾。我们从一个生产 bug 中学到了这一点,其中片段"el"同时匹配了手套、连衣裙和商店描述。
每个查询通过令牌分类器,同时针对所有四种语言的实时分类法测试每个令牌,优先级排序:性别→类别→子类别→颜色/尺寸→价格。
"pembe yubka" → { color_family: "cehrayi", category: "Skirts", text: "" }
多字子类别获得滑动窗口短语匹配器,带有令牌覆盖评分:
for size in (3, 2):
for window in windows(tokens, size):
for sub, field, name_tokens in candidates: # stopwords stripped
if all(any(similarity(w, nt) >= 0.9 for nt in name_tokens)
for w in window):
coverage = size / len(name_tokens)
if coverage >= 0.5:
accept(sub, window)
所以"ətək dəsti"(裙装套装)匹配子类别"Ətək və Üst Geyim Dəsti"作为短语——单个模糊令牌永远无法劫持过滤器。
朴素匹配也需要保护:没有它们,"ayaqqabısı"(鞋的长词)会模糊匹配尺寸"S"。长度比率规则和尺寸关键字列表在阻止意外匹配的同时保护真实尺寸查询。价格提示也会解析——"50 manatadək"变成 {price_max: 50},"ucuz"变成便宜范围过滤器。
端到端,一行混合语言——qadın pembe yubka 50 manatadək(AZ + TR + RU-translit + AZ 价格)——解析为:
{ gender: "women", # AZ 列匹配
color_families: ["pink"], # TR 别名
category: "Skirts", # RU 词→同义词池→ətək
price_max: 50 } # 解析,不匹配
零令牌保留为自由文本。将 pembe 换成英文 pink——相同解析;别名承载所有四种语言,所以一个句子可以自由混合它们。
供应商写了 107 个不同的色彩值,包括管道("Qırmızı | Açıq çəhrayı")和字面 CSS:
radial-gradient(circle, #000000 20%, #C69258 20%) # a leopard print
解决方案不是更多字符串匹配。它是色彩科学:
HEX_RE = re.compile(r'#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b') # finds ALL hexes,
# even inside gradients
def hex_to_lab(hex_code): # sRGB → linear → XYZ(D65) → Lab
...
def delta_e(lab1, lab2): # full CIEDE2000 (Sharma formulation),
... # ~80 lines, zero dependencies
每个色彩族有锚点十六进制值;每个供应商色彩通过 CIEDE2000 映射到其最近的锚点。我们针对官方 Sharma 测试向量进行了验证——9/9 精确到四位小数,包括臭名昭著的 1.5381 对。从朴素 CIE76(Lab 中的欧几里得)升级到 CIEDE2000 正确地重新分类了九个边界色:芥末→黄色(曾是:金色)、象牙→米色(曾是:白色)、深蓝不再渗入紫色。
15 是今天的分类法,不是上限。色彩族在管理表中——添加第 16 个是数据库行,不是部署。更重要的是,分类器根本没有色彩限制:供应商明天发明的任何新色调,其十六进制值落在 Lab 空间中并自动映射到最近的色彩族。随着卖家增长,色彩词汇无需许可就增长——组织吸收它。
数学之上的规则:
管道/双色→两个色彩族(红|粉色集必须出现在粉色搜索中)
≥4 个十六进制派生的色彩族→折叠为"multicolor"
图案别名(豹纹、斑马纹)→ multicolor,十六进制色彩族跳过
没有任何色彩变种的商品?一次性回填:商品照片→Gemini Flash-Lite→"主导商品色彩作为十六进制,忽略背景和皮肤"→CIEDE2000→色彩族。76 个商品总成本低于 $0.01。
一个战争故事:第一个版本在 Gemini 调用中设置了 max_output_tokens=256。一个 AI 审查代理在部署前标记了它:思考令牌共享该预算,所以模型可以燃尽整个上限思考并返回空文本——整个回填会无声地产生空结果。上限提高到 2048,截断已记录。
硬过滤器是如何你提供空页面。每个过滤搜索下降一个阶梯;第一个有结果的阶梯赢:
yield 'full', search_with(category, color, text)
if color:
yield 'no_color', search_with(category, text)
if subcategory:
yield 'partial', search_with(text_plus_category_only)
yield 'text_only', search_with(text)
错误的约束成本排名质量,永不空白屏幕。
第二宽恕机制:分类法桥。"yubka"检测到裙装类别——但裙装套装生活在另一个类别,其子类别字面上名为"Skirt & Top Set"。硬 category.id 过滤器无声地隐藏了它们。现在过滤器是:
Q('bool', should=[
Q('term', **{'category.id': detected_id}),
Q('match', **{'subcategory.title': { # concept bridge
'query': specific_tokens_only, # generic tokens stripped
'analyzer': 'search_analyzer',
}}),
], minimum_should_match=1)
通用令牌(服装、套装、顶部……)被排除,所以桥不能过度扩展——如果只有通用令牌保留,不构建桥。(鸣谢:一个 AI 代码审查代理在它发布前捕捉到了过度扩展风险。)
文本和图像嵌入存活在单一共享的 1536 维空间中(Gemini Embedding 2,多模态),存储在 pgvector 中——跨索引重建耐用。
当词汇结果较弱(低于阈值)时,我们运行文本 kNN 和图像 kNN,并使用倒数排名融合融合:
def rrf_merge(*ranked_lists, k=60, limit=None):
scores = defaultdict(float)
for lst in ranked_lists:
for rank, pid in enumerate(lst):
scores[pid] += 1.0 / (k + rank + 1)
...
同一空间为视觉搜索(照片→商品)提供动力。一个空间,两道门。
分类法也被嵌入了。每个类别/子类别/性别都有一个质心——其商品向量的平均值——在 pgvector 中。视觉搜索在排名之前针对质心对查询照片进行分类:
rows = (VisualCentroid.objects
.filter(embedding__isnull=False)
.annotate(distance=CosineDistance('embedding', query_vector))
.values_list('kind', 'ref_id', 'distance'))
# → "this photo is a women's earring" → type-constrain results
# (an earring photo can never return trousers on color similarity alone)
关键是,语义仅在词汇结果较弱时触发。当精确匹配有效时,不要用感觉稀释它。
向量人群的两个生产脚注:
SET LOCAL hnsw.iterative_scan = relaxed_order;
pgvector 的 HNSW 索引在你后过滤 kNN 查询时无声地返回不足(例如 status='Published')——索引产出 K 个候选项,过滤器吃掉大部分,你得到 3 个结果,其中存在 40 个。我们的每个过滤 kNN 都在该会话设置内运行。
和新鲜度:每个商品保存会调度一个 Cloud Task,在秒内重新同步其 ES 文档——没有夜间重建,没有 Celery 工作器,只是 Cloud Run 上的 HTTP 触发任务。供应商列出连衣裙;在他们关闭标签页之前它是可搜索的。
我们的 AI 助手没有自己的商品知识。它通过 7 个认证工具端点调用搜索引擎(API 密钥通过常时间比较,加上适用于服务帐户的 Google 签名 JWT):
search_products # same intent parser, same filters
search_shops # same product-evidence ranking
search_faq / search_blog # RAG corpus: FAQ + docs + blog chunks,
# same embedding space, same RRF fusion
search_products_by_vibe # semantic-only door
find_similar_products # embedding neighbors
get_catalog_info # taxonomy/variants/brands (cached)
视觉搜索是第三道门——相同的嵌入、相同的质心、相同的排名规则。修复一个同义词一次;搜索栏、相机和聊天机器人在同一秒内都改进。
每个有意义的交互发出结构化 JSON 事件——18 个事件类型,15 个通过 Cloud Logging sink 路由到 BigQuery(过去 30 天 60K+ 事件)。有趣的事件不是页面浏览:
log_smart_upload_save(
ai_suggestions=..., # what the AI proposed
vendor_choices=..., # what the human decided
changes=..., # field-level diff: accepted/modified/rejected
acceptance_rate=..., # one number per upload
)
这是由正常平台使用制造的标记训练对。相同的模式无处不在:
SearchHistory 存储查询→点击商品(文本和视觉;视觉查询保留其 1536 维嵌入)→查询相关性对
审核员拒绝带有类别,并馈送负例索引;新上传在任何 Gemini 调用之前获得 kNN 预检查——被拒绝图像的样本在毫秒内被标记,完全跳过 API 成本
审核员审查队列对视觉搜索结果进行分级并纠正类别→手标记的地面真实
零结果查询落入专用 BigQuery 视图→每周同义词策划
阶梯:(1) 仪表板和今天的挖掘 → (2) 行为驱动排名(CTR 调整权重,语义混合比率由测量设置)→ (3) 在建议对决对和点击相关性对上微调我们自己的检索/分类模型。没有其他人有这个数据集,因为没有其他人的用户输入"гара йупка"。
每个 AI 行为都可通过零部署进行管理员调整——47 个旋钮分布在两个单例配置模型中(语义阈值、RRF k、色彩过滤切换、视觉相似度层级、门模型、速率限制、会话 TTL)。配置分辨:DB 覆盖→设置→默认值,在管理员保存时缓存失效。
阈值不是感觉。零 API 成本 eval 命令在每个商品嵌入上运行留一个出,比较三种分类方法(邻居投票/质心/文本 RAG argmax),并打印达到 95% 精度的阈值网格:
score ∈ {0.60..0.85} × margin ∈ {0..0.05} → precision/coverage table
→ recommended gate = first cell with precision ≥ 0.95
管理员旋钮被设置为基准说的什么。
成本也是控制平面的一部分:Gemini 目录上下文在服务器端上下文缓存中(~94% 的每 AI 上传调用的提示令牌来自缓存),视觉输入在发送前调整大小,被拒绝的图像 kNN 预检查完全跳过 Gemini 调用——每个跳过的调用都会与其保存的成本一起记录。
生产调试,不是猜测
当"pembe"(粉色)在生产中开始返回米色和牛仔裙时,我们没有盲目地调整 boost。我们在实时系统上跟踪了解析的意图:
FILTERS = {'variants': [{'title': 'Açıq Mavi | Krem | Çəhrayı',
'matched_field': 'title_tr', ...}],
'color_families': ['cehrayi', 'bej', 'mavi'], ...}
就是这样:"pembe"精确匹配了一个管道项目的土耳其语翻译并继承了所有三个色彩族,稀释了过滤器。修复:词别名优先于项目继承——粉色意味着粉色。一个追踪,一行改变,由两个新测试覆盖。
4 种语言、2 种字母、~100 个同义词组
15 个色彩族;116/116 色彩自动分类;76 个商品从照片着色,成本 <$0.01
CIEDE2000:9/9 官方向量
18 个分析事件类型→ BigQuery;7 个 AI 工具端点;47 个零部署管理员旋钮
搜索栈上的 366 个自动化测试
由 1 位创始人 + AI agents 构建和审查(代码审查、ES 审查、迁移安全、性能)
分层架构有意是一个模板:突厥语言世界(土耳其、中亚)与我们主要市场共享相同的脚本混合和音译混乱——每个新语言都是同一大脑上的新折叠表 + 同义词包。路线图上的下一个信号:用户行为(已流式传输),然后是身体测量和个人风格(选择加入、隐私优先)作为排名输入。目标状态:搜索栏行为像个人造型师,碰巧接受文本、照片和聊天。
多语言搜索首先在字母层死亡。在调整排名之前折叠脚本。
色彩词是过滤器,不是关键字。解析意图。
Lab + CIEDE2000 是 80 个无依赖行,胜过任何手动维护的色彩映射。
构建放松阶梯。错误的约束应该成本排名,永不结果。
零结果日志是免费的产品管理。
一个大脑,许多道门:搜索栏、相机和聊天机器人应该共享相同的意图解析器、嵌入和排名。
从第一天捕捉每个 AI 建议对人类决定对。那是你的微调数据集在你睡眠时复合。
关于任何层的问题——分析器、短语匹配器、CIEDE2000 端口、RRF 调整——在下面提问。我会分享细节。
我正在独自建立 geyin.az(阿塞拜疆首个 AI 驱动的时尚市场),公开,使用 AI。之前的文章:我们如何构建零幻觉 AI 内容管道。
如果要采取进一步行动,你可能会考虑阻止此人和/或报告滥用