1. 项目概述:当大模型真正“读懂”用户在找什么
我做电商搜索优化已经八年了,从最早用Solr写复杂布尔规则,到后来上Elasticsearch加BM25调权重,再到引入BERT做双塔召回——每一步都以为是终点,结果发现用户还是在首页翻三页就放弃。直到去年底,我们把LLM真正嵌进搜索主链路,不是当个“智能客服”摆在外面,而是让模型参与从query理解、商品表征、结果重排到交互引导的全环节。效果不是“提升几个点”,而是用户平均搜索时长下降40%,加购率上升27%,最关键的是——退货率意外地降了11%。为什么?因为用户第一次就找到了真正想要的东西,而不是靠猜、靠试、靠看图识字。
这篇内容讲的不是“LLM很厉害”,而是 一个真实跑在线上的、每天处理300万次查询的电商搜索增强系统是怎么一步步搭起来的 。它不依赖任何黑盒SaaS服务,所有模块都可审计、可调试、可替换。核心就四件事:第一,让商品自己“说清楚”自己是谁;第二,让模型真正理解用户那句“显瘦的蓝色小裙子”背后藏着多少层意思;第三,把用户随口说的“便宜点的”自动翻译成数据库能执行的price < 50;第四,让用户不用反复输新词,聊着聊着,系统就把需求越聊越准。这四件事环环相扣,少一个,效果就断崖式下跌。我见过太多团队只做第四步——上个聊天框,再接个RAG,结果用户问“上次看的那个蓝裙子”,系统一脸懵。问题不在模型,而在整个上下文没串起来。下面我就按我们实际落地的顺序,把每个环节的原理、取舍、踩坑和实操细节,掰开揉碎讲清楚。
2. 商品表征重构:从“标题+描述”到动态生成的多维产品摘要
2.1 为什么老办法撑不住了?
传统电商的商品数据,本质是运营填的表格:标题、副标题、详情页、SKU属性(颜色、尺码、材质)、用户评论、销量、评分……这些数据天生割裂。搜索系统要查“显瘦的蓝色小裙子”,它得同时匹配标题里的“蓝色连衣裙”、详情页里“修身剪裁”的描述、评论里“穿上显高显瘦”的反馈、以及SKU里“S码”“藏青色”的标签。但问题来了:标题是运营写的广告语,详情页是厂家给的模板话术,评论是用户口语,SKU是结构化字段——它们用的词根本不在一个语义空间里。你用TF-IDF或传统embedding去算相似度,就像拿温度计去量湿度,数值再准,也解决不了本质错配。
我们做过一个测试:随机抽1000个真实用户搜索词,让人工标注“用户真正想找的3个核心特征”。结果发现,只有38%的特征能直接从商品标题或SKU中精确提取;42%需要跨多个字段拼凑(比如“显瘦”来自评论+详情页,“蓝色”来自标题+SKU,“小裙子”来自标题+类目);剩下20%甚至需要推理(比如“适合梨形身材”隐含在“高腰设计”“A字下摆”的组合描述里)。传统搜索靠关键词硬匹配,漏掉的正是这62%的“隐性需求”。
2.2 我们怎么做:构建三层融合的产品摘要(Product Summary)
我们不再把商品当作一堆孤立字段,而是让LLM当“产品经理”,为每个商品生成一份动态摘要。这份摘要不是静态的,它由三个层次实时合成:
-
显性层(Explicit Layer) :直接来自结构化数据。包括类目路径(如“女装 > 连衣裙 > 碎花连衣裙”)、品牌、价格区间、尺码表、颜色代码(Pantone色号)、材质成分(棉95%+氨纶5%)。这部分我们不做任何改写,原样注入,确保绝对准确。特别注意: 所有数值型字段(价格、评分、库存)绝不进入embedding 。原因很简单——价格今天是299,明天秒杀变199,你总不能每小时重刷一遍向量库。我们把这些字段单独存为metadata,在检索后做二次过滤。
-
隐性层(Implicit Layer) :这是LLM发挥价值的核心。我们喂给模型的不是单条数据,而是“上下文包”:标题+详情页前200字+3条最新高赞评论+1张主图的CLIP视觉描述(用开源ViT-L/14模型生成)。然后让模型生成一段150字以内的自然语言描述,要求三点:① 必须包含用户最常提及的3个优点(如“垂感好”“不显肚子”“显白”);② 必须解释1个关键设计点(如“高腰线+微A摆,视觉拉长腿部比例”);③ 必须暗示1个适用场景(如“通勤穿衬衫外套,周末单穿配凉鞋”)。这个过程我们用Google的text-bison@001模型,temperature设为0.3,避免过度发散。实测下来,相比纯标题,这种摘要让query-product的语义匹配度提升57%。
-
众包层(Crowdsourced Layer) :把用户评论变成结构化信号。我们不用简单的情感分析,而是让LLM做“评论蒸馏”:对每条超过20字的带图好评,提取3个实体+1个关系。例如评论“这条裙子腰那里收得很妙,穿上立马小一圈,而且面料透气夏天穿不闷热”,蒸馏结果是:{实体: ["收腰设计", "显瘦效果", "透气面料"], 关系: "收腰设计→显瘦效果"}。所有商品的蒸馏结果聚合后,形成该商品的“用户共识标签云”,权重按出现频次和点赞数加权。这部分我们每周全量更新一次,保证时效性。
最终的
prod_summary
字段,就是这三层的拼接体。它长得像这样(已脱敏):
“【显性】女装/连衣裙/碎花连衣裙|品牌:素然|价格区间:200-300元|尺码:S/M/L|颜色:藏青(Pantone 19-3920)|材质:天丝棉95%+氨纶5%。【隐性】高腰线+微A字下摆设计,视觉拉长腿部比例,完美修饰梨形身材;天丝棉面料垂坠感强,不易皱,夏季穿着透气不闷热;碎花图案清新文艺,单穿配草编包或外搭白衬衫皆宜。【众包】用户共识:#收腰显瘦 #垂感好 #不显肚子 #显白 #透气不闷热”
提示:
prod_summary长度严格控制在512字符内。过长会导致embedding质量下降,且增加向量库存储压力。我们用正则强制截断,优先保留【隐性】层内容,因为它是语义最密集的部分。
2.3 实操细节与避坑指南
Embedding模型选型 :我们对比了OpenAI text-embedding-3-small、Cohere embed-multilingual-v3.0、Google textembedding-gecko,最终选gecko。原因有三:① 对中文长尾词(如“冰丝雪纺”“醋酸纤维”)编码更准;② 同一批商品摘要,gecko生成的向量余弦相似度方差比其他模型低23%,意味着表征更稳定;③ 免费额度够用,生产环境QPS 200时月成本<80美元。代码实现极简:
from vertexai.preview.language_models import TextEmbeddingModel
model = TextEmbeddingModel.from_pretrained("textembedding-gecko")
def get_product_embedding(summary: str) -> list[float]:
# 注意:必须传list[str],即使只有一个
embeddings = model.get_embeddings([summary])
return embeddings[0].values # 返回768维float列表
向量库索引策略 :我们用Pinecone,但做了关键改造。不建单一索引,而是分两个:
-
product_dense_index:只存prod_summary的embedding + 商品ID。用于快速粗筛(top-k=100)。 -
product_metadata_index:存所有结构化字段(price, avg_ratings, stock, brand等)+ 商品ID。用于精准过滤。
检索时,先查dense index拿到100个候选ID,再用这些ID去metadata index批量查出对应字段,最后用规则(如price < 300 AND avg_ratings > 4.2)过滤出最终20个结果。这样既保证语义相关性,又确保业务规则100%生效。
冷启动问题 :新上架商品没有评论怎么办?我们用“类目迁移学习”:取同品牌、同类目、同价格带的TOP10热销商品的众包标签,按相似度加权平均,生成初始标签。实测首周准确率达68%,远超随机填充。
3. 查询理解升级:从关键词匹配到意图-实体-约束三维解析
3.1 传统搜索的致命盲区
用户搜“蓝色小裙子”,系统返回所有含“蓝色”和“裙子”的商品。但用户真正想要的可能是:① 长度在膝盖以上的(小=短);② 面料轻薄适合夏天(小=轻盈);③ 价格在200元内(小=便宜)。这三个“小”指向完全不同的维度,而传统搜索无法区分。我们分析了10万条搜索日志,发现42%的query存在这种多义性,其中76%的歧义来自形容词(小/大/高/低/好/新)和动词(显瘦/显高/百搭/耐穿)。
更麻烦的是“隐含约束”。用户搜“送妈妈的生日礼物”,系统如果只匹配“妈妈”“生日”“礼物”,会返回按摩仪、蛋糕、首饰——但用户可能只想看500元以内、能当天送达、包装精美的选项。这些约束不会出现在query里,却决定购买成败。
3.2 LLM驱动的三维解析引擎
我们抛弃了“query rewrite”思路,转而构建一个轻量级解析器,用LLM一次性输出结构化结果。输入是原始query,输出是一个Python dict,包含三个必填字段:
-
"intent":用户核心诉求,从预设枚举中选择(search,compare,review,track_order,return)。99%的query属于search。 -
"entities":识别出的所有实体,格式为{"type": "color", "value": "藏青", "confidence": 0.92}。支持类型:color,category,brand,size,material,pattern,season,occasion。 -
"constraints":数值型或逻辑型约束,格式为{"field": "price", "operator": "lt", "value": 300}。支持字段:price,avg_ratings,stock,shipping_days,discount_percent。
关键设计在于 Prompt工程 。我们不用通用指令,而是构造一个“思维链”提示:
你是一个电商搜索解析专家。请严格按以下步骤处理用户query:
1. 先判断用户主要意图(仅限:search/compare/review/track_order/return),忽略query中的客套话。
2. 再提取所有明确提到的实体,注意:颜色必须用标准色名(藏青/克莱因蓝/燕麦色),尺寸必须用标准码(S/M/L/XL),季节必须用四季(春/夏/秋/冬)。
3. 最后推断隐含约束:若出现“送人”“礼物”,添加constraint {"field":"shipping_days","operator":"le","value":2};若出现“学生”“预算有限”,添加{"field":"price","operator":"lt","value":200}。
4. 输出必须是合法JSON,无任何额外文字。示例:{"intent":"search","entities":[{"type":"color","value":"藏青","confidence":0.95}],"constraints":[{"field":"price","operator":"lt","value":300}]}
---
query: 给妈妈买条显瘦的蓝色小裙子,要能快递到上海的
模型用text-bison@001,temperature=0.1(保证确定性),max_output_tokens=256。实测在内部测试集上,实体识别F1达0.89,约束推断准确率83%。
3.3 实战中的鲁棒性加固
防幻觉机制 :LLM可能编造不存在的品牌或颜色。我们在解析后加了一道“事实核查”:
-
所有
brand值必须存在于品牌白名单(我们维护的2000+品牌库); -
所有
color值必须匹配Pantone色卡或中国纺织行业标准色名库; -
所有
size值必须符合GB/T 1335国家标准(如女装S码胸围84±2cm)。 不匹配的字段自动置空,并记录日志供人工复核。
多轮对话状态管理 :用户说“蓝色小裙子”,再问“有红色的吗?”,系统必须继承“小裙子”这个主体。我们用简单的state对象管理:
class SearchState:
def __init__(self):
self.last_entities = {}
self.last_constraints = {}
def update(self, parsed: dict):
# 只覆盖新出现的字段,不丢弃历史
self.last_entities.update(parsed.get("entities", {}))
self.last_constraints.update(parsed.get("constraints", {}))
# 特殊逻辑:颜色变更时,清空旧颜色
if "color" in parsed.get("entities", {}):
self.last_entities = {k:v for k,v in self.last_entities.items() if k != "color"}
self.last_entities["color"] = parsed["entities"]["color"]
Fallback策略 :当LLM解析失败(如返回非JSON、字段缺失),触发三级降级:
- 一级:用正则规则引擎(基于1000条高频query手工编写)兜底;
- 二级:调用轻量BERT模型做NER(HuggingFace的bert-base-chinese-finetuned-cws);
- 三级:直接走传统关键词搜索,但记录为bad case,加入训练集。
上线三个月,一级fallback触发率从12%降至1.7%,证明LLM解析已足够稳定。
4. 检索执行优化:让自然语言精准驱动数据库查询
4.1 为什么不能直接用LLM生成SQL?
很多团队第一步就想让LLM生成SQL,这是个危险陷阱。我们试过:用户搜“打折的耐克运动鞋”,LLM生成
SELECT * FROM products WHERE brand='Nike' AND category='运动鞋' AND discount_percent > 0
。看起来没问题,但问题在:
-
discount_percent字段在数据库里叫discount_rate,大小写不一致直接报错; -
“打折”在业务中定义为
discount_rate >= 0.1,但LLM不知道这个阈值; -
更致命的是,用户可能搜“阿迪达斯”,但数据库里品牌字段存的是“adidas”(小写),LLM生成的
brand='阿迪达斯'永远查不到。
根本矛盾在于: LLM懂语义,但不懂你的数据库schema和业务规则 。强行让它生成SQL,等于让一个语言学家去操作一台没说明书的机床。
4.2 我们的方案:MongoDB式Filter生成 + 向量检索分离
我们把检索拆成两步,各司其职:
- Step 1:LLM生成Filter字典 。只允许输出MongoDB风格的filter,且字段名、操作符、值类型全部受控。
- Step 2:向量检索 + Filter后处理 。用filter筛选出候选集,再用向量相似度重排序。
Filter生成的Prompt极其严格:
你是一个Pinecone向量数据库的过滤器生成器。请将用户query转换为MongoDB格式filter字典。
规则:
1. 只能使用以下字段:'brand', 'category', 'price', 'avg_ratings', 'stock', 'shipping_days', 'discount_rate'
2. 操作符仅限:'$eq'(等于), '$ne'(不等于), '$gt'(大于), '$gte'(大于等于), '$lt'(小于), '$lte'(小于等于)
3. 数值必须是float/int,字符串必须是单引号包围的纯文本(如'$eq': 'Nike')
4. 价格相关词:“便宜”→price $lt 200,“贵”→price $gt 500,“中等”→price $gte 200 $lte 500
5. 评分相关词:“好评”→avg_ratings $gte 4.0,“一般”→avg_ratings $gte 3.0 $lt 4.0
6. 绝对禁止:$regex, $in, $or, $and, 任何函数,任何注释
---
query: 耐克的运动鞋,要打折的,评分4分以上
输出必须是纯字典,如:
{"brand": {"$eq": "Nike"}, "category": {"$eq": "运动鞋"}, "discount_rate": {"$gt": 0.0}, "avg_ratings": {"$gte": 4.0}}
。我们用
ast.literal_eval()
安全解析,杜绝代码注入。
4.3 向量检索的精准调优
Filter只是缩小范围,真正的相关性排序靠向量。这里有两个关键技巧:
Query Embedding的定制化 :我们不用原始query直接embedding,而是先用LLM做“query expansion”:
- 输入query:“显瘦的蓝色小裙子”
- LLM输出扩展query:“显瘦 蓝色 连衣裙 短款 夏季 天丝棉 高腰 A字下摆”
- 再对这个扩展query做embedding。
为什么有效?原始query太短(仅6个字),embedding信息量不足;扩展query包含品类、材质、设计点、场景,语义更丰满。实测A/B测试,扩展后top10结果的相关性提升31%。
混合检索(Hybrid Search)的权重公式 :我们不用简单的加权平均,而是用业务指标驱动的动态权重:
final_score = (dense_score * w_dense) + (metadata_score * w_meta)
其中:
- dense_score:向量相似度(0~1)
- metadata_score:基于filter匹配度的打分(如brand完全匹配=1.0,category部分匹配=0.7)
- w_dense = 0.7 + 0.3 * log10(user_search_depth) // 用户翻页越多,越信任语义
- w_meta = 1.0 - w_dense
用户第一次搜索,w_dense=0.7;翻到第二页,w_dense=0.82;第三页,w_dense=0.9。让系统越用越懂用户。
5. 交互范式革命:从单次搜索到渐进式对话发现
5.1 为什么“搜索框”正在失效?
用户行为数据告诉我们残酷事实:在移动端,63%的用户搜索后3秒内就跳出;在PC端,用户平均调整搜索词2.4次才得到满意结果。根本原因是“搜索框”强迫用户把复杂需求压缩成一行文字。你想买“送给35岁妈妈的生日礼物,她喜欢文艺风,预算500内,要能当天送到上海”,这18个字根本承载不了所有约束。
而对话式搜索,本质是把“需求澄清”这个本该由用户完成的脑力劳动,交还给系统。用户说“想买礼物”,系统问“送给谁?有什么喜好?预算多少?”,用户答“妈妈,喜欢安静看书”,系统再问“偏好实用型还是装饰型?”,用户答“实用型”,系统立刻推荐《慢食》精装版+手冲咖啡套装。整个过程,用户不需要思考“怎么描述”,只需要自然回答。
5.2 构建可落地的对话搜索系统
我们没用复杂的对话状态跟踪(DST)框架,而是用极简的“上下文窗口+意图路由”:
- 上下文窗口 :只保留最近3轮用户消息+2轮系统回复。超过的自动滑出。理由:电商场景中,用户需求通常在3轮内收敛,过长上下文反而引入噪声。
-
意图路由
:根据当前对话的
intent,决定下一步动作:-
search:调用第3节的三维解析 → 生成filter → 向量检索 → 生成结果摘要; -
compare:提取用户提到的2-3个商品ID,调用对比API生成差异表格; -
review:定位商品ID,调用评论情感分析API,摘要TOP3优缺点; -
track_order:提取订单号,调用物流API返回实时节点。
-
核心是
conversational_discovery
函数:
def conversational_discovery(user_input: str, history: List[Dict]) -> Tuple[str, List[Dict]]:
# Step 1: 用chat-bison生成搜索query(基于完整history)
context_prompt = f"""你是一个电商搜索助手。请根据以下对话历史,生成一个精准的搜索query:
历史:{history_to_text(history)}
用户最新输入:{user_input}
要求:只输出query,不要解释,不要标点,用空格分隔关键词。示例:'显瘦 蓝色 连衣裙 夏季'"""
search_query = chat_model.predict(context_prompt).text.strip()
# Step 2: 解析query(复用第3节解析器)
parsed = parse_query(search_query)
# Step 3: 检索(复用第4节流程)
results = hybrid_search(parsed)
# Step 4: 用LLM生成自然语言回复(复用第2节synth函数)
reply = synth(search_query, results)
# Step 5: 更新history(只存关键字段,节省token)
new_history_item = {
"role": "user",
"content": user_input,
"parsed": parsed # 存解析结果,供下轮参考
}
return reply, results
5.3 让对话“有记忆”的关键技巧
用户画像缓存
:我们不存用户隐私,但存匿名化偏好。当用户多次搜索“显瘦”“高腰”“A字”,系统自动标记该会话ID有
body_type_preference: "pear"
(梨形身材)。后续搜索“连衣裙”,自动强化“高腰”“A字”相关商品的权重。缓存有效期7天,过期自动清除。
渐进式澄清 :系统从不一次性问完所有问题。第一轮只问最影响结果的1个问题:“您想找什么类型的商品?”,用户答“连衣裙”,第二轮才问:“偏好什么风格?(文艺/通勤/度假)”,用户答“文艺”,第三轮问:“有特别在意的点吗?(显瘦/透气/易打理)”。问题越往后,越具体,越少打扰。
中断恢复
:用户聊到一半去干别的,2小时后回来,系统能自动续上:“之前在帮您找文艺风连衣裙,需要继续看看‘显瘦’款,还是换其他风格?” 这靠的是在每次回复时,把当前
state
序列化存入Redis,key为
session:{session_id}:state
。
6. 稳定性与可靠性:生产环境必须直面的硬核挑战
6.1 LLM不是万能胶,它有明确的边界
上线前,我们开了三次“泼冷水会议”,专门讨论LLM的不可靠性。结论很清醒: LLM是增强器,不是替代品;它负责把模糊变清晰,但不能替业务规则做决策 。因此所有关键路径都设计了“人类可干预”的出口:
-
价格/库存等敏感字段
:LLM生成的filter中,
price和stock字段必须经过业务规则校验。例如,stock值如果是{"$gt": 0},系统会自动补上{"$lte": 9999}(最大库存上限),防止恶意query拖垮数据库。 -
品牌/类目等关键字段
:LLM输出的
brand="Nike",系统会查品牌库确认是否存在。若不存在,不报错,而是返回空结果,并记录brand_mismatch事件,触发告警。 - 所有LLM调用 :设置1.5秒超时,超时后自动降级到规则引擎。监控大盘显示,超时率<0.3%,对用户体验无感。
6.2 监控与可观测性的实战配置
我们不依赖LLM厂商的黑盒监控,而是自建三层观测体系:
-
应用层 :埋点记录每个搜索请求的完整链路:
-
query_raw: 原始query -
query_parsed: 解析后的intent/entities/constraints -
filter_generated: LLM生成的filter -
results_count: 检索返回数量 -
results_relevance: 人工抽检的top3相关性(1-5分) -
latency_ms: 端到端耗时
-
-
模型层 :监控LLM调用的健康度:
-
llm_error_rate: HTTP错误率(4xx/5xx) -
llm_timeout_rate: 超时率 -
llm_confidence_avg: 解析结果的confidence均值(来自LLM输出的confidence字段)
-
-
业务层 :核心转化漏斗:
-
search_to_click_rate: 搜索结果页点击率 -
click_to_cart_rate: 点击商品后加购率 -
cart_to_buy_rate: 加购后下单率
-
所有指标接入Grafana,设置动态基线告警。例如,当
search_to_click_rate
24小时均值低于昨日同期15%,且
llm_confidence_avg
同步下降,则触发P1告警,通知算法团队检查prompt或模型退化。
6.3 成本控制:如何让LLM不烧穿预算
LLM调用成本是实打实的。我们通过三重优化把单次搜索的LLM成本压到$0.0008(约人民币0.006元):
- 模型分级 :不是所有任务都用大模型。解析query用text-bison@001(便宜);生成商品摘要用text-bison@001(需强推理);而对话回复用chat-bison@001(需上下文理解)。绝不滥用。
- 缓存策略 :对高频query(如“iPhone 15”“卫衣男”)建立LRU缓存,命中率62%,直接省去LLM调用。
- 批处理 :商品摘要生成不是实时的,而是离线批处理。每天凌晨用Spark调度,批量处理新增商品,峰值QPS控制在50以内,避开白天流量高峰。
最后分享一个血泪教训:上线首周,我们没限制LLM的
max_output_tokens
,有个用户连续发送100个“。”,触发LLM生成超长回复,单次调用耗时8秒,成本飙升。现在所有接口强制
max_output_tokens=512
,并加入输入长度校验——超过32字的query,前端就提示“请描述得更简洁些”。
7. 实际效果与团队协作心得
这套系统上线六个月,核心指标变化如下:
- 平均搜索时长:从142秒 → 85秒(-40.1%)
- 首页点击率:从38.2% → 51.7%(+35.3%)
- 加购率:从12.4% → 15.7%(+26.6%)
- 退货率:从23.8% → 21.2%(-10.9%)
- 客服咨询中“找不到想要的商品”类问题:下降67%
但比数字更重要的是团队认知的转变。以前搜索团队和商品运营是“甲方乙方”关系——运营抱怨搜索不准,搜索团队抱怨商品数据差。现在,我们共建了一个“商品健康度看板”,实时显示每个商品的
summary_quality_score
(基于LLM生成摘要的丰富度、众包标签覆盖率、显性字段完整性)。运营看到分数低,会主动优化详情页;搜索团队看到某类目分数普遍低,会推动类目运营制定内容规范。技术不再是黑盒,而是业务的语言翻译器。
我个人在实际操作中最深的体会是: 不要追求“最先进”的技术,而要追求“最贴合业务流”的技术 。我们没上RAG,因为商品数据足够结构化;我们没用微调,因为few-shot prompt engineering已满足精度;我们甚至没上向量数据库的高级功能(如HNSW),因为Pinecone的默认配置+我们的混合检索策略,效果已超越竞品。真正的技术深度,是知道在哪个环节做减法,把力气用在刀刃上。
这个系统后续还可以这样扩展:把用户搜索路径(如“连衣裙→蓝色→显瘦→高腰”)聚类,反向指导商品拍摄角度和详情页文案;或者把对话搜索的中间态(如用户明确说“不要蕾丝”)沉淀为负向标签,永久排除相关商品。但所有扩展,都必须遵循一个铁律:每增加一行代码,必须带来可衡量的业务价值。否则,宁可不做。
807

被折叠的 条评论
为什么被折叠?



