Django+PostgreSQL语义搜索实战:用pgvector实现零新增服务的生产级方案 1. 项目概述为什么在 Django 里做语义相似度不是“炫技”而是刚需你有没有遇到过这种场景用户在搜索框里输入“苹果手机13pro max 256g 黑色”结果系统返回了一堆“iPhone 13 Pro Max 256GB Midnight Black”的商品但偏偏漏掉了数据库里那条字段写成“iPhone13ProMax-256G-MidnightBlack”的记录更糟的是当用户手滑打成“苹国手机13pro max”时整个搜索直接哑火——连个模糊提示都没有。这不是小概率事件而是我们每天在电商、SaaS、内容管理后台里真实面对的“数据失语症”。产品数据从来就不是教科书里的干净样本SKU 字段可能填着“WH-1000XM4”“WH1000XM4”“XM4”三种变体品牌名一会儿是“Moxy Electronics”一会儿缩写成“Moxy”甚至被用户记成“Moxi”描述字段里混着中英文、单位符号、括号注释还经常为空。传统方案——比如用LIKE %关键词%做模糊匹配或者靠 PostgreSQL 自带的tsvector做全文检索——本质上是在比对字符排列它们能识别“apple”和“Apple”的大小写差异却完全无法理解“无线降噪耳机”和“WH-1000XM4”指向的是同一个物理设备。而正则表达式它要求你提前预判所有拼写错误模式可现实中的错别字是无限生成的用户会把“cancellation”打成“cancelation”“cancallation”“cencellation”你不可能穷举。这就是为什么我们最终放弃修补旧方法转而把语义理解能力直接塞进 Django 的血管里用 BERT 生成文本的“意义指纹”再用 pgvector 在 PostgreSQL 里完成毫秒级的指纹比对。整个过程不引入新服务、不改造现有部署流程、不增加运维负担——它就安静地运行在你已有的 Django PostgreSQL 栈上像给老车换了一套智能悬挂系统底盘没变但过坑时再也不颠了。关键词Django、Python、Big Data在这里不是标签而是约束条件我们必须在 Django 的 ORM 框架内完成向量计算在 Python 生态里选型轻量可靠的模型在海量商品数据我们线上库有 270 万条 SKU下保证查询延迟低于 80ms。这不是一个“可以试试”的 PoC而是一个上线后三个月内将用户搜索无结果率从 12.7% 降到 1.9% 的生产级解决方案。2. 整体设计与思路拆解为什么拒绝“大模型向量库”的标准答案很多团队一提语义搜索第一反应就是“上向量数据库”。于是架构图里立刻多出 Milvus、Weaviate 或 Pinecone 的图标接着是 Kafka 同步数据、独立的服务进程、额外的监控告警……但当我们真正坐下来算账时发现这套方案在 Django 场景下存在三个致命断层数据一致性断层、运维复杂度断层、以及开发心智断层。先说数据一致性——你的商品主表在 PostgreSQL 里库存、价格、状态这些强事务字段必须走 ACID如果把向量单独存到另一个库每次更新商品信息时就得发两条写请求一条写 PG一条写向量库中间任何一条失败都会导致语义搜索结果和实际商品状态脱节。我们试过用事务消息队列兜底结果发现光是保证“向量更新最终一致”就花了两周时间调试幂等逻辑。再说运维复杂度一个原本只需维护 Django 应用 PostgreSQL 的小团队突然要懂 hnsw 索引参数调优、GPU 显存分配、向量库分片策略……这已经超出了“加个搜索功能”的范畴变成了“新建一个基础设施团队”。最后是开发心智断层Django 工程师最熟悉的是Product.objects.filter(brand__icontainsMoxy)这种链式调用突然让他们写weaviate_client.query.get(Product, [name, sku]).with_near_text({concepts: [query]})不仅学习成本高更关键的是——当线上搜索出问题时排查路径从“看 Django 日志 → 查 PG 查询计划”拉长到了“查 Weaviate 日志 → 查同步服务日志 → 查 PG 日志”故障定位时间翻了三倍。所以我们的核心设计原则非常朴素向量能力必须成为 PostgreSQL 的原生能力语义搜索必须能用 Django ORM 原生语法表达。pgvector 就是这个原则下的唯一解。它不是一个外部服务而是 PostgreSQL 的一个扩展插件安装后你获得的是vector数据类型、-距离操作符、ivfflat和hnsw两种索引类型——所有能力都通过 SQL 原生暴露。这意味着你可以用Product.objects.extra(tables[product], where[product.name_vector - %s 0.3], params[query_vector])这种混合 ORM/SQL 的方式渐进式接入也可以像文档里那样用CosineDistance注解完全隐藏 SQL 细节。更重要的是向量数据和业务数据物理共存于同一张表、同一事务中product.save()方法里同时更新name_vector和price字段要么全成功要么全回滚。我们做过压测当单表商品数达到 300 万时pgvector 的ivfflat索引查询 P95 延迟稳定在 62ms而同等数据量下独立部署的 Milvus 集群需要 4 台 8C16G 机器才能压到 75ms且内存占用峰值达 28GB。这不是技术选型的妥协而是对 Django 生态本质的尊重Django 的强大不在于它能对接多少外部系统而在于它能把复杂逻辑封装成一行 Python 代码的能力。我们选择 pgvector本质上是在说“让 PostgreSQL 做好向量的事让 Django 做好业务的事中间不要横插一个‘翻译官’。”2.1 模型选型为什么 all-mpnet-base-v2 是 Django 场景的“甜点模型”在 Hugging Face 的 sentence-transformers 模型库里有上百个标着“semantic similarity”的模型从 110M 参数的all-MiniLM-L6-v2到 355M 参数的all-mpnet-base-v2再到需要 16GB 显存的bge-large-zh。我们花了三周时间在真实商品数据上做 A/B 测试最终锁定all-mpnet-base-v2原因不是它参数最多而是它在精度、速度、内存占用三角关系中找到了 Django 应用的黄金平衡点。先看精度我们在 5000 条人工标注的商品对含同款不同写法、近似款、完全无关款上测试all-mpnet-base-v2的 top-1 准确率达到 92.3%比all-MiniLM-L6-v2高 4.7 个百分点。这个差距主要来自对长尾语义的理解——比如“iPhone 13 Pro Max 256GB”和“苹果13pro max 256g”在 MiniLM 里余弦相似度只有 0.61而在 mpnet 里达到 0.83因为 mpnet 的训练数据包含更多跨语言、跨领域的产品描述对“iPhone/苹果”“GB/g”这类映射更鲁棒。再看速度在 CPUIntel Xeon E5-2680 v4上批量编码 1000 条商品名all-MiniLM-L6-v2耗时 8.2 秒all-mpnet-base-v2耗时 14.7 秒看似慢了 79%但注意——Django 的商品保存是低频操作平均每秒不到 3 次这点延迟完全可接受而搜索查询是高频操作此时模型推理发生在客户端用户提交表单时我们用的是异步任务队列Celery Redis预热向量实际用户感知不到。最关键的是内存all-MiniLM-L6-v2加载后占内存 420MBall-mpnet-base-v2占 780MB。我们的 Django 应用部署在 4C8G 的容器里如果每个 Gunicorn worker 都加载一个大模型8GB 内存最多跑 8 个 worker而换成 MiniLM 可以跑 16 个——但实测发现当 worker 数超过 10 时CPU 上下文切换开销反而让整体吞吐下降。最终我们用 12 个 worker mpnet 模型QPS 达到 1850比 16 个 worker MiniLM 的 1720 更高。这里有个重要经验不要迷信“越小越快”要算总拥有成本TCO。mpnet 模型虽然单次推理慢一点但它省去了为提升 5% 准确率而额外增加的 2 台服务器、1 套监控系统、以及工程师每周花在调参上的 3 小时。另外很多人忽略了一个细节all-mpnet-base-v2的输出维度是 768而all-MiniLM-L6-v2是 384。这意味着在 pgvector 里前者每条记录多存 768×43KBfloat32300 万商品就是 9GB 存储后者只多 4.5GB。但 PostgreSQL 的 TOAST 表压缩对向量字段效果极好实测开启pg_compress后768 维向量的平均压缩比达 1:2.3最终存储增量仅 3.9GB——这笔账必须拿真实数据算不能只看模型文档写的“384 维更小”。2.2 架构分层如何让向量能力“隐身”在 Django 的每一层我们的最终架构不是“Django pgvector BERT”而是把向量能力拆解成三层每一层都严格遵循 Django 的设计哲学配置驱动、可插拔、零侵入。第一层是模型抽象层我们没有在models.py里硬编码SentenceTransformer(all-mpnet-base-v2)而是创建了一个embedding_models.py文件里面定义from sentence_transformers import SentenceTransformer from django.conf import settings EMBEDDING_MODELS { default: { class: SentenceTransformer, args: [all-mpnet-base-v2], kwargs: {device: cpu} }, fast: { class: SentenceTransformer, args: [all-MiniLM-L6-v2], kwargs: {device: cpu} } } def get_embedding_model(model_namedefault): config EMBEDDING_MODELS[model_name] return config[class](*config[args], **config[kwargs])这样只要在settings.py里改一行EMBEDDING_MODEL fast就能全局切换模型无需动任何业务代码。第二层是向量生成层我们把get_text_embedding封装成一个 Django 管理命令python manage.py generate_embeddings --model default --batch-size 1000而不是放在save()方法里实时计算。原因很实在商品导入、批量编辑、历史数据迁移这些场景实时计算会导致save()响应时间飙升影响管理后台体验。我们约定——所有新增/修改商品走实时向量化save()里触发所有存量数据用管理命令异步处理并通过 Celery 任务队列控制并发数默认 4 个 worker每个处理 1000 条。第三层是查询抽象层我们没有让每个视图都写CosineDistance注解而是创建了一个ProductSearchManagerclass ProductSearchManager(models.Manager): def semantic_search(self, query_name, query_metadata, name_weight0.7, metadata_weight0.3): from pgvector.django import CosineDistance from django.db.models import F name_vector get_text_embedding(query_name) metadata_vector get_text_embedding(query_metadata) return self.annotate( name_similarityCosineDistance(name_vector, name_vector), metadata_similarityCosineDistance(metadata_vector, metadata_vector), weighted_similarity( name_weight * F(name_similarity) metadata_weight * F(metadata_similarity) ) ).order_by(weighted_similarity) class Product(models.Model): objects models.Manager() search ProductSearchManager() # 新增 search manager # ... 字段定义这样视图里只需要写Product.search.semantic_search(name, metadata)连权重参数都可以动态传入。这三层设计带来的好处是当某天我们需要支持多语言搜索时只需在EMBEDDING_MODELS里加一个multilingual: {class: ..., args: [paraphrase-multilingual-mpnet-base-v2]}然后在管理命令里指定--model multilingual整个系统就完成了升级——没有改模型、没有动视图、没有碰数据库迁移。这才是 Django 式的可扩展性不是靠堆砌新组件而是靠把能力沉淀成可配置、可复用的抽象单元。3. 核心细节解析与实操要点那些文档里不会写的“血泪经验”当你真正把 pgvector 和 BERT 塞进 Django 时会发现官方文档里轻描淡写的几行代码背后藏着一堆必须亲手踩过的坑。这些坑不致命但足以让你在上线前一周陷入“为什么本地跑得飞快线上慢如蜗牛”的焦虑。我把最关键的五个细节展开讲全是我们在 270 万商品库上实测出来的硬核经验。3.1 向量字段的 NULL 处理为什么 save() 里必须加 try/except初看文档你会觉得在save()方法里直接调self.name_vector get_text_embedding(self.name)很自然。但现实是商品名可能是空字符串、全是空格、或包含不可见 Unicode 字符比如从 Excel 导入时带的零宽空格。SentenceTransformer.encode()遇到这些输入时不会报错而是返回一个全零向量[0.0, 0.0, ..., 0.0]。问题来了全零向量和任何其他向量的余弦相似度都是 0这意味着所有空名称商品在搜索时会“意外”排在最前面——因为CosineDistance计算的是距离值越小越近。我们线上就出现过一次事故某供应商上传了 5000 条 SKU 为空的商品结果用户搜“耳机”首页刷出的全是这些脏数据。解决方案不是过滤空字符串业务上允许 SKU 为空而是用异常捕获主动拦截def save(self, *args, **kwargs): # 先清理名称 clean_name (self.name or ).strip() if not clean_name: self.name_vector None # 显式设为 NULL else: try: self.name_vector get_text_embedding(clean_name) except Exception as e: # 记录日志但不中断保存 logger.warning(fFailed to encode name {clean_name}: {e}) self.name_vector None # 元数据同理但要处理 None 值拼接 metadata_parts [] for field in [sku, brand, description, category, color]: value getattr(self, field, None) if value and str(value).strip(): metadata_parts.append(f{field.title()}: {str(value).strip()}) if metadata_parts: metadata_text \n.join(metadata_parts) try: self.metadata_vector get_text_embedding(metadata_text) except Exception as e: logger.warning(fFailed to encode metadata for {self.id}: {e}) self.metadata_vector None else: self.metadata_vector None super().save(*args, **kwargs)关键点有三个第一self.name_vector None而不是self.name_vector []因为 pgvector 的VectorField对None有特殊处理查询时自动跳过第二try/except里不raise避免因单条数据编码失败导致整个事务回滚第三元数据拼接时用metadata_parts动态构建避免fSKU: {self.sku}在self.sku为None时抛TypeError。这个细节看似琐碎但决定了系统在脏数据冲击下的健壮性——我们线上 270 万商品里有 8.3% 的 SKU 字段为空正是这套防御机制让它们在搜索中“隐形”而不是“霸榜”。3.2 元数据拼接的“语义保鲜”技巧为什么不用固定模板文档示例里用fSKU: {self.sku}\nBrand: {self.brand}...拼接元数据这在小规模测试时没问题但放到真实商品库就会暴露问题不同字段的信息密度差异巨大固定模板会稀释关键语义。比如一条高端耳机记录SKU: WH-1000XM4 Brand: Moxy Electronics Description: Industry-leading noise cancellation, 30-hour battery life, touch controls Category: Audio Devices Color: Midnight Black拼接后变成 120 字的文本其中 “Industry-leading”“30-hour”“touch controls” 这些高信息量词被淹没在模板词“SKU:”“Brand:”里。而另一条廉价耳机记录SKU: H123 Brand: Generic Description: Category: Audio Color: Black拼接后只有 30 字且全是低信息量词。结果就是两条记录的元数据向量在向量空间里距离很近因为它们都被模板词主导了。我们的解法是动态加权拼接给每个字段分配语义权重再用\n\n分隔增强结构感def build_metadata_text(self): # 字段权重SKU 和 Brand 是强标识Description 是强语义Category/Color 是弱补充 weights { sku: 2.0, # 最高权重SKU 是唯一标识 brand: 1.5, # 品牌是品类锚点 description: 1.0, # 描述含核心功能但可能为空 category: 0.3, # 类别太宽泛如Audio Devices color: 0.2, # 颜色是末位属性 } parts [] for field, weight in weights.items(): value getattr(self, field, None) if value and str(value).strip(): # 按权重重复字段名强化模型注意力 prefix f{field.title()}: ( field.title()) * int(weight - 1) if weight 1 else f{field.title()}: parts.append(f{prefix} {str(value).strip()}) return \n\n.join(parts) if parts else 实测效果用这个方法拼接后“WH-1000XM4”在元数据向量里的激活强度比固定模板高 3.2 倍而“Generic”品牌的激活强度被压制到 0.4 倍。这意味着搜索“WH-1000XM4 黑色”时系统会优先匹配 SKU 完全一致的记录而不是被“Generic Black”这种弱信号干扰。这个技巧的本质是把人类对商品信息重要性的直觉编码成模型能感知的文本结构。3.3 权重调优的实操方法论如何用 A/B 测试找到 70/30 的真相文档里写的“70% 名称 30% 元数据”听起来很合理但如果你真按这个比例上线会发现搜索“Moxy WH-1000XM4”时名称相似度高的廉价耳机如“Moxy H123”可能因为元数据里写了“wireless headphones”而反超。这是因为名称向量和元数据向量的分布范围不同名称向量的余弦相似度集中在 0.7~0.95 区间而元数据向量因描述长度差异大分布在 0.4~0.85。直接加权会放大元数据的噪声。我们的调优方法是分桶归一化 业务反馈闭环分桶统计用线上 10 万次真实搜索日志按查询类型分桶纯品牌名、含 SKU、含描述词、错别字等对每个桶计算名称/元数据相似度的均值和标准差归一化加权对每个桶定义normalized_name_score (name_similarity - name_mean) / name_std同理算元数据再按0.7 * normalized_name_score 0.3 * normalized_metadata_score加权A/B 测试在管理后台开启“搜索实验开关”随机 5% 流量走新权重其余走旧权重核心指标是“首条命中率”top-1 结果是否为用户真实想找的商品和“平均点击深度”用户翻到第几页才点击反馈闭环当新权重在“含 SKU”桶的首条命中率提升 12%但在“错别字”桶下降 5% 时我们不是简单调权重而是为错别字桶单独加一个typo_boost参数在查询时检测到错别字用 pyspellchecker就动态提高元数据权重到 40%。最终我们得到的不是单一权重而是一个权重决策树如果查询含 4 位以上数字大概率是 SKU→ 名称权重 80%如果查询含“wireless”“noise”“battery”等描述词 → 元数据权重 40%如果查询编辑距离 2检测到错别字→ 元数据权重 35%并启用拼写纠错候选集这个决策树用 20 行 Python 代码实现嵌入在搜索视图里不需要改模型或数据库。它证明了一件事语义搜索的精度一半靠模型一半靠对业务场景的深度理解。3.4 pgvector 索引的“冷启动”陷阱为什么首次查询慢得吓人当你第一次在 Django shell 里执行Product.search.semantic_search(WH-1000XM4)会发现第一次查询耗时 2.3 秒之后稳定在 60ms。这不是 Django 缓存的问题而是 pgvector 的ivfflat索引在首次使用时需要构建聚类中心centroids。ivfflat的原理是先把所有向量聚成 k 个簇默认 k100查询时只计算目标向量到最近几个簇的距离大幅减少计算量。但聚类中心必须在索引创建后由第一次查询触发构建。问题在于Django 的migrate命令只创建索引结构不触发聚类——它等着第一次SELECT来干活。线上环境如果流量突增第一批查询就会集体卡顿。我们的解法是索引预热脚本# management/commands/warmup_pgvector.py from django.core.management.base import BaseCommand from django.db import connection class Command(BaseCommand): help Warm up pgvector ivfflat indexes by building centroids def handle(self, *args, **options): with connection.cursor() as cursor: # 对 name_vector 字段预热 cursor.execute( SET ivfflat.probes 10; SELECT * FROM product ORDER BY name_vector - ARRAY[0.0::real, 0.0::real, ...] LIMIT 1; ) # 对 metadata_vector 字段预热同理 cursor.execute( SET ivfflat.probes 10; SELECT * FROM product ORDER BY metadata_vector - ARRAY[0.0::real, 0.0::real, ...] LIMIT 1; ) self.stdout.write(self.style.SUCCESS(pgvector indexes warmed up))这个脚本在应用启动后、流量接入前执行我们集成在 Kubernetes 的postStarthook 里。关键点是SET ivfflat.probes 10它告诉 pgvector 在预热时多探查几个簇确保聚类质量。实测表明预热后首次查询延迟从 2.3 秒降到 85ms且 P95 稳定在 62ms。这个细节再次印证向量搜索不是“装上就跑”它需要像数据库一样做运维准备。3.5 GPU 加速的务实取舍为什么我们只在批处理用 GPU不用在在线服务看到“BERT 推理慢”很多团队第一反应是上 GPU。但我们做了成本核算一台 8C16G 的 CPU 服务器月租 $45一台 4C16GT4 GPU 的服务器月租 $128。如果把 GPU 用在在线搜索用户每次查询都走 GPU那么T4 显存 16GB最多同时加载 2 个all-mpnet-base-v2模型每个占 780MB即 2 个并发查询当 QPS 超过 2请求就会排队平均延迟飙升而 CPU 服务器用 12 个 Gunicorn workerQPS 轻松破 1800。所以我们的策略是GPU 专用于离线批处理CPU 专用于在线服务商品导入、批量编辑、每日凌晨的数据稽核——这些低频、大批量任务走 Celery GPU worker用户实时搜索——全部走 CPU worker用torch.set_num_threads(2)限制每个 worker 最多用 2 个 CPU 核避免争抢。我们写了一个gpu_batch_encoder.py脚本用torch.cuda.is_available()自动检测 GPU然后用DataLoader批量加载商品实测 10 万条商品编码从 CPU 的 24 分钟降到 GPU 的 3.2 分钟。但这个脚本永远不会出现在 Django 视图里——它只是运维工具。这个取舍的背后是对 Django 应用本质的认知Django 不是推理框架它是业务逻辑编排器。把 GPU 当作“加速离线任务的工具”而不是“提升在线服务的必需品”让我们在性能和成本之间找到了可持续的平衡点。4. 实操过程与核心环节实现从零开始搭建可上线的语义搜索现在我们把所有碎片整合成一条可执行的流水线。这不是教程式的“第一步第二步”而是按真实项目节奏组织的实操手册——从环境准备到上线验证每一步都标注了耗时、风险点和绕过方案。整个过程我们团队实测耗时 3.5 天含测试你可以按这个节奏推进。4.1 环境准备Docker Compose 里的 pgvector 无缝集成我们不推荐在已有 PostgreSQL 实例上手动安装 pgvector 扩展容易版本冲突而是用 Docker Compose 定义一个开箱即用的环境。docker-compose.yml关键片段如下version: 3.8 services: db: image: pgvector/pgvector:pg17 # 直接用官方镜像省去编译 restart: unless-stopped environment: POSTGRES_DB: myproject POSTGRES_USER: django POSTGRES_PASSWORD: secret volumes: - postgres_data:/var/lib/postgresql/data # 必须暴露 5432 端口否则 Django 连不上 ports: - 5432:5432 web: build: . command: gunicorn myproject.wsgi:application --bind 0.0.0.0:8000 depends_on: - db environment: DATABASE_URL: postgresql://django:secretdb:5432/myproject # 关键为向量计算预留足够内存 mem_limit: 2g volumes: postgres_data:风险点与绕过方案风险pgvector/pgvector:pg17镜像基于 PostgreSQL 17而你的 Django 项目可能依赖较老的psycopg2版本如 2.9.x连接时会报server version mismatch。绕过在requirements.txt里强制指定psycopg2-binary2.9.7,3.0.0并运行pip install --force-reinstall psycopg2-binary2.9.7。耗时环境准备拉镜像、建容器约 8 分钟比手动安装快 20 分钟。接下来在 Django 的settings.py里配置数据库import dj_database_url DATABASES { default: dj_database_url.config( defaultos.environ.get(DATABASE_URL, postgresql://django:secretlocalhost:5432/myproject) ) } # 启用 pgvector 的 Django 扩展 INSTALLED_APPS [pgvector.django]然后执行python manage.py migrate你会看到 Django 自动创建vector类型和pgvector扩展。注意migrate不会自动创建向量索引这是下一步的事。4.2 模型下载与缓存如何避免 CI/CD 时的网络超时SentenceTransformer默认从 Hugging Face 下载模型但在 CI/CD 流水线里网络不稳定会导致构建失败。我们的方案是预下载 本地缓存在本地机器运行python -c from sentence_transformers import SentenceTransformer; model SentenceTransformer(all-mpnet-base-v2)模型会下载到~/.cache/torch/sentence_transformers/把整个缓存目录打包tar -czf sentence-transformers-cache.tgz -C ~/.cache/torch/sentence_transformers/ .在 Dockerfile 里加入COPY sentence-transformers-cache.tgz /tmp/ RUN mkdir -p /root/.cache/torch/sentence_transformers \ tar -xzf /tmp/sentence-transformers-cache.tgz -C /root/.cache/torch/sentence_transformers/这样CI/CD 构建时不再需要联网下载耗时从平均 3 分钟常因超时失败降到 12 秒。我们还加了一个健康检查脚本health_check.py在容器启动时验证模型加载from sentence_transformers import SentenceTransformer try: model SentenceTransformer(all-mpnet-base-v2, devicecpu) test_vec model.encode([test]) assert len(test_vec[0]) 768 print(✅ Model loaded successfully) except Exception as e: print(f❌ Model load failed: {e}) exit(1)这个脚本集成在 Kubernetes 的livenessProbe里确保容器只在模型可用时才接收流量。4.3 数据迁移如何安全地为百万级商品添加向量字段给已有 270 万商品的表添加两个vector(768)字段不能用ALTER TABLE ADD COLUMN直接加——PostgreSQL 会锁表线上服务中断。我们的方案是分阶段零停机迁移阶段一添加 nullable 字段耗时 2 分钟ALTER TABLE product ADD COLUMN name_vector vector(768); ALTER TABLE product ADD COLUMN metadata_vector vector(768);这两个字段允许NULLALTER语句瞬间完成无锁。阶段二后台异步填充耗时 4.5 小时用管理命令python manage.py generate_embeddings --batch-size 5000 --workers 4核心逻辑def handle(self, *args, **options): batch_size options[batch_size] workers options[workers] # 分页处理避免内存溢出 total Product.objects.count() for offset in range(0, total, batch_size): products list(Product.objects.all()[offset:offsetbatch_size]) if not products: break # 批量编码用 torch.no_grad() 节省内存 with torch.no_grad(): names [p.name or for p in products] metadatas [p.build_metadata_text() for p in products] name_vectors model.encode(names, batch_size128) metadata_vectors model.encode(metadatas, batch_size128) # 批量更新用 bulk_update 减少 SQL 次数 for i, p in enumerate(products): p.name_vector name_vectors[i].tolist() p.metadata_vector metadata_vectors[i].tolist() Product.objects.bulk_update(products, [name_vector, metadata_vector], batch_size1000)阶段三创建索引耗时 18 分钟-- 创建 ivfflat 索引probes10 平衡精度和速度 CREATE INDEX ON product USING ivfflat (name_vector vector_cosine_ops) WITH (lists 100); CREATE INDEX ON product USING ivfflat (metadata_vector vector_cosine_ops) WITH (lists 100);阶段四验证与切换耗时 30 分钟写一个验证脚本随机抽 1000 条商品对比新旧搜索结果# 验证脚本 old_results legacy_search(WH-10