diff --git a/README.md b/README.md
index 781a6e6..59b7577 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,33 @@ query anchor
+
+对外:
+embedding服务:
+ curl -X POST http://120.76.41.98:6005/embed/text \
+ -H "Content-Type: application/json" \
+ -d '["衣服", "Bohemian Maxi Dress"]'
+
+
+翻译服务:
+# 方式1:直接运行
+python api/translator_app.py
+# 方式2:使用 uvicorn
+uvicorn api.translator_app:app --host 0.0.0.0 --port 6006 --reload
+
+curl -X POST http://localhost:6006/translate -H "Content-Type: application/json" -d '{
+ "text": "商品名称",
+ "target_lang": "en",
+ "source_lang": "zh"
+ }'
+
+localhost替换为
+服务器内网地址:
+10.0.163.168
+公网地址:
+120.76.41.98
+
+
# 电商搜索引擎 SaaS
一个针对跨境独立站(店匠 Shoplazza 等)的多租户可配置搜索平台。README 作为项目导航入口,帮助你在不同阶段定位到更详细的文档。
diff --git a/api/translator_app.py b/api/translator_app.py
new file mode 100644
index 0000000..7f41913
--- /dev/null
+++ b/api/translator_app.py
@@ -0,0 +1,283 @@
+
+"""
+
+# 方式1:直接运行
+python api/translator_app.py
+
+# 方式2:使用 uvicorn
+uvicorn api.translator_app:app --host 0.0.0.0 --port 6006 --reload
+
+
+使用说明:
+Translation HTTP Service
+
+This service provides a RESTful API for text translation using DeepL API.
+The service runs on port 6006 and provides a simple translation endpoint.
+
+API Endpoint:
+ POST /translate
+
+Request Body (JSON):
+ {
+ "text": "要翻译的文本",
+ "target_lang": "en", # Required: target language code (zh, en, ru, etc.)
+ "source_lang": "zh" # Optional: source language code (auto-detect if not provided)
+ }
+
+Response (JSON):
+ {
+ "text": "要翻译的文本",
+ "target_lang": "en",
+ "source_lang": "zh",
+ "translated_text": "Text to translate",
+ "status": "success"
+ }
+
+Usage Examples:
+
+1. Translate Chinese to English:
+ curl -X POST http://localhost:6006/translate \
+ -H "Content-Type: application/json" \
+ -d '{
+ "text": "商品名称",
+ "target_lang": "en",
+ "source_lang": "zh"
+ }'
+
+2. Translate with auto-detection:
+ curl -X POST http://localhost:6006/translate \
+ -H "Content-Type: application/json" \
+ -d '{
+ "text": "Product name",
+ "target_lang": "zh"
+ }'
+
+3. Translate Russian to English:
+ curl -X POST http://localhost:6006/translate \
+ -H "Content-Type: application/json" \
+ -d '{
+ "text": "Название товара",
+ "target_lang": "en",
+ "source_lang": "ru"
+ }'
+
+Health Check:
+ GET /health
+
+ curl http://localhost:6006/health
+
+Start the service:
+ python api/translator_app.py
+ # or
+ uvicorn api.translator_app:app --host 0.0.0.0 --port 6006 --reload
+"""
+
+import os
+import sys
+import logging
+import argparse
+import uvicorn
+from typing import Optional
+from fastapi import FastAPI, HTTPException
+from fastapi.responses import JSONResponse
+from fastapi.middleware.cors import CORSMiddleware
+from pydantic import BaseModel, Field
+
+# Add parent directory to path
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from query.translator import Translator
+from config.env_config import DEEPL_AUTH_KEY, REDIS_CONFIG
+
+# Configure logging
+logging.basicConfig(
+ level=logging.INFO,
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
+)
+logger = logging.getLogger(__name__)
+
+# Fixed translation prompt
+TRANSLATION_PROMPT = "Translate the original text into an English product SKU name. Requirements: Ensure accurate and complete transmission of the original information, with concise, clear, authentic, and professional language."
+
+# Global translator instance
+_translator: Optional[Translator] = None
+
+
+def init_translator():
+ """Initialize translator instance."""
+ global _translator
+ if _translator is None:
+ logger.info("Initializing translator...")
+ _translator = Translator(
+ api_key=DEEPL_AUTH_KEY,
+ use_cache=True,
+ timeout=10
+ )
+ logger.info("Translator initialized")
+ return _translator
+
+
+# Request/Response models
+class TranslationRequest(BaseModel):
+ """Translation request model."""
+ text: str = Field(..., description="Text to translate")
+ target_lang: str = Field(..., description="Target language code (zh, en, ru, etc.)")
+ source_lang: Optional[str] = Field(None, description="Source language code (optional, auto-detect if not provided)")
+
+ class Config:
+ json_schema_extra = {
+ "example": {
+ "text": "商品名称",
+ "target_lang": "en",
+ "source_lang": "zh"
+ }
+ }
+
+
+class TranslationResponse(BaseModel):
+ """Translation response model."""
+ text: str = Field(..., description="Original text")
+ target_lang: str = Field(..., description="Target language code")
+ source_lang: Optional[str] = Field(None, description="Source language code (detected or provided)")
+ translated_text: str = Field(..., description="Translated text")
+ status: str = Field(..., description="Translation status")
+
+
+# Create FastAPI app
+app = FastAPI(
+ title="Translation Service API",
+ description="RESTful API for text translation using DeepL",
+ version="1.0.0",
+ docs_url="/docs",
+ redoc_url="/redoc"
+)
+
+# Add CORS middleware
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+
+@app.on_event("startup")
+async def startup_event():
+ """Initialize translator on startup."""
+ logger.info("Starting Translation Service API on port 6006")
+ try:
+ init_translator()
+ logger.info("Translation service ready")
+ except Exception as e:
+ logger.error(f"Failed to initialize translator: {e}", exc_info=True)
+ logger.warning("Service will start but translation may not work correctly")
+
+
+@app.get("/health")
+async def health_check():
+ """Health check endpoint."""
+ try:
+ translator = init_translator()
+ return {
+ "status": "healthy",
+ "service": "translation",
+ "translator_initialized": translator is not None,
+ "cache_enabled": translator.use_cache if translator else False
+ }
+ except Exception as e:
+ logger.error(f"Health check failed: {e}")
+ return JSONResponse(
+ status_code=503,
+ content={
+ "status": "unhealthy",
+ "error": str(e)
+ }
+ )
+
+
+@app.post("/translate", response_model=TranslationResponse)
+async def translate(request: TranslationRequest):
+ """
+ Translate text to target language.
+
+ Uses a fixed prompt optimized for product SKU name translation.
+ The translation is cached in Redis for performance.
+ """
+ if not request.text or not request.text.strip():
+ raise HTTPException(
+ status_code=400,
+ detail="Text cannot be empty"
+ )
+
+ if not request.target_lang:
+ raise HTTPException(
+ status_code=400,
+ detail="target_lang is required"
+ )
+
+ try:
+ translator = init_translator()
+
+ # Translate using the fixed prompt
+ translated_text = translator.translate(
+ text=request.text,
+ target_lang=request.target_lang,
+ source_lang=request.source_lang,
+ prompt=TRANSLATION_PROMPT
+ )
+
+ if translated_text is None:
+ raise HTTPException(
+ status_code=500,
+ detail="Translation failed"
+ )
+
+ return TranslationResponse(
+ text=request.text,
+ target_lang=request.target_lang,
+ source_lang=request.source_lang,
+ translated_text=translated_text,
+ status="success"
+ )
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Translation error: {e}", exc_info=True)
+ raise HTTPException(
+ status_code=500,
+ detail=f"Translation error: {str(e)}"
+ )
+
+
+@app.get("/")
+async def root():
+ """Root endpoint with API information."""
+ return {
+ "service": "Translation Service API",
+ "version": "1.0.0",
+ "status": "running",
+ "endpoints": {
+ "translate": "POST /translate",
+ "health": "GET /health",
+ "docs": "GET /docs"
+ }
+ }
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description='Start translation API service')
+ parser.add_argument('--host', default='0.0.0.0', help='Host to bind to')
+ parser.add_argument('--port', type=int, default=6006, help='Port to bind to')
+ parser.add_argument('--reload', action='store_true', help='Enable auto-reload')
+ args = parser.parse_args()
+
+ # Run server
+ uvicorn.run(
+ "api.translator_app:app",
+ host=args.host,
+ port=args.port,
+ reload=args.reload
+ )
+
diff --git a/docs/CNCLIP_SERVICE.md b/docs/CNCLIP_SERVICE.md
deleted file mode 100644
index 0e4d919..0000000
--- a/docs/CNCLIP_SERVICE.md
+++ /dev/null
@@ -1,187 +0,0 @@
-# CN-CLIP 编码服务
-
-## 模块说明
-
-CN-CLIP 编码服务基于 [clip-as-service](https://github.com/jina-ai/clip-as-service) 提供中文 CLIP 模型的文本和图像编码功能。服务使用 gRPC 协议,支持批量编码,返回固定维度的向量表示。
-
-### 功能特性
-
-- 文本编码:将中文文本编码为向量
-- 图像编码:将图像(本地文件或远程 URL)编码为向量
-- 混合编码:同时编码文本和图像
-- 批量处理:支持批量编码,提高效率
-
-### 技术架构
-
-- **框架**: clip-as-service (基于 Jina)
-- **模型**: CN-CLIP/ViT-L-14-336(默认)
-- **协议**: gRPC(默认,官方推荐)
-- **运行时**: PyTorch
-
-## 启动服务
-
-### 基本用法
-
-```bash
-./scripts/start_cnclip_service.sh
-```
-
-### 启动参数
-
-| 参数 | 说明 | 默认值 |
-|------|------|--------|
-| `--port PORT` | 服务端口 | 51000 |
-| `--device DEVICE` | 设备类型:cuda 或 cpu | 自动检测 |
-| `--batch-size SIZE` | 批处理大小 | 32 |
-| `--num-workers NUM` | 预处理线程数 | 4 |
-| `--dtype TYPE` | 数据类型:float16 或 float32 | float16 |
-| `--model-name NAME` | 模型名称 | CN-CLIP/ViT-L-14-336 |
-| `--replicas NUM` | 副本数 | 1 |
-
-### 示例
-
-```bash
-# 使用默认配置启动
-./scripts/start_cnclip_service.sh
-
-# 指定端口和设备
-./scripts/start_cnclip_service.sh --port 52000 --device cpu
-
-# 使用其他模型
-./scripts/start_cnclip_service.sh --model-name CN-CLIP/ViT-H-14
-```
-
-### 停止服务
-
-```bash
-./scripts/stop_cnclip_service.sh
-```
-
-## API 接口说明
-
-### Python 客户端
-
-服务使用 gRPC 协议,必须使用 Python 客户端:
-
-```python
-from clip_client import Client
-
-# 创建客户端(使用 grpc:// 协议)
-c = Client('grpc://localhost:51000')
-```
-
-### 编码接口
-
-#### 1. 文本编码
-
-```python
-from clip_client import Client
-
-c = Client('grpc://localhost:51000')
-
-# 编码单个文本
-result = c.encode(['这是测试文本'])
-print(result.shape) # (1, 1024)
-
-# 编码多个文本
-result = c.encode(['文本1', '文本2', '文本3'])
-print(result.shape) # (3, 1024)
-```
-
-#### 2. 图像编码
-
-```python
-# 编码远程图像 URL
-result = c.encode(['https://oss.essa.cn/98532128-cf8e-456c-9e30-6f2a5ea0c19f.jpg'])
-print(result.shape) # (1, 1024)
-
-# 编码本地图像文件
-result = c.encode(['/path/to/image.jpg'])
-print(result.shape) # (1, 1024)
-```
-
-#### 3. 混合编码
-
-```python
-# 同时编码文本和图像
-result = c.encode([
- '这是文本',
- 'https://oss.essa.cn/98532128-cf8e-456c-9e30-6f2a5ea0c19f.jpg',
- '另一个文本'
-])
-print(result.shape) # (3, 1024)
-```
-
-### 返回格式
-
-- **类型**: `numpy.ndarray`
-- **形状**: `(N, 1024)`,其中 N 是输入数量
-- **数据类型**: `float32`
-- **维度**: 1024(CN-CLIP 模型的 embedding 维度)
-
-### 支持的模型
-
-| 模型名称 | 说明 | 推荐场景 |
-|---------|------|---------|
-| `CN-CLIP/ViT-B-16` | 基础版本,速度快 | 对速度要求高的场景 |
-| `CN-CLIP/ViT-L-14` | 平衡版本 | 通用场景 |
-| `CN-CLIP/ViT-L-14-336` | 高分辨率版本(默认) | 需要处理高分辨率图像 |
-| `CN-CLIP/ViT-H-14` | 大型版本,精度高 | 对精度要求高的场景 |
-| `CN-CLIP/RN50` | ResNet-50 版本 | 兼容性场景 |
-
-## 测试
-
-运行测试脚本:
-
-```bash
-./scripts/test_cnclip_service.sh
-```
-
-测试脚本会验证:
-- 文本编码功能
-- 图像编码功能(远程 URL)
-- 混合编码功能
-
-每个测试会显示 embedding 的维度和前 20 个数字。
-
-## 查看日志
-
-```bash
-tail -f /data/tw/SearchEngine/logs/cnclip_service.log
-```
-
-## 常见问题
-
-### 1. 服务启动失败
-
-- 检查端口是否被占用:`lsof -i :51000`
-- 检查 conda 环境是否正确激活
-- 查看日志文件获取详细错误信息
-
-### 2. 客户端连接失败
-
-确保使用正确的协议:
-
-```python
-# 正确:使用 grpc://
-c = Client('grpc://localhost:51000')
-
-# 错误:不要使用 http://
-# c = Client('http://localhost:51000') # 会失败
-```
-
-### 3. 编码失败
-
-- 检查服务是否正常运行
-- 检查输入格式是否正确
-- 查看服务日志排查错误
-
-### 4. 依赖安装
-
-确保已安装必要的依赖:
-
-```bash
-pip install clip-client
-```
-
-服务端依赖会在启动脚本中自动检查。
diff --git a/docs/CNCLIP_SERVICE说明文档.md b/docs/CNCLIP_SERVICE说明文档.md
new file mode 100644
index 0000000..d2725af
--- /dev/null
+++ b/docs/CNCLIP_SERVICE说明文档.md
@@ -0,0 +1,210 @@
+
+# TODO
+
+现在,跟自己 cn_clip 预估的结果,有差别:
+这个比较接近: 可能是预处理逻辑有些不一样。
+https://oss.essa.cn/98532128-cf8e-456c-9e30-6f2a5ea0c19f.jpg
+normlize后的结果:
+0.046295166015625,0.012847900390625,-0.0299530029296875,-0.01629638671875,0.01708984375,0.00487518310546875,0.01284027099609375,0.01348876953125,0.04617632180452347, 0.012860896065831184, -0.030133124440908432, -0.0162516962736845,
+0.04617632180452347, 0.012860896065831184, -0.030133124440908432, -0.0162516962736845, 0.01708567887544632, 0.005110889207571745
+
+以下两个,差别非常大,感觉不是一个模型:
+https://aisearch.cdn.bcebos.com/fileManager/GtB5doGAr1skTx38P7fb7Q/182.jpg?authorization=bce-auth-v1%2F7e22d8caf5af46cc9310f1e3021709f3%2F2025-12-30T04%3A45%3A38Z%2F86400%2Fhost%2Ffe222039926cb7ff593021af40268c782b8892598114e24773d0c1bfc976a8df
+https://oss.essa.cn/2e353867-7496-4d4e-a7c8-0af50f49f6eb.jpg?x-oss-process=image/resize,m_lfit,w_2048,h_2048
+
+curl -X POST "http://120.76.41.98:5000/embedding/generate_image_embeddings" -H "Content-Type: application/json" -d '[
+ {
+ "id": "test_1",
+ "pic_url": "https://oss.essa.cn/98532128-cf8e-456c-9e30-6f2a5ea0c19f.jpg"
+ }
+ ]'
+
+
+
+# CN-CLIP 编码服务
+
+## 模块说明
+
+CN-CLIP 编码服务基于 [clip-as-service](https://github.com/jina-ai/clip-as-service) 提供中文 CLIP 模型的文本和图像编码功能。服务使用 gRPC 协议,支持批量编码,返回固定维度的向量表示。
+
+### 功能特性
+
+- 文本编码:将中文文本编码为向量
+- 图像编码:将图像(本地文件或远程 URL)编码为向量
+- 混合编码:同时编码文本和图像
+- 批量处理:支持批量编码,提高效率
+
+### 技术架构
+
+- **框架**: clip-as-service (基于 Jina)
+- **模型**: CN-CLIP/ViT-L-14-336(默认)
+- **协议**: gRPC(默认,官方推荐)
+- **运行时**: PyTorch
+
+## 启动服务
+
+### 基本用法
+
+```bash
+./scripts/start_cnclip_service.sh
+```
+
+### 启动参数
+
+| 参数 | 说明 | 默认值 |
+|------|------|--------|
+| `--port PORT` | 服务端口 | 51000 |
+| `--device DEVICE` | 设备类型:cuda 或 cpu | 自动检测 |
+| `--batch-size SIZE` | 批处理大小 | 32 |
+| `--num-workers NUM` | 预处理线程数 | 4 |
+| `--dtype TYPE` | 数据类型:float16 或 float32 | float16 |
+| `--model-name NAME` | 模型名称 | CN-CLIP/ViT-L-14-336 |
+| `--replicas NUM` | 副本数 | 1 |
+
+### 示例
+
+```bash
+# 使用默认配置启动
+./scripts/start_cnclip_service.sh
+
+# 指定端口和设备
+./scripts/start_cnclip_service.sh --port 52000 --device cpu
+
+# 使用其他模型
+./scripts/start_cnclip_service.sh --model-name CN-CLIP/ViT-H-14
+```
+
+### 停止服务
+
+```bash
+./scripts/stop_cnclip_service.sh
+```
+
+## API 接口说明
+
+### Python 客户端
+
+服务使用 gRPC 协议,必须使用 Python 客户端:
+
+```python
+from clip_client import Client
+
+# 创建客户端(使用 grpc:// 协议)
+c = Client('grpc://localhost:51000')
+```
+
+### 编码接口
+
+#### 1. 文本编码
+
+```python
+from clip_client import Client
+
+c = Client('grpc://localhost:51000')
+
+# 编码单个文本
+result = c.encode(['这是测试文本'])
+print(result.shape) # (1, 1024)
+
+# 编码多个文本
+result = c.encode(['文本1', '文本2', '文本3'])
+print(result.shape) # (3, 1024)
+```
+
+#### 2. 图像编码
+
+```python
+# 编码远程图像 URL
+result = c.encode(['https://oss.essa.cn/98532128-cf8e-456c-9e30-6f2a5ea0c19f.jpg'])
+print(result.shape) # (1, 1024)
+
+# 编码本地图像文件
+result = c.encode(['/path/to/image.jpg'])
+print(result.shape) # (1, 1024)
+```
+
+#### 3. 混合编码
+
+```python
+# 同时编码文本和图像
+result = c.encode([
+ '这是文本',
+ 'https://oss.essa.cn/98532128-cf8e-456c-9e30-6f2a5ea0c19f.jpg',
+ '另一个文本'
+])
+print(result.shape) # (3, 1024)
+```
+
+### 返回格式
+
+- **类型**: `numpy.ndarray`
+- **形状**: `(N, 1024)`,其中 N 是输入数量
+- **数据类型**: `float32`
+- **维度**: 1024(CN-CLIP 模型的 embedding 维度)
+
+### 支持的模型
+
+| 模型名称 | 说明 | 推荐场景 |
+|---------|------|---------|
+| `CN-CLIP/ViT-B-16` | 基础版本,速度快 | 对速度要求高的场景 |
+| `CN-CLIP/ViT-L-14` | 平衡版本 | 通用场景 |
+| `CN-CLIP/ViT-L-14-336` | 高分辨率版本(默认) | 需要处理高分辨率图像 |
+| `CN-CLIP/ViT-H-14` | 大型版本,精度高 | 对精度要求高的场景 |
+| `CN-CLIP/RN50` | ResNet-50 版本 | 兼容性场景 |
+
+## 测试
+
+运行测试脚本:
+
+```bash
+./scripts/test_cnclip_service.sh
+```
+
+测试脚本会验证:
+- 文本编码功能
+- 图像编码功能(远程 URL)
+- 混合编码功能
+
+每个测试会显示 embedding 的维度和前 20 个数字。
+
+## 查看日志
+
+```bash
+tail -f /data/tw/SearchEngine/logs/cnclip_service.log
+```
+
+## 常见问题
+
+### 1. 服务启动失败
+
+- 检查端口是否被占用:`lsof -i :51000`
+- 检查 conda 环境是否正确激活
+- 查看日志文件获取详细错误信息
+
+### 2. 客户端连接失败
+
+确保使用正确的协议:
+
+```python
+# 正确:使用 grpc://
+c = Client('grpc://localhost:51000')
+
+# 错误:不要使用 http://
+# c = Client('http://localhost:51000') # 会失败
+```
+
+### 3. 编码失败
+
+- 检查服务是否正常运行
+- 检查输入格式是否正确
+- 查看服务日志排查错误
+
+### 4. 依赖安装
+
+确保已安装必要的依赖:
+
+```bash
+pip install clip-client
+```
+
+服务端依赖会在启动脚本中自动检查。
diff --git a/docs/MySQL到ES字段映射说明-业务版.md b/docs/MySQL到ES字段映射说明-业务版.md
new file mode 100644
index 0000000..22b882a
--- /dev/null
+++ b/docs/MySQL到ES字段映射说明-业务版.md
@@ -0,0 +1,570 @@
+# MySQL 到 Elasticsearch 字段映射说明(业务版)
+
+## 1. 概述
+
+本文档说明 Elasticsearch 中的商品文档(Document)的各个字段是如何从 MySQL 数据库中的相关表获取和转换的。
+
+### 1.1 数据源表
+
+- **SPU 表** (`shoplazza_product_spu`): 商品基本信息
+- **SKU 表** (`shoplazza_product_sku`): 商品变体信息(多款式)
+- **Option 表** (`shoplazza_product_option`): 选项名称定义(如:颜色、尺寸)
+- **类目表** (`product_category`): 类目信息(通过 SPU 表的 category_id 关联)
+
+### 1.2 数据关系
+
+```
+SPU (1) ──→ (N) SKU # 一个商品有多个变体
+SPU (1) ──→ (N) Option # 一个商品有多个选项维度
+SPU.category_id ──→ 类目表 # 商品关联类目
+SKU.option1/2/3 ──→ Option # SKU 的选项值对应 Option 的 position
+```
+
+---
+
+## 2. 基础字段映射
+
+### 2.1 标识字段
+
+| ES 字段 | 数据来源表 | 表中字段 | 转换说明 |
+|---------|-----------|----------|----------|
+| `tenant_id` | SPU 表 | `tenant_id` | 直接使用,转为字符串 |
+| `spu_id` | SPU 表 | `id` | 直接使用,转为字符串 |
+
+### 2.2 文本字段(多语言)
+
+文本字段支持中英文双语,根据租户配置自动翻译。
+
+| ES 字段 | 数据来源表 | 表中字段 | 转换说明 |
+|---------|-----------|----------|----------|
+| `title_zh` | SPU 表 | `title` | 如果主语言是中文,直接使用;否则翻译为中文 |
+| `title_en` | SPU 表 | `title` | 如果主语言是英文,直接使用;否则翻译为英文 |
+| `brief_zh` | SPU 表 | `brief` | 同上 |
+| `brief_en` | SPU 表 | `brief` | 同上 |
+| `description_zh` | SPU 表 | `description` | 同上 |
+| `description_en` | SPU 表 | `description` | 同上 |
+| `vendor_zh` | SPU 表 | `vendor` | 同上 |
+| `vendor_en` | SPU 表 | `vendor` | 同上 |
+
+**翻译规则:**
+- 根据租户配置的 `primary_language` 确定主语言
+- 如果配置了 `translate_to_en=true`,且主语言不是英文,则翻译为英文
+- 如果配置了 `translate_to_zh=true`,且主语言不是中文,则翻译为中文
+
+### 2.3 标签字段
+
+| ES 字段 | 数据来源表 | 表中字段 | 转换说明 |
+|---------|-----------|----------|----------|
+| `tags` | SPU 表 | `tags` | 逗号分隔的字符串 → 数组
示例:`"标签1,标签2"` → `["标签1", "标签2"]` |
+
+### 2.4 图片字段
+
+| ES 字段 | 数据来源表 | 表中字段 | 转换说明 |
+|---------|-----------|----------|----------|
+| `image_url` | SPU 表 | `image_src` | 直接使用,如果 URL 不以 `http` 开头则添加 `//` 前缀 |
+
+### 2.5 销量字段
+
+| ES 字段 | 数据来源表 | 表中字段 | 转换说明 |
+|---------|-----------|----------|----------|
+| `sales` | SPU 表 | `fake_sales` | 转为整数,如果为空则为 0 |
+
+### 2.6 时间字段
+
+| ES 字段 | 数据来源表 | 表中字段 | 转换说明 |
+|---------|-----------|----------|----------|
+| `create_time` | SPU 表 | `create_time` | 转为 ISO 格式字符串(如:`2024-01-01T00:00:00`) |
+| `update_time` | SPU 表 | `update_time` | 转为 ISO 格式字符串 |
+
+---
+
+## 3. 类别(Category)字段映射
+
+### 3.1 类别数据来源
+
+类别信息主要来自 SPU 表的以下字段:
+- `category_path`: 类目路径,逗号分隔的类目ID列表(如:`"1,2,3"`)
+- `category`: 类目名称(备用字段)
+- `category_id`: 当前类目ID
+- `category_level`: 类目层级
+
+### 3.2 类别映射流程
+
+**步骤 1:解析 category_path**
+
+从 SPU 表的 `category_path` 字段解析出类目ID列表:
+
+```
+category_path = "1,2,3"
+→ category_ids = ["1", "2", "3"]
+```
+
+**步骤 2:ID 转名称**
+
+通过预加载的类目映射表(从 SPU 表查询 `category_id` 和 `category` 字段构建),将ID转换为名称:
+
+```
+映射表:{"1": "电子产品", "2": "手机", "3": "iPhone"}
+category_ids = ["1", "2", "3"]
+→ category_names = ["电子产品", "手机", "iPhone"]
+```
+
+**步骤 3:构建 ES 字段**
+
+| ES 字段 | 数据来源 | 转换说明 |
+|---------|----------|----------|
+| `category_path_zh` | 类目名称列表 | 用 `/` 连接:`"电子产品/手机/iPhone"` |
+| `category1_name` | 类目名称列表[0] | 一级类目:`"电子产品"` |
+| `category2_name` | 类目名称列表[1] | 二级类目:`"手机"` |
+| `category3_name` | 类目名称列表[2] | 三级类目:`"iPhone"` |
+| `category_id` | SPU 表 | `category_id` 转为字符串 |
+| `category_level` | SPU 表 | `category_level` 转为整数 |
+
+**备用处理:**
+
+如果 `category_path` 为空,使用 `category` 字段作为备选:
+- 如果 `category` 包含 `/`,按 `/` 分割为多级类目
+- 否则,直接作为 `category1_name`
+
+**数据质量检查:**
+
+如果 `category_path` 中的类目ID在映射表中不存在,则不写入任何类目字段(视为没有类目)。
+
+---
+
+## 4. 多款式(SKU/Options)字段映射
+
+### 4.1 SKU 嵌套结构
+
+一个 SPU 可以有多个 SKU,每个 SKU 代表一个商品变体(如:红色-L码、蓝色-M码)。
+
+**ES 中的 `skus` 字段结构:**
+
+```json
+{
+ "skus": [
+ {
+ "sku_id": "123",
+ "price": 99.99,
+ "compare_at_price": 129.99,
+ "sku_code": "SKU001",
+ "stock": 100,
+ "weight": 0.5,
+ "weight_unit": "kg",
+ "option1_value": "红色",
+ "option2_value": "L",
+ "option3_value": "棉",
+ "image_src": "https://..."
+ }
+ ]
+}
+```
+
+### 4.2 SKU 字段映射
+
+| ES 字段 | 数据来源表 | 表中字段 | 转换说明 |
+|---------|-----------|----------|----------|
+| `skus[].sku_id` | SKU 表 | `id` | 转为字符串 |
+| `skus[].price` | SKU 表 | `price` | 转为浮点数 |
+| `skus[].compare_at_price` | SKU 表 | `compare_at_price` | 转为浮点数 |
+| `skus[].sku_code` | SKU 表 | `sku` | 转为字符串 |
+| `skus[].stock` | SKU 表 | `inventory_quantity` | 转为整数 |
+| `skus[].weight` | SKU 表 | `weight` | 转为浮点数 |
+| `skus[].weight_unit` | SKU 表 | `weight_unit` | 转为字符串 |
+| `skus[].image_src` | SKU 表 | `image_src` | 转为字符串 |
+| `skus[].option1_value` | SKU 表 | `option1` | 转为字符串 |
+| `skus[].option2_value` | SKU 表 | `option2` | 转为字符串 |
+| `skus[].option3_value` | SKU 表 | `option3` | 转为字符串 |
+
+### 4.3 选项名称字段
+
+选项名称来自 Option 表,按 `position` 字段排序(1、2、3 对应 option1、option2、option3)。
+
+| ES 字段 | 数据来源表 | 表中字段 | 转换说明 |
+|---------|-----------|----------|----------|
+| `option1_name` | Option 表 | `name` (where position=1) | 第一个选项的名称(如:"颜色") |
+| `option2_name` | Option 表 | `name` (where position=2) | 第二个选项的名称(如:"尺寸") |
+| `option3_name` | Option 表 | `name` (where position=3) | 第三个选项的名称(如:"材质") |
+
+**查询逻辑:**
+
+```sql
+SELECT position, name
+FROM shoplazza_product_option
+WHERE spu_id = ? AND deleted = 0
+ORDER BY position
+```
+
+### 4.4 选项值字段
+
+选项值来自所有 SKU 的 `option1`、`option2`、`option3` 字段,去重后形成列表。
+
+| ES 字段 | 数据来源表 | 表中字段 | 转换说明 |
+|---------|-----------|----------|----------|
+| `option1_values` | SKU 表 | `option1` | 收集所有 SKU 的 option1 值,去重
示例:`["红色", "蓝色", "绿色"]` |
+| `option2_values` | SKU 表 | `option2` | 收集所有 SKU 的 option2 值,去重
示例:`["S", "M", "L"]` |
+| `option3_values` | SKU 表 | `option3` | 收集所有 SKU 的 option3 值,去重
示例:`["棉", "涤纶"]` |
+
+**转换逻辑:**
+
+```
+遍历所有 SKU:
+ - 收集 option1 值 → option1_values 列表
+ - 收集 option2 值 → option2_values 列表
+ - 收集 option3 值 → option3_values 列表
+去重后写入 ES
+```
+
+**注意:** 只有配置在 `searchable_option_dimensions` 中的选项才会被索引。
+
+### 4.5 规格(Specifications)字段
+
+规格字段用于支持按规格过滤和分面搜索,将 SKU 的选项值结构化存储。
+
+**ES 中的 `specifications` 字段结构:**
+
+```json
+{
+ "specifications": [
+ {"sku_id": "123", "name": "颜色", "value": "红色"},
+ {"sku_id": "123", "name": "尺寸", "value": "L"},
+ {"sku_id": "124", "name": "颜色", "value": "蓝色"},
+ {"sku_id": "124", "name": "尺寸", "value": "M"}
+ ]
+}
+```
+
+**构建逻辑:**
+
+1. 从 Option 表获取选项名称映射:
+ ```
+ position=1 → name="颜色"
+ position=2 → name="尺寸"
+ position=3 → name="材质"
+ ```
+
+2. 遍历所有 SKU,为每个 SKU 的每个选项值构建规格记录:
+ ```
+ SKU.id=123, option1="红色", option2="L"
+ → {"sku_id": "123", "name": "颜色", "value": "红色"}
+ → {"sku_id": "123", "name": "尺寸", "value": "L"}
+ ```
+
+| ES 字段 | 数据来源 | 转换说明 |
+|---------|----------|----------|
+| `specifications[].sku_id` | SKU 表 | `id` 转为字符串 |
+| `specifications[].name` | Option 表 | `name`(根据 position 匹配) |
+| `specifications[].value` | SKU 表 | `option1/2/3` 转为字符串 |
+
+### 4.6 价格聚合字段
+
+从所有 SKU 的价格中计算聚合值。
+
+| ES 字段 | 数据来源 | 转换说明 |
+|---------|----------|----------|
+| `min_price` | SKU 表 | 所有 SKU 的 `price` 最小值 |
+| `max_price` | SKU 表 | 所有 SKU 的 `price` 最大值 |
+| `compare_at_price` | SKU 表 | 所有 SKU 的 `compare_at_price` 最大值 |
+| `sku_prices` | SKU 表 | 所有 SKU 的 `price` 数组
示例:`[99.99, 99.99, 129.99]` |
+
+**计算逻辑:**
+
+```
+遍历所有 SKU:
+ - 收集 price → prices 列表
+ - 收集 compare_at_price → compare_prices 列表
+
+min_price = min(prices)
+max_price = max(prices)
+compare_at_price = max(compare_prices)
+sku_prices = prices 列表
+```
+
+### 4.7 库存和重量聚合字段
+
+| ES 字段 | 数据来源 | 转换说明 |
+|---------|----------|----------|
+| `total_inventory` | SKU 表 | 所有 SKU 的 `inventory_quantity` 求和 |
+| `sku_weights` | SKU 表 | 所有 SKU 的 `weight` 数组
示例:`[500, 500, 600]` |
+| `sku_weight_units` | SKU 表 | 所有 SKU 的 `weight_unit` 去重后的列表
示例:`["kg"]` |
+
+**计算逻辑:**
+
+```
+遍历所有 SKU:
+ - 累加 inventory_quantity → total_inventory
+ - 收集 weight → sku_weights 列表
+ - 收集 weight_unit → sku_weight_units 列表
+
+total_inventory = 所有 SKU 库存之和
+sku_weights = 所有 SKU 重量数组
+sku_weight_units = 去重后的重量单位列表
+```
+
+---
+
+## 5. 向量字段映射
+
+### 5.1 标题向量(title_embedding)
+
+| ES 字段 | 数据来源 | 转换说明 |
+|---------|----------|----------|
+| `title_embedding` | SPU 表 `title` | 使用文本编码器(BGE)将标题转换为 1024 维向量
优先使用 `title_en`,如果没有则使用 `title_zh` |
+
+**生成逻辑:**
+
+```
+如果启用向量搜索:
+ 文本 = title_en 或 title_zh
+ 向量 = 文本编码器.encode(文本)
+ title_embedding = 向量(1024 维浮点数组)
+```
+
+---
+
+## 6. 完整字段映射表
+
+### 6.1 字段来源汇总
+
+| ES 字段 | 数据来源表 | 表中字段 | 说明 |
+|---------|-----------|----------|------|
+| **基础字段** |
+| `tenant_id` | SPU | `tenant_id` | 租户ID |
+| `spu_id` | SPU | `id` | 商品ID |
+| `title_zh/en` | SPU | `title` | 标题(多语言) |
+| `brief_zh/en` | SPU | `brief` | 简介(多语言) |
+| `description_zh/en` | SPU | `description` | 描述(多语言) |
+| `vendor_zh/en` | SPU | `vendor` | 品牌(多语言) |
+| `tags` | SPU | `tags` | 标签数组 |
+| `image_url` | SPU | `image_src` | 主图URL |
+| `sales` | SPU | `fake_sales` | 销量 |
+| `create_time` | SPU | `create_time` | 创建时间 |
+| `update_time` | SPU | `update_time` | 更新时间 |
+| **类别字段** |
+| `category_path_zh` | SPU + 类目映射 | `category_path` → 类目名称 | 类目路径 |
+| `category1_name` | SPU + 类目映射 | `category_path` → 类目名称[0] | 一级类目 |
+| `category2_name` | SPU + 类目映射 | `category_path` → 类目名称[1] | 二级类目 |
+| `category3_name` | SPU + 类目映射 | `category_path` → 类目名称[2] | 三级类目 |
+| `category_id` | SPU | `category_id` | 类目ID |
+| `category_level` | SPU | `category_level` | 类目层级 |
+| **SKU 嵌套字段** |
+| `skus[]` | SKU | 多个字段 | SKU 数组(见 4.2 节) |
+| **选项字段** |
+| `option1_name` | Option | `name` (position=1) | 选项1名称 |
+| `option2_name` | Option | `name` (position=2) | 选项2名称 |
+| `option3_name` | Option | `name` (position=3) | 选项3名称 |
+| `option1_values` | SKU | `option1` | 选项1值列表 |
+| `option2_values` | SKU | `option2` | 选项2值列表 |
+| `option3_values` | SKU | `option3` | 选项3值列表 |
+| **规格字段** |
+| `specifications[]` | SKU + Option | `option1/2/3` + `name` | 规格数组 |
+| **聚合字段** |
+| `min_price` | SKU | `price` | 最低价格 |
+| `max_price` | SKU | `price` | 最高价格 |
+| `compare_at_price` | SKU | `compare_at_price` | 最高原价 |
+| `sku_prices` | SKU | `price` | 所有价格数组 |
+| `total_inventory` | SKU | `inventory_quantity` | 总库存 |
+| `sku_weights` | SKU | `weight` | 所有重量数组 |
+| `sku_weight_units` | SKU | `weight_unit` | 重量单位列表 |
+| **向量字段** |
+| `title_embedding` | SPU | `title` | 标题向量(1024维) |
+
+---
+
+## 7. 数据查询示例
+
+### 7.1 查询 SPU 数据
+
+```sql
+SELECT
+ id, tenant_id, title, brief, description, vendor,
+ category, category_id, category_path, category_level,
+ tags, image_src, fake_sales, create_time, update_time
+FROM shoplazza_product_spu
+WHERE tenant_id = ? AND id = ? AND deleted = 0
+```
+
+### 7.2 查询 SKU 数据
+
+```sql
+SELECT
+ id, spu_id, price, compare_at_price, sku,
+ inventory_quantity, weight, weight_unit,
+ option1, option2, option3, image_src
+FROM shoplazza_product_sku
+WHERE tenant_id = ? AND spu_id = ? AND deleted = 0
+```
+
+### 7.3 查询 Option 数据
+
+```sql
+SELECT
+ position, name
+FROM shoplazza_product_option
+WHERE tenant_id = ? AND spu_id = ? AND deleted = 0
+ORDER BY position
+```
+
+### 7.4 查询类目映射
+
+```sql
+SELECT DISTINCT
+ category_id, category
+FROM shoplazza_product_spu
+WHERE deleted = 0 AND category_id IS NOT NULL
+```
+
+---
+
+## 8. ES 文档示例
+
+```json
+{
+ "tenant_id": "1",
+ "spu_id": "12345",
+ "title_zh": "iPhone 15 Pro Max",
+ "title_en": "iPhone 15 Pro Max",
+ "brief_zh": "最新款 iPhone",
+ "brief_en": "Latest iPhone",
+ "description_zh": "详细描述...",
+ "description_en": "Detailed description...",
+ "vendor_zh": "Apple",
+ "vendor_en": "Apple",
+ "tags": ["手机", "智能手机", "Apple"],
+ "category_path_zh": "电子产品/手机/iPhone",
+ "category1_name": "电子产品",
+ "category2_name": "手机",
+ "category3_name": "iPhone",
+ "category_id": "3",
+ "category_level": 3,
+ "option1_name": "颜色",
+ "option2_name": "存储容量",
+ "option1_values": ["深空黑色", "原色钛金属", "白色钛金属"],
+ "option2_values": ["256GB", "512GB", "1TB"],
+ "image_url": "https://example.com/image.jpg",
+ "sales": 1000,
+ "min_price": 8999.0,
+ "max_price": 12999.0,
+ "compare_at_price": 12999.0,
+ "sku_prices": [8999.0, 10999.0, 12999.0],
+ "sku_weights": [221, 221, 221],
+ "sku_weight_units": ["g"],
+ "total_inventory": 500,
+ "create_time": "2024-01-01T00:00:00",
+ "update_time": "2024-01-15T10:30:00",
+ "title_embedding": [0.123, 0.456, ...],
+ "skus": [
+ {
+ "sku_id": "1001",
+ "price": 8999.0,
+ "compare_at_price": 9999.0,
+ "sku_code": "IP15PM-256-BLK",
+ "stock": 100,
+ "weight": 221.0,
+ "weight_unit": "g",
+ "option1_value": "深空黑色",
+ "option2_value": "256GB",
+ "image_src": "https://example.com/sku1.jpg"
+ }
+ ],
+ "specifications": [
+ {"sku_id": "1001", "name": "颜色", "value": "深空黑色"},
+ {"sku_id": "1001", "name": "存储容量", "value": "256GB"}
+ ]
+}
+```
+
+---
+
+## 9. 关键映射关系总结
+
+### 9.1 类别映射
+
+```
+SPU.category_path ("1,2,3")
+ ↓ [解析ID列表]
+category_ids ["1", "2", "3"]
+ ↓ [通过映射表转换]
+category_names ["电子产品", "手机", "iPhone"]
+ ↓ [构建字段]
+category_path_zh: "电子产品/手机/iPhone"
+category1_name: "电子产品"
+category2_name: "手机"
+category3_name: "iPhone"
+```
+
+### 9.2 SKU 和选项映射
+
+```
+Option 表 (position=1, name="颜色")
+ ↓
+option1_name: "颜色"
+
+SKU 表 (option1="红色", option1="蓝色")
+ ↓ [收集所有值,去重]
+option1_values: ["红色", "蓝色"]
+
+SKU 表 + Option 表
+ ↓ [组合构建]
+specifications: [
+ {name: "颜色", value: "红色", sku_id: "123"},
+ {name: "颜色", value: "蓝色", sku_id: "124"}
+]
+```
+
+### 9.3 价格聚合
+
+```
+SKU 表 (price: 99.99, 109.99, 129.99)
+ ↓ [聚合计算]
+min_price: 99.99
+max_price: 129.99
+sku_prices: [99.99, 109.99, 129.99]
+```
+
+---
+
+## 附录:数据表字段对照
+
+### SPU 表主要字段
+
+| 表字段 | ES 字段 | 说明 |
+|--------|---------|------|
+| `id` | `spu_id` | 商品ID |
+| `tenant_id` | `tenant_id` | 租户ID |
+| `title` | `title_zh/en` | 标题 |
+| `brief` | `brief_zh/en` | 简介 |
+| `description` | `description_zh/en` | 描述 |
+| `vendor` | `vendor_zh/en` | 品牌 |
+| `tags` | `tags` | 标签 |
+| `category_path` | `category_path_zh` | 类目路径 |
+| `category_id` | `category_id` | 类目ID |
+| `category_level` | `category_level` | 类目层级 |
+| `image_src` | `image_url` | 主图 |
+| `fake_sales` | `sales` | 销量 |
+| `create_time` | `create_time` | 创建时间 |
+| `update_time` | `update_time` | 更新时间 |
+
+### SKU 表主要字段
+
+| 表字段 | ES 字段 | 说明 |
+|--------|---------|------|
+| `id` | `skus[].sku_id` | SKU ID |
+| `price` | `skus[].price`, `min_price`, `max_price`, `sku_prices` | 价格 |
+| `compare_at_price` | `skus[].compare_at_price`, `compare_at_price` | 原价 |
+| `sku` | `skus[].sku_code` | SKU编码 |
+| `inventory_quantity` | `skus[].stock`, `total_inventory` | 库存 |
+| `weight` | `skus[].weight`, `sku_weights` | 重量 |
+| `weight_unit` | `skus[].weight_unit`, `sku_weight_units` | 重量单位 |
+| `option1` | `skus[].option1_value`, `option1_values`, `specifications[].value` | 选项1值 |
+| `option2` | `skus[].option2_value`, `option2_values`, `specifications[].value` | 选项2值 |
+| `option3` | `skus[].option3_value`, `option3_values`, `specifications[].value` | 选项3值 |
+| `image_src` | `skus[].image_src` | SKU图片 |
+
+### Option 表主要字段
+
+| 表字段 | ES 字段 | 说明 |
+|--------|---------|------|
+| `position` | - | 位置(1/2/3) |
+| `name` | `option1_name`, `option2_name`, `option3_name`, `specifications[].name` | 选项名称 |
+
diff --git a/docs/MySQL到ES文档映射说明.md b/docs/MySQL到ES文档映射说明.md
new file mode 100644
index 0000000..1a40544
--- /dev/null
+++ b/docs/MySQL到ES文档映射说明.md
@@ -0,0 +1,961 @@
+# MySQL 到 Elasticsearch 文档映射说明
+
+## 1. 概述
+
+本文档详细说明从 MySQL 数据库到 Elasticsearch 的完整数据转换流程,包括 SQL 查询逻辑、字段映射规则、以及复杂字段(类别、多款式)的处理方法。
+
+### 1.1 数据流转架构
+
+```
+MySQL 数据库
+ ↓
+[SQL 查询] → SPU表、SKU表、Option表
+ ↓
+[数据加载] → Pandas DataFrame
+ ↓
+[文档转换] → ES 文档字典
+ ↓
+[批量写入] → Elasticsearch
+```
+
+### 1.2 核心组件
+
+- **IncrementalIndexerService**: 增量索引服务,负责数据获取和索引
+- **SPUDocumentTransformer**: 文档转换器,负责将数据库记录转换为 ES 文档
+- **BulkIndexer**: 批量写入器,负责将文档批量写入 ES
+
+---
+
+## 2. 数据源表结构
+
+### 2.1 SPU 表(shoplazza_product_spu)
+
+SPU(Standard Product Unit)表存储商品的基本信息。
+
+**主要字段:**
+
+| 字段名 | 类型 | 说明 |
+|--------|------|------|
+| `id` | BIGINT | SPU主键ID |
+| `tenant_id` | BIGINT | 租户ID |
+| `title` | VARCHAR(500) | 商品标题 |
+| `brief` | VARCHAR(1000) | 商品简介 |
+| `description` | TEXT | 商品详细描述 |
+| `vendor` | VARCHAR(255) | 供应商/品牌 |
+| `category` | VARCHAR(255) | 类目名称 |
+| `category_id` | BIGINT | 类目ID |
+| `category_path` | VARCHAR(500) | 类目路径(逗号分隔的ID列表) |
+| `category_level` | INT | 类目层级 |
+| `tags` | TEXT | 标签(逗号分隔) |
+| `image_src` | VARCHAR(500) | 主图URL |
+| `fake_sales` | INT | 假销量 |
+| `create_time` | DATETIME | 创建时间 |
+| `update_time` | DATETIME | 更新时间 |
+| `deleted` | BIT(1) | 删除标记 |
+
+### 2.2 SKU 表(shoplazza_product_sku)
+
+SKU(Stock Keeping Unit)表存储商品的变体信息,一个 SPU 可以有多个 SKU。
+
+**主要字段:**
+
+| 字段名 | 类型 | 说明 |
+|--------|------|------|
+| `id` | BIGINT | SKU主键ID |
+| `spu_id` | BIGINT | 关联的SPU ID |
+| `tenant_id` | BIGINT | 租户ID |
+| `sku` | VARCHAR(100) | SKU编码 |
+| `price` | DECIMAL(10,2) | 价格 |
+| `compare_at_price` | DECIMAL(10,2) | 对比价格(原价) |
+| `inventory_quantity` | INT | 库存数量 |
+| `weight` | DECIMAL(10,2) | 重量 |
+| `weight_unit` | VARCHAR(10) | 重量单位 |
+| `option1` | VARCHAR(255) | 选项1的值(如:颜色-红色) |
+| `option2` | VARCHAR(255) | 选项2的值(如:尺寸-L) |
+| `option3` | VARCHAR(255) | 选项3的值(如:材质-棉) |
+| `image_src` | VARCHAR(500) | SKU图片URL |
+| `deleted` | BIT(1) | 删除标记 |
+
+### 2.3 Option 表(shoplazza_product_option)
+
+Option 表存储选项的名称定义,用于定义 SKU 的选项维度(如:颜色、尺寸、材质)。
+
+**主要字段:**
+
+| 字段名 | 类型 | 说明 |
+|--------|------|------|
+| `id` | BIGINT | Option主键ID |
+| `spu_id` | BIGINT | 关联的SPU ID |
+| `tenant_id` | BIGINT | 租户ID |
+| `position` | INT | 位置序号(1、2、3对应option1、option2、option3) |
+| `name` | VARCHAR(255) | 选项名称(如:"颜色"、"尺寸") |
+| `values` | TEXT | 选项值列表(JSON格式) |
+| `deleted` | BIT(1) | 删除标记 |
+
+---
+
+## 3. SQL 查询逻辑
+
+### 3.1 批量查询 SPU 数据
+
+**方法:** `_load_spus_for_spu_ids(tenant_id, spu_ids, include_deleted=True)`
+
+**SQL 查询:**
+
+```sql
+SELECT
+ id, shop_id, shoplazza_id, title, brief, description,
+ spu, vendor, vendor_url,
+ image_src, image_width, image_height, image_path, image_alt,
+ tags, note, category, category_id, category_google_id,
+ category_level, category_path,
+ fake_sales, display_fake_sales,
+ tenant_id, creator, create_time, updater, update_time, deleted
+FROM shoplazza_product_spu
+WHERE tenant_id = :tenant_id
+ AND id IN :spu_ids
+ [AND deleted = 0] -- 如果 include_deleted=False
+```
+
+**查询特点:**
+- 使用 `IN` 子句批量查询多个 SPU
+- 支持查询已删除的记录(用于检测删除状态)
+- 使用 SQLAlchemy 的 `bindparam` 和 `expanding=True` 处理动态 IN 列表
+
+**代码位置:** `indexer/incremental_service.py:223-262`
+
+### 3.2 批量查询 SKU 数据
+
+**方法:** `_load_skus_for_spu_ids(tenant_id, spu_ids)`
+
+**SQL 查询:**
+
+```sql
+SELECT
+ id, spu_id, shop_id, shoplazza_id, shoplazza_product_id,
+ shoplazza_image_id, title, sku, barcode, position,
+ price, compare_at_price, cost_price,
+ option1, option2, option3,
+ inventory_quantity, weight, weight_unit, image_src,
+ wholesale_price, note, extend,
+ shoplazza_created_at, shoplazza_updated_at, tenant_id,
+ creator, create_time, updater, update_time, deleted
+FROM shoplazza_product_sku
+WHERE tenant_id = :tenant_id
+ AND deleted = 0
+ AND spu_id IN :spu_ids
+```
+
+**查询特点:**
+- 批量查询多个 SPU 的所有 SKU
+- 只查询未删除的 SKU(`deleted = 0`)
+- 结果按 `spu_id` 分组处理
+
+**代码位置:** `indexer/incremental_service.py:264-288`
+
+### 3.3 批量查询 Option 数据
+
+**方法:** `_load_options_for_spu_ids(tenant_id, spu_ids)`
+
+**SQL 查询:**
+
+```sql
+SELECT
+ id, spu_id, shop_id, shoplazza_id, shoplazza_product_id,
+ position, name, `values`, tenant_id,
+ creator, create_time, updater, update_time, deleted
+FROM shoplazza_product_option
+WHERE tenant_id = :tenant_id
+ AND deleted = 0
+ AND spu_id IN :spu_ids
+ORDER BY spu_id, position
+```
+
+**查询特点:**
+- 批量查询多个 SPU 的所有 Option
+- 按 `spu_id` 和 `position` 排序,确保选项顺序一致
+- `position` 字段对应 SKU 表中的 `option1`、`option2`、`option3`
+
+**代码位置:** `indexer/incremental_service.py:290-310`
+
+### 3.4 分类映射加载
+
+**方法:** `load_category_mapping(db_engine)`
+
+**SQL 查询:**
+
+```sql
+SELECT DISTINCT
+ category_id,
+ category
+FROM shoplazza_product_spu
+WHERE deleted = 0 AND category_id IS NOT NULL
+```
+
+**查询特点:**
+- 全局加载所有租户的分类映射(category_id → category_name)
+- 在服务初始化时预加载,避免重复查询
+- 用于将 `category_path` 中的 ID 转换为名称
+
+**代码位置:** `indexer/indexing_utils.py:17-51`
+
+---
+
+## 4. 字段映射详细说明
+
+### 4.1 基础字段映射
+
+#### 4.1.1 标识字段
+
+| ES 字段 | 数据来源 | 转换逻辑 |
+|---------|----------|----------|
+| `tenant_id` | `spu_row['tenant_id']` | 直接转换为字符串:`str(tenant_id)` |
+| `spu_id` | `spu_row['id']` | 直接转换为字符串:`str(spu_id)` |
+
+#### 4.1.2 文本字段(多语言处理)
+
+文本字段支持中英文双语,根据租户配置进行自动翻译。
+
+**字段列表:**
+- `title_zh` / `title_en`
+- `brief_zh` / `brief_en`
+- `description_zh` / `description_en`
+- `vendor_zh` / `vendor_en`
+
+**填充逻辑:**
+
+1. **获取租户主语言**:从 `tenant_config` 读取 `primary_language`(默认 'zh')
+
+2. **翻译处理**:
+ - 如果配置了 `translate_to_en=true` 且主语言不是 'en',则翻译到英文
+ - 如果配置了 `translate_to_zh=true` 且主语言不是 'zh',则翻译到中文
+ - 使用 `Translator.translate_for_indexing()` 方法进行翻译
+
+3. **字段填充**:
+ ```python
+ # 示例:title 字段
+ if pd.notna(spu_row.get('title')):
+ title_text = str(spu_row['title'])
+ if self.translator:
+ translations = self.translator.translate_for_indexing(
+ title_text,
+ shop_language=primary_lang,
+ source_lang=primary_lang,
+ translate_to_en=translate_to_en,
+ translate_to_zh=translate_to_zh,
+ )
+ doc['title_zh'] = translations.get('zh') or (title_text if primary_lang == 'zh' else None)
+ doc['title_en'] = translations.get('en') or (title_text if primary_lang == 'en' else None)
+ ```
+
+**代码位置:** `indexer/document_transformer.py:168-298`
+
+#### 4.1.3 标签字段
+
+| ES 字段 | 数据来源 | 转换逻辑 |
+|---------|----------|----------|
+| `tags` | `spu_row['tags']` | 逗号分隔字符串 → 列表:`[tag.strip() for tag in tags_str.split(',') if tag.strip()]` |
+
+**示例:**
+- 数据库:`"标签1,标签2,标签3"`
+- ES:`["标签1", "标签2", "标签3"]`
+
+#### 4.1.4 图片字段
+
+| ES 字段 | 数据来源 | 转换逻辑 |
+|---------|----------|----------|
+| `image_url` | `spu_row['image_src']` | 如果 URL 不以 `http` 开头,添加 `//` 前缀 |
+
+**代码位置:** `indexer/document_transformer.py:390-396`
+
+#### 4.1.5 销量字段
+
+| ES 字段 | 数据来源 | 转换逻辑 |
+|---------|----------|----------|
+| `sales` | `spu_row['fake_sales']` | 转换为整数,如果为空或转换失败则设为 0 |
+
+#### 4.1.6 时间字段
+
+| ES 字段 | 数据来源 | 转换逻辑 |
+|---------|----------|----------|
+| `create_time` | `spu_row['create_time']` | 转换为 ISO 格式字符串:`datetime.isoformat()` |
+| `update_time` | `spu_row['update_time']` | 转换为 ISO 格式字符串:`datetime.isoformat()` |
+
+---
+
+### 4.2 类别(Category)字段映射
+
+类别字段是复杂字段,涉及多级分类的解析和映射。
+
+#### 4.2.1 类别数据来源
+
+类别信息主要来自 SPU 表的以下字段:
+- `category_path`: 类目路径,逗号分隔的类目ID列表(如:`"1,2,3"`)
+- `category`: 类目名称(备用字段)
+- `category_id`: 当前类目ID
+- `category_level`: 类目层级
+
+#### 4.2.2 类别映射流程
+
+**步骤 1:加载分类映射**
+
+在服务初始化时,从数据库加载全局分类映射:
+
+```python
+category_id_to_name = load_category_mapping(db_engine)
+# 结果:{"1": "电子产品", "2": "手机", "3": "iPhone"}
+```
+
+**步骤 2:解析 category_path**
+
+```python
+if pd.notna(spu_row.get('category_path')):
+ category_path = str(spu_row['category_path']) # 例如:"1,2,3"
+ category_ids = [cid.strip() for cid in category_path.split(',') if cid.strip()]
+ # 结果:["1", "2", "3"]
+```
+
+**步骤 3:ID 转名称**
+
+```python
+category_names = []
+missing_ids = []
+for cid in category_ids:
+ if cid in self.category_id_to_name:
+ category_names.append(self.category_id_to_name[cid])
+ else:
+ missing_ids.append(cid)
+```
+
+**步骤 4:数据质量检查**
+
+如果存在缺失的类目ID(在映射中找不到),则:
+- 记录错误日志
+- **不写入任何类目字段**(视为没有类目)
+- 不阻塞索引流程
+
+**步骤 5:填充 ES 字段**
+
+```python
+if category_names:
+ # 构建类目路径字符串
+ category_path_str = '/'.join(category_names)
+ doc['category_path_zh'] = category_path_str # 例如:"电子产品/手机/iPhone"
+
+ # 填充分层类目名称
+ if len(category_names) > 0:
+ doc['category1_name'] = category_names[0] # "电子产品"
+ if len(category_names) > 1:
+ doc['category2_name'] = category_names[1] # "手机"
+ if len(category_names) > 2:
+ doc['category3_name'] = category_names[2] # "iPhone"
+```
+
+**步骤 6:备用处理(category_path 为空时)**
+
+如果 `category_path` 为空,使用 `category` 字段作为备选:
+
+```python
+elif pd.notna(spu_row.get('category')):
+ category = str(spu_row['category'])
+ doc['category_name_zh'] = category
+
+ # 尝试从 category 字段解析多级分类(如果包含 "/")
+ if '/' in category:
+ path_parts = category.split('/')
+ if len(path_parts) > 0:
+ doc['category1_name'] = path_parts[0].strip()
+ if len(path_parts) > 1:
+ doc['category2_name'] = path_parts[1].strip()
+ if len(path_parts) > 2:
+ doc['category3_name'] = path_parts[2].strip()
+```
+
+#### 4.2.3 类别相关 ES 字段
+
+| ES 字段 | 类型 | 说明 |
+|---------|------|------|
+| `category_path_zh` | text | 类目路径字符串(如:"电子产品/手机/iPhone") |
+| `category_path_en` | text | 类目路径英文(暂未实现) |
+| `category1_name` | keyword | 一级类目名称 |
+| `category2_name` | keyword | 二级类目名称 |
+| `category3_name` | keyword | 三级类目名称 |
+| `category_id` | keyword | 当前类目ID |
+| `category_level` | integer | 类目层级 |
+
+**代码位置:** `indexer/document_transformer.py:300-376`
+
+---
+
+### 4.3 多款式(SKU/Options)字段映射
+
+多款式字段是最复杂的部分,涉及 SKU 嵌套结构、选项值提取、规格构建等。
+
+#### 4.3.1 SKU 数据结构
+
+一个 SPU 可以有多个 SKU,每个 SKU 代表一个商品变体(如:红色-L码、蓝色-M码)。
+
+**ES 中的 SKU 嵌套结构:**
+
+```json
+{
+ "skus": [
+ {
+ "sku_id": "123",
+ "price": 99.99,
+ "compare_at_price": 129.99,
+ "sku_code": "SKU001",
+ "stock": 100,
+ "weight": 0.5,
+ "weight_unit": "kg",
+ "option1_value": "红色",
+ "option2_value": "L",
+ "option3_value": "棉",
+ "image_src": "https://..."
+ },
+ {
+ "sku_id": "124",
+ "price": 99.99,
+ "compare_at_price": 129.99,
+ "sku_code": "SKU002",
+ "stock": 50,
+ "weight": 0.5,
+ "weight_unit": "kg",
+ "option1_value": "蓝色",
+ "option2_value": "M",
+ "option3_value": "棉",
+ "image_src": "https://..."
+ }
+ ]
+}
+```
+
+#### 4.3.2 SKU 处理流程
+
+**步骤 1:构建 Option 名称映射**
+
+从 Option 表获取选项名称:
+
+```python
+option_name_map = {}
+if not options.empty:
+ for _, opt_row in options.iterrows():
+ position = opt_row.get('position') # 1, 2, 3
+ name = opt_row.get('name') # "颜色", "尺寸", "材质"
+ if pd.notna(position) and pd.notna(name):
+ option_name_map[int(position)] = str(name)
+# 结果:{1: "颜色", 2: "尺寸", 3: "材质"}
+```
+
+**步骤 2:转换每个 SKU**
+
+```python
+for _, sku_row in skus.iterrows():
+ sku_data = self._transform_sku_row(sku_row, option_name_map)
+ if sku_data:
+ skus_list.append(sku_data)
+```
+
+**SKU 转换逻辑:**
+
+```python
+def _transform_sku_row(self, sku_row, option_name_map):
+ sku_data = {
+ 'sku_id': str(sku_row['id']),
+ 'price': float(sku_row['price']) if pd.notna(sku_row.get('price')) else None,
+ 'compare_at_price': float(sku_row['compare_at_price']) if pd.notna(sku_row.get('compare_at_price')) else None,
+ 'sku_code': str(sku_row['sku']) if pd.notna(sku_row.get('sku')) else None,
+ 'stock': int(sku_row['inventory_quantity']) if pd.notna(sku_row.get('inventory_quantity')) else 0,
+ 'weight': float(sku_row['weight']) if pd.notna(sku_row.get('weight')) else None,
+ 'weight_unit': str(sku_row['weight_unit']) if pd.notna(sku_row.get('weight_unit')) else None,
+ 'image_src': str(sku_row['image_src']) if pd.notna(sku_row.get('image_src')) else None,
+ }
+
+ # 填充选项值
+ if pd.notna(sku_row.get('option1')):
+ sku_data['option1_value'] = str(sku_row['option1'])
+ if pd.notna(sku_row.get('option2')):
+ sku_data['option2_value'] = str(sku_row['option2'])
+ if pd.notna(sku_row.get('option3')):
+ sku_data['option3_value'] = str(sku_row['option3'])
+
+ return sku_data
+```
+
+**步骤 3:收集聚合信息**
+
+在处理 SKU 的同时,收集价格、重量、库存等聚合信息:
+
+```python
+prices = []
+compare_prices = []
+sku_prices = []
+sku_weights = []
+sku_weight_units = []
+total_inventory = 0
+
+for sku_data in skus_list:
+ if 'price' in sku_data and sku_data['price'] is not None:
+ prices.append(sku_data['price'])
+ sku_prices.append(sku_data['price'])
+
+ if 'compare_at_price' in sku_data and sku_data['compare_at_price'] is not None:
+ compare_prices.append(sku_data['compare_at_price'])
+
+ if 'weight' in sku_data and sku_data['weight'] is not None:
+ sku_weights.append(int(float(sku_data['weight'])))
+
+ if 'weight_unit' in sku_data and sku_data['weight_unit']:
+ sku_weight_units.append(sku_data['weight_unit'])
+
+ if 'stock' in sku_data and sku_data['stock'] is not None:
+ total_inventory += sku_data['stock']
+```
+
+**步骤 4:填充 ES 字段**
+
+```python
+doc['skus'] = skus_list # 嵌套 SKU 数组
+
+# 价格范围
+if prices:
+ doc['min_price'] = float(min(prices))
+ doc['max_price'] = float(max(prices))
+else:
+ doc['min_price'] = 0.0
+ doc['max_price'] = 0.0
+
+if compare_prices:
+ doc['compare_at_price'] = float(max(compare_prices))
+
+# SKU 扁平化字段(用于过滤和聚合)
+doc['sku_prices'] = sku_prices # [99.99, 99.99, 129.99]
+doc['sku_weights'] = sku_weights # [500, 500, 600]
+doc['sku_weight_units'] = list(set(sku_weight_units)) # ["kg"]
+doc['total_inventory'] = total_inventory # 150
+```
+
+**代码位置:** `indexer/document_transformer.py:398-480`
+
+#### 4.3.3 选项名称字段
+
+从 Option 表获取选项名称,填充到 ES 文档的顶层:
+
+```python
+def _fill_option_names(self, doc, options):
+ if not options.empty:
+ sorted_options = options.sort_values('position')
+ if len(sorted_options) > 0 and pd.notna(sorted_options.iloc[0].get('name')):
+ doc['option1_name'] = str(sorted_options.iloc[0]['name']) # "颜色"
+ if len(sorted_options) > 1 and pd.notna(sorted_options.iloc[1].get('name')):
+ doc['option2_name'] = str(sorted_options.iloc[1]['name']) # "尺寸"
+ if len(sorted_options) > 2 and pd.notna(sorted_options.iloc[2].get('name')):
+ doc['option3_name'] = str(sorted_options.iloc[2]['name']) # "材质"
+```
+
+**ES 字段:**
+- `option1_name`: 选项1的名称(如:"颜色")
+- `option2_name`: 选项2的名称(如:"尺寸")
+- `option3_name`: 选项3的名称(如:"材质")
+
+**代码位置:** `indexer/document_transformer.py:378-388`
+
+#### 4.3.4 选项值字段
+
+从所有 SKU 中提取选项值,去重后填充到 ES 文档:
+
+```python
+def _fill_option_values(self, doc, skus):
+ option1_values = []
+ option2_values = []
+ option3_values = []
+
+ for _, sku_row in skus.iterrows():
+ if pd.notna(sku_row.get('option1')):
+ option1_values.append(str(sku_row['option1']))
+ if pd.notna(sku_row.get('option2')):
+ option2_values.append(str(sku_row['option2']))
+ if pd.notna(sku_row.get('option3')):
+ option3_values.append(str(sku_row['option3']))
+
+ # 根据配置决定是否写入索引(searchable_option_dimensions)
+ if 'option1' in self.searchable_option_dimensions:
+ doc['option1_values'] = list(set(option1_values)) # ["红色", "蓝色", "绿色"]
+ else:
+ doc['option1_values'] = []
+
+ # 同样处理 option2_values 和 option3_values
+```
+
+**ES 字段:**
+- `option1_values`: 选项1的所有值列表(如:`["红色", "蓝色", "绿色"]`)
+- `option2_values`: 选项2的所有值列表(如:`["S", "M", "L"]`)
+- `option3_values`: 选项3的所有值列表(如:`["棉", "涤纶"]`)
+
+**注意:** 只有配置在 `searchable_option_dimensions` 中的选项才会被索引。
+
+**代码位置:** `indexer/document_transformer.py:482-510`
+
+#### 4.3.5 规格(Specifications)字段
+
+规格字段用于支持按规格过滤和分面搜索,将 SKU 的选项值结构化存储。
+
+**构建逻辑:**
+
+```python
+specifications = []
+
+for _, sku_row in skus.iterrows():
+ sku_id = str(sku_row['id'])
+
+ # 如果 SKU 有 option1 且 Option 表中有对应的名称
+ if pd.notna(sku_row.get('option1')) and 1 in option_name_map:
+ specifications.append({
+ 'sku_id': sku_id,
+ 'name': option_name_map[1], # "颜色"
+ 'value': str(sku_row['option1']) # "红色"
+ })
+
+ # 同样处理 option2 和 option3
+ if pd.notna(sku_row.get('option2')) and 2 in option_name_map:
+ specifications.append({
+ 'sku_id': sku_id,
+ 'name': option_name_map[2], # "尺寸"
+ 'value': str(sku_row['option2']) # "L"
+ })
+
+ if pd.notna(sku_row.get('option3')) and 3 in option_name_map:
+ specifications.append({
+ 'sku_id': sku_id,
+ 'name': option_name_map[3], # "材质"
+ 'value': str(sku_row['option3']) # "棉"
+ })
+
+doc['specifications'] = specifications
+```
+
+**ES 字段结构:**
+
+```json
+{
+ "specifications": [
+ {"sku_id": "123", "name": "颜色", "value": "红色"},
+ {"sku_id": "123", "name": "尺寸", "value": "L"},
+ {"sku_id": "123", "name": "材质", "value": "棉"},
+ {"sku_id": "124", "name": "颜色", "value": "蓝色"},
+ {"sku_id": "124", "name": "尺寸", "value": "M"},
+ {"sku_id": "124", "name": "材质", "value": "棉"}
+ ]
+}
+```
+
+**用途:**
+- 支持按规格过滤:`{"specifications": {"name": "颜色", "value": "红色"}}`
+- 支持规格分面:统计每个规格值的数量
+
+**代码位置:** `indexer/document_transformer.py:459-478`
+
+---
+
+### 4.4 向量字段映射
+
+#### 4.4.1 标题向量(title_embedding)
+
+**生成时机:**
+- 在增量索引中,批量生成 embedding(性能优化)
+- 在单条查询中,按需生成
+
+**生成逻辑:**
+
+```python
+# 批量生成(增量索引)
+if enable_embedding and encoder and documents:
+ title_texts = []
+ title_doc_indices = []
+ for i, (_, doc) in enumerate(documents):
+ title_text = doc.get("title_en") or doc.get("title_zh")
+ if title_text and str(title_text).strip():
+ title_texts.append(str(title_text))
+ title_doc_indices.append(i)
+
+ if title_texts:
+ embeddings = encoder.encode_batch(title_texts, batch_size=32)
+ for j, emb in enumerate(embeddings):
+ doc_idx = title_doc_indices[j]
+ if isinstance(emb, np.ndarray):
+ documents[doc_idx][1]["title_embedding"] = emb.tolist()
+```
+
+**ES 字段:**
+- `title_embedding`: 1024 维浮点数组,用于语义搜索
+
+**代码位置:**
+- 批量生成:`indexer/incremental_service.py:558-576`
+- 单条生成:`indexer/document_transformer.py:586-619`
+
+---
+
+## 5. 数据转换完整流程
+
+### 5.1 增量索引流程
+
+**入口方法:** `IncrementalIndexerService.index_spus_to_es()`
+
+**流程步骤:**
+
+1. **参数处理**
+ - 去重 SPU ID 列表
+ - 生成索引名称(如果未提供)
+
+2. **显式删除处理**(如果提供了 `delete_spu_ids`)
+ - 遍历 `delete_spu_ids`,从 ES 中删除对应文档
+
+3. **批量加载数据**
+ - 调用 `_load_spus_for_spu_ids()` 批量加载 SPU 数据
+ - 调用 `_load_skus_for_spu_ids()` 批量加载 SKU 数据
+ - 调用 `_load_options_for_spu_ids()` 批量加载 Option 数据
+
+4. **自动删除检测**
+ - 检查 SPU 是否在数据库中存在
+ - 检查 SPU 的 `deleted` 字段
+ - 如果不存在或已删除,从 ES 中删除对应文档
+
+5. **数据分组**
+ - 将 SKU 按 `spu_id` 分组
+ - 将 Option 按 `spu_id` 分组
+
+6. **文档转换**
+ - 遍历每个 SPU
+ - 调用 `transformer.transform_spu_to_doc()` 转换为 ES 文档
+ - 收集所有文档
+
+7. **批量生成 Embedding**(如果启用)
+ - 收集所有文档的标题文本
+ - 批量调用 `encoder.encode_batch()` 生成 embedding
+ - 填充到对应文档
+
+8. **批量写入 ES**
+ - 使用 `BulkIndexer` 批量写入文档
+ - 使用 `spu_id` 作为文档 ID
+
+9. **返回结果**
+ - 返回成功/失败统计
+ - 返回每个 SPU 的处理状态
+
+**代码位置:** `indexer/incremental_service.py:391-679`
+
+### 5.2 文档转换流程
+
+**入口方法:** `SPUDocumentTransformer.transform_spu_to_doc()`
+
+**流程步骤:**
+
+1. **初始化文档字典**
+ ```python
+ doc = {}
+ doc['tenant_id'] = str(tenant_id)
+ doc['spu_id'] = str(spu_id)
+ ```
+
+2. **填充文本字段**(多语言)
+ - 调用 `_fill_text_fields()` 填充 title、brief、description、vendor
+
+3. **填充标签**
+ - 解析 `tags` 字段,转换为列表
+
+4. **填充类别字段**
+ - 调用 `_fill_category_fields()` 处理类别映射
+
+5. **填充选项名称**
+ - 调用 `_fill_option_names()` 从 Option 表获取选项名称
+
+6. **填充图片 URL**
+ - 调用 `_fill_image_url()` 处理图片 URL
+
+7. **填充销量**
+ - 转换 `fake_sales` 为整数
+
+8. **处理 SKU**
+ - 调用 `_process_skus()` 处理所有 SKU
+ - 构建 `skus` 嵌套数组
+ - 构建 `specifications` 数组
+ - 计算价格范围、总库存等聚合字段
+
+9. **填充选项值**
+ - 调用 `_fill_option_values()` 提取所有选项值
+
+10. **填充时间字段**
+ - 转换 `create_time` 和 `update_time` 为 ISO 格式
+
+11. **返回文档**
+ ```python
+ return doc
+ ```
+
+**代码位置:** `indexer/document_transformer.py:57-166`
+
+---
+
+## 6. 特殊处理逻辑
+
+### 6.1 删除检测
+
+系统支持两种删除方式:
+
+1. **显式删除**:通过 `delete_spu_ids` 参数显式指定要删除的 SPU
+2. **自动检测删除**:
+ - SPU 在数据库中不存在
+ - SPU 的 `deleted` 字段为 `1` 或 `b'\x01'`
+
+**删除处理:**
+
+```python
+# 检查 deleted 字段(可能是 bit 类型)
+def _is_deleted_value(v):
+ if isinstance(v, bytes):
+ return v == b"\x01" or v == 1
+ return bool(v)
+
+spu_df["_is_deleted"] = spu_df["deleted"].apply(_is_deleted_value)
+```
+
+**代码位置:** `indexer/incremental_service.py:481-517`
+
+### 6.2 数据质量检查
+
+#### 6.2.1 类别映射缺失
+
+如果 `category_path` 中的类目ID在映射中不存在:
+- 记录错误日志
+- 不写入任何类目字段(视为没有类目)
+- 不阻塞索引流程
+
+#### 6.2.2 标题缺失
+
+如果 SPU 没有标题:
+- 记录错误日志
+- 继续处理(但可能影响搜索效果)
+
+#### 6.2.3 SKU 缺失
+
+如果 SPU 没有 SKU:
+- 记录警告日志
+- 继续处理(但价格、库存等字段可能为空)
+
+### 6.3 性能优化
+
+1. **批量查询**:使用 `IN` 子句批量查询,减少数据库往返
+2. **缓存分类映射**:在服务初始化时预加载,避免重复查询
+3. **缓存 Transformer**:按租户缓存 Transformer 实例,避免重复初始化
+4. **批量生成 Embedding**:收集所有标题后批量生成,利用批处理性能
+5. **批量写入 ES**:使用 `BulkIndexer` 批量写入,提高写入效率
+
+---
+
+## 7. ES 文档完整示例
+
+```json
+{
+ "tenant_id": "1",
+ "spu_id": "12345",
+ "title_zh": "iPhone 15 Pro Max",
+ "title_en": "iPhone 15 Pro Max",
+ "brief_zh": "最新款 iPhone",
+ "brief_en": "Latest iPhone",
+ "description_zh": "详细描述...",
+ "description_en": "Detailed description...",
+ "vendor_zh": "Apple",
+ "vendor_en": "Apple",
+ "tags": ["手机", "智能手机", "Apple"],
+ "category_path_zh": "电子产品/手机/iPhone",
+ "category1_name": "电子产品",
+ "category2_name": "手机",
+ "category3_name": "iPhone",
+ "category_id": "3",
+ "category_level": 3,
+ "option1_name": "颜色",
+ "option2_name": "存储容量",
+ "option3_name": null,
+ "option1_values": ["深空黑色", "原色钛金属", "白色钛金属"],
+ "option2_values": ["256GB", "512GB", "1TB"],
+ "option3_values": [],
+ "image_url": "https://example.com/image.jpg",
+ "sales": 1000,
+ "min_price": 8999.0,
+ "max_price": 12999.0,
+ "compare_at_price": 12999.0,
+ "sku_prices": [8999.0, 10999.0, 12999.0],
+ "sku_weights": [221, 221, 221],
+ "sku_weight_units": ["g"],
+ "total_inventory": 500,
+ "create_time": "2024-01-01T00:00:00",
+ "update_time": "2024-01-15T10:30:00",
+ "title_embedding": [0.123, 0.456, ...], // 1024维向量
+ "skus": [
+ {
+ "sku_id": "1001",
+ "price": 8999.0,
+ "compare_at_price": 9999.0,
+ "sku_code": "IP15PM-256-BLK",
+ "stock": 100,
+ "weight": 221.0,
+ "weight_unit": "g",
+ "option1_value": "深空黑色",
+ "option2_value": "256GB",
+ "option3_value": null,
+ "image_src": "https://example.com/sku1.jpg"
+ },
+ {
+ "sku_id": "1002",
+ "price": 10999.0,
+ "compare_at_price": 11999.0,
+ "sku_code": "IP15PM-512-BLK",
+ "stock": 200,
+ "weight": 221.0,
+ "weight_unit": "g",
+ "option1_value": "深空黑色",
+ "option2_value": "512GB",
+ "option3_value": null,
+ "image_src": "https://example.com/sku2.jpg"
+ }
+ ],
+ "specifications": [
+ {"sku_id": "1001", "name": "颜色", "value": "深空黑色"},
+ {"sku_id": "1001", "name": "存储容量", "value": "256GB"},
+ {"sku_id": "1002", "name": "颜色", "value": "深空黑色"},
+ {"sku_id": "1002", "name": "存储容量", "value": "512GB"}
+ ]
+}
+```
+
+---
+
+## 8. 总结
+
+### 8.1 关键要点
+
+1. **数据源**:三个主要表(SPU、SKU、Option)
+2. **批量查询**:使用 `IN` 子句提高查询效率
+3. **类别映射**:通过预加载的映射表将 ID 转换为名称
+4. **多款式处理**:通过嵌套结构和扁平化字段支持复杂的 SKU 查询
+5. **多语言支持**:自动翻译文本字段,支持中英文双语
+6. **向量搜索**:批量生成 embedding,支持语义搜索
+
+### 8.2 复杂字段处理总结
+
+| 字段类型 | 处理方式 | 关键逻辑 |
+|---------|---------|---------|
+| **类别** | ID映射 + 路径解析 | 从 `category_path` 解析ID,通过映射表转换为名称,构建多级分类 |
+| **SKU** | 嵌套数组 + 聚合计算 | 将每个 SKU 转换为嵌套对象,同时计算价格范围、总库存等聚合值 |
+| **选项** | 名称 + 值分离 | 从 Option 表获取名称,从 SKU 表提取值,分别存储 |
+| **规格** | 结构化数组 | 将 SKU 的选项值转换为 `{name, value, sku_id}` 结构,支持过滤和分面 |
+
+---
+
+## 附录:相关代码文件
+
+- `indexer/incremental_service.py`: 增量索引服务,数据获取逻辑
+- `indexer/document_transformer.py`: 文档转换器,字段填充逻辑
+- `indexer/indexing_utils.py`: 工具函数,分类映射加载和 Transformer 创建
+- `indexer/bulk_indexer.py`: 批量写入器,ES 写入逻辑
+
diff --git a/docs/blog/img_seo_analysis.md b/docs/blog/img_seo_analysis.md
new file mode 100644
index 0000000..71a32c3
--- /dev/null
+++ b/docs/blog/img_seo_analysis.md
@@ -0,0 +1,34 @@
+# IMG SEO 插件分析
+
+## 1. 解决的具体问题
+- **图片搜索排名低**:通过优化 Alt 标签,让商品图片在 Google 图片搜索中获得更高排名。
+- **运营效率低下**:手动为大量商品图片设置 Alt 标签非常耗时,该工具提供批量处理能力。
+- **网站可访问性(Accessibility)**:为视障人士或图片加载失败时提供文字描述,符合 Web 标准。
+- **SEO 规范缺失**:商家不知道如何编写符合 SEO 最佳实践的 Alt 文本,工具提供内置推荐配置。
+
+## 2. 技术方案/功能点
+- **智能 Alt 标签模板**:支持基于产品标题、集合名称等变量自动生成优化描述。
+- **三大场景覆盖**:一键覆盖商品图、专辑封面、博客配图。
+- **批量处理引擎**:支持全站图片的一键批量优化。
+- **内置最佳实践**:提供一键采用的推荐配置,确保符合搜索引擎规范。
+- **无障碍支持**:生成的 Alt 文本提升了页面的无障碍访问体验。
+
+## 3. 市场需求
+- 视觉驱动型电商:如服装、家居等行业,图片搜索是重要的流量来源。
+- 自动化运维:减少重复性劳动,确保新上传的图片自动符合 SEO 要求。
+- 合规性需求:提升网站的无障碍访问水平。
+
+## 4. 详细用法与案例
+### 用法步骤:
+1. **安装**:在应用商店安装“图片 SEO”插件。
+2. **选择场景**:选择要优化的场景(商品、专辑或博客)。
+3. **设置模板**:使用变量(如 `{product_title}`)或推荐配置设置 Alt 规则。
+4. **执行批量优化**:点击一键优化,系统将自动处理存量图片。
+
+### 使用案例:
+**案例:家居装饰独立站的图片流量获取**
+- **背景**:某家居站拥有大量精美的产品实拍图,但由于没有 Alt 标签,这些图片无法在 Google 图片搜索中被搜到。
+- **配置方案**:
+ - **模板设置**:`{product_title} - Modern Home Decor | {shop_name}`。
+ - **操作**:对全站 500 个商品的 3000 张图片执行了一键批量优化。
+- **效果**:一个月后,来自 Google Images 的自然流量增长了 45%,且部分长尾关键词(如“modern minimalist vase”)的图片搜索结果排到了前三名。
diff --git a/docs/blog/seo_optimizer_analysis.md b/docs/blog/seo_optimizer_analysis.md
new file mode 100644
index 0000000..b3b9a07
--- /dev/null
+++ b/docs/blog/seo_optimizer_analysis.md
@@ -0,0 +1,38 @@
+# SEO Optimizer 插件分析
+
+## 1. 解决的具体问题
+- **SEO 诊断与评估**:帮助商家识别店铺中存在的 SEO 缺陷。
+- **全站 Meta 标签覆盖**:解决手动为成百上千个商品、专辑、博客设置 Meta 标签的低效问题。
+- **图片搜索流量缺失**:通过自动/批量设置图片 Alt 标签,提升图片在 Google Images 的排名。
+- **死链影响权重**:修复 404 无效链接,通过重定向保留页面权重。
+- **搜索引擎收录慢**:自动生成并提交 Sitemap,加速 Google 索引。
+- **高级 SEO 控制**:提供 Robots.txt 管理,满足个性化爬取需求。
+
+## 2. 技术方案/功能点
+- **自动化 Meta 标签**:支持首页、商品、专辑、博客等页面的批量 Meta 设置,支持变量引用。
+- **图片 SEO 规则引擎**:基于自定义规则(如商品名+关键词)自动为新图片生成 Alt 文本。
+- **URL 重定向工具**:支持路径级和完整 URL 级的 301/302 重定向设置。
+- **Sitemap 自动更新**:每日凌晨自动更新站点地图,并提供与 Google Search Console 的集成接口。
+- **JSON-LD 结构化数据**:自动添加 Google Snippets(面包屑、商品信息、网站链接),提升搜索结果展示效果。
+- **Robots.txt 编辑器**:支持预览、编辑和一键屏蔽爬虫功能。
+
+## 3. 市场需求
+- 规模化运营需求:商品量大的商家需要自动化工具。
+- 技术进阶需求:需要 Robots.txt 和结构化数据等高级 SEO 手段。
+- 流量监控需求:集成数据看板,直观查看自然流量转化。
+
+## 4. 详细用法与案例
+### 用法步骤:
+1. **Meta 标签设置**:在插件后台点击“Meta标签”,切换不同页面类型,输入模板并保存。
+2. **图片 Alt 设置**:在“图片SEO”模块设置生成规则,开启自动应用。
+3. **死链修复**:在“无效链接”模块输入旧路径和新目标 URL。
+4. **Sitemap 提交**:复制自动生成的 Sitemap 地址,前往 Google Search Console 提交。
+
+### 使用案例:
+**案例:大型 3C 电子产品站的自动化 SEO 维护**
+- **背景**:某站有 2000+ SKU,手动设置 SEO 极其耗时,且经常有下架商品导致死链。
+- **配置方案**:
+ - **Meta 模版**:商品页 Meta 标题设为 `{product_title} - Buy Online at {shop_name}`。
+ - **图片 Alt**:规则设为 `{product_title} - {shop_name} Electronics`。
+ - **死链处理**:将所有已下架的旧款手机页面重定向到对应的最新款分类页。
+- **效果**:全站 SEO 覆盖率从 10% 提升到 100%,图片搜索带来的流量增长了 30%,且避免了因死链导致的 Google 惩罚。
diff --git a/docs/blog/smart_search_analysis.md b/docs/blog/smart_search_analysis.md
new file mode 100644
index 0000000..4b30a4a
--- /dev/null
+++ b/docs/blog/smart_search_analysis.md
@@ -0,0 +1,41 @@
+# 智能商品搜索插件分析
+
+## 1. 解决的具体问题
+- **搜索效率低下**:帮助顾客快速定位目标商品。
+- **搜索结果不精准**:通过自定义排序规则和筛选条件优化结果。
+- **缺乏引导**:通过搜索热词和历史记录引导用户搜索。
+- **转化率提升**:智能关联推荐与搜索词相关的商品,增加曝光和转化。
+
+## 2. 技术方案/功能点
+- **多维度筛选器配置**:
+ - **自动拉取数据**:厂商、价格、库存。
+ - **手动配置/选择**:款式(Size/Color等)、标签、元字段(外形、尺寸、体积、重量、单行文本)。
+- **自定义排序条件**:
+ - 默认首位:Recommend(推荐)。
+ - 可选维度:最新上架、价格高低、销量高低、加购最多、浏览最多、名称 A-Z/Z-A。
+- **搜索热词与引导**:
+ - 手动设置特定搜索热词。
+ - 自动推荐高频搜索词。
+ - 展示用户历史搜索记录。
+- **主题深度集成**:仅支持 Nova2023 系列(Night, Sweet, Morning, Bamboo, Moon)3.3.0 版本以上。
+
+## 4. 详细用法与案例
+### 用法步骤:
+1. **安装**:通过应用商店或主题装修页面快捷安装。
+2. **配置筛选器**:点击“添加筛选维度”,选择类型(如标签或元字段),并设置标题。
+3. **配置排序**:在插件后台调整排序维度的顺序。
+4. **应用**:开启“应用到店铺”开关。
+
+### 使用案例:
+**案例:时尚服装独立站优化搜索体验**
+- **背景**:某服装站商品繁多,用户搜索“连衣裙”后结果太多,难以抉择。
+- **配置方案**:
+ - **筛选器**:添加“款式”筛选(尺码、颜色)、“价格”区间筛选、“标签”筛选(如“2024夏季新品”)。
+ - **排序**:将“销量高低”和“加购最多”排在靠前位置,帮助用户参考他人购买偏好。
+ - **热词**:设置“法式复古”、“显瘦”为热词,引导用户点击。
+- **效果**:用户通过筛选快速找到符合自己尺码和预算的连衣裙,提升了搜索到下单的转化率。
+
+## 3. 市场需求
+- 提升站内搜索体验,减少用户流失。
+- 商家希望通过搜索数据引导用户购买特定商品(通过热词)。
+- 自动化推荐减少人工配置成本。
diff --git a/docs/blog/website_seo_analysis.md b/docs/blog/website_seo_analysis.md
new file mode 100644
index 0000000..ec24fce
--- /dev/null
+++ b/docs/blog/website_seo_analysis.md
@@ -0,0 +1,33 @@
+# Website SEO 插件分析
+
+## 1. 解决的具体问题
+- **搜索排名靠后**:通过优化 TDK(Title, Description, Keywords)提升在 Google 等搜索引擎的排名。
+- **自然流量匮乏**:通过 SEO 吸引精准客户,减少对付费广告的依赖。
+- **SEO 门槛高**:为新手商家提供简化的设置流程,快速上手 SEO 基础配置。
+
+## 2. 技术方案/功能点
+- **主页 TDK 管理**:
+ - **SEO 标题**:自定义主页在搜索结果中显示的标题。
+ - **SEO 描述**:添加店铺整体描述和关键词,增强相关性。
+ - **SEO 关键词**:添加多个核心关键字,扩大搜索覆盖面。
+- **索引优化**:通过规范化的元标签设置,增强搜索引擎的爬取和索引效率。
+
+## 3. 市场需求
+- 商家需要低成本获取流量的手段。
+- 品牌化建设需求,确保搜索品牌名时能展示正确的描述。
+- 自动化/引导式 SEO 配置,降低技术理解成本。
+
+## 4. 详细用法与案例
+### 用法步骤:
+1. **安装**:在应用商店搜索“网站SEO”并安装。
+2. **配置主页 SEO**:进入插件,填写“主页SEO标题”、“主页SEO描述”和“SEO关键词”。
+3. **保存**:点击保存,等待搜索引擎抓取更新。
+
+### 使用案例:
+**案例:新兴宠物用品独立站的品牌曝光优化**
+- **背景**:一家新开的宠物用品店“Pawsome Friends”,搜索品牌名时结果杂乱,且没有吸引人的描述。
+- **配置方案**:
+ - **标题**:Pawsome Friends | Premium Pet Supplies & Eco-friendly Toys
+ - **描述**:Shop at Pawsome Friends for high-quality, eco-friendly pet toys and organic treats. Free shipping on orders over $50. Your pet's happiness is our priority.
+ - **关键词**:pet supplies, eco-friendly dog toys, organic cat treats, Pawsome Friends.
+- **效果**:两周后,Google 搜索结果显示了整洁的品牌标题和描述,点击率(CTR)明显提升,品牌专业度得到认可。
diff --git a/docs/blog/店匠(Shoplazza)插件功能分析报告:搜索与推荐方向的市场需求与技术方案提炼.md b/docs/blog/店匠(Shoplazza)插件功能分析报告:搜索与推荐方向的市场需求与技术方案提炼.md
new file mode 100644
index 0000000..3d443c5
--- /dev/null
+++ b/docs/blog/店匠(Shoplazza)插件功能分析报告:搜索与推荐方向的市场需求与技术方案提炼.md
@@ -0,0 +1,121 @@
+# 店匠(Shoplazza)插件功能分析报告:搜索与推荐方向的市场需求与技术方案提炼
+
+**作者:** Manus AI
+**日期:** 2025年12月30日
+
+## 引言
+
+本报告旨在通过对店匠(Shoplazza)生态中四款核心插件——**智能商品搜索**、**网站 SEO**、**SEO 优化工具**和**图片 SEO**——的功能、技术方案及市场反馈进行深入分析,为贵公司的电商独立站 SaaS 系统在**搜索**和**推荐**领域提供市场需求收集和技术方向的参考。通过研究这些成熟工具,我们可以提炼出商家在站内搜索体验、站外搜索引擎优化(SEO)方面的核心痛点和解决方案。
+
+## 插件功能概览与核心价值
+
+这四款插件分别从站内搜索体验和站外自然流量获取两个维度,解决了电商商家在运营中的关键问题。下表总结了每个工具的核心价值和目标。
+
+| 插件名称 | 核心解决问题 | 技术方案核心 | 市场需求定位 |
+| :--- | :--- | :--- | :--- |
+| **智能商品搜索** | 站内搜索效率低下、结果不精准 | 多维度筛选器、自定义排序规则、搜索热词引导 | 提升站内转化率、优化用户体验 |
+| **网站 SEO** | 网站主页 SEO 基础配置缺失 | 主页 TDK(标题、描述、关键词)管理 | 品牌曝光、低成本自然流量获取 |
+| **SEO 优化工具** | 全站 SEO 维护复杂、效率低下 | 批量 Meta 标签、Sitemap 自动化、URL 重定向、结构化数据 | 规模化运营、高级 SEO 控制、死链修复 |
+| **图片 SEO** | 图片搜索流量缺失、Alt 标签配置耗时 | 智能 Alt 标签模板、批量优化引擎 | 视觉电商流量获取、无障碍访问合规 |
+
+## 详细分析与技术方案提炼
+
+### 1. 智能商品搜索插件
+
+#### 解决的具体问题与市场需求
+
+该插件的核心价值在于解决电商平台**站内搜索**的效率和精准度问题 [1]。在商品数量庞大的独立站中,用户往往难以快速找到目标商品,导致跳出率升高。商家迫切需要工具来**优化搜索结果的排序和筛选**,并通过**搜索热词**等方式引导用户,最终目标是提升搜索到购买的转化率。
+
+#### 技术方案与 SaaS 系统方向
+
+该插件的技术方案体现了对搜索结果的精细化控制:
+
+* **多维度筛选器配置**:技术上需要支持从商品数据中**自动拉取**(如价格、库存)和**手动配置**(如款式、标签、元字段)两种类型的筛选维度。这要求 SaaS 系统具备灵活的**商品属性元数据管理**能力。
+* **自定义排序引擎**:除了默认的推荐排序,还允许商家根据**业务需求**(如销量、加购数、上新时间)自定义排序权重。这表明 SaaS 系统应提供一个**可配置的搜索结果排序算法接口**,允许商家通过后台配置影响搜索结果的展示逻辑。
+* **搜索引导机制**:通过**搜索热词**和**历史记录**实现用户搜索意图的捕捉和引导。这要求系统具备**搜索日志分析**能力,能够识别高频搜索词并将其作为推荐热词。
+
+#### 使用案例:时尚服装独立站优化搜索体验
+
+某服装站通过配置**款式**(尺码、颜色)和**价格**筛选器,并将**销量高低**和**加购最多**设置为靠前排序规则,显著提升了用户在搜索“连衣裙”后找到目标商品的效率,从而提高了转化率。
+
+### 2. 网站 SEO 插件
+
+#### 解决的具体问题与市场需求
+
+网站 SEO 插件专注于解决**网站主页**的 SEO 基础配置问题 [2]。对于新手商家而言,如何设置**标题(Title)**、**描述(Description)**和**关键词(Keywords)**是进入 SEO 领域的第一步。市场需求集中在**简化操作**、**快速上手**,以低成本方式获取搜索引擎的**品牌曝光**和**自然流量**。
+
+#### 技术方案与 SaaS 系统方向
+
+其技术方案相对基础,但至关重要:
+
+* **TDK 集中管理**:提供一个统一的界面来设置网站主页的元标签。
+* **搜索引擎预览**:在后台提供 Google 搜索结果的**实时预览**功能,帮助商家优化文案以提高点击率(CTR)。
+
+对于 SaaS 系统而言,这是**基础功能**,应内置于核心建站模块中,确保每个新站点都能轻松完成基础 SEO 配置。
+
+#### 使用案例:新兴宠物用品独立站的品牌曝光优化
+
+一家新开的宠物用品店通过设置清晰的品牌标题和吸引人的描述(如包含“Premium Pet Supplies”和“Free shipping”),在 Google 搜索结果中展示了专业的品牌形象,有效提升了点击率。
+
+### 3. SEO 优化工具插件
+
+#### 解决的具体问题与市场需求
+
+SEO 优化工具是针对**规模化运营**和**高级 SEO 维护**的解决方案 [3]。它解决了商家在商品数量增加后,手动维护全站 SEO 标签、处理死链、提交网站地图等一系列复杂且耗时的任务。市场需求是**自动化**、**高级控制**和**合规性**。
+
+#### 技术方案与 SaaS 系统方向
+
+该插件的技术方案涉及多个高级 SEO 模块:
+
+* **批量 Meta 标签与规则引擎**:通过**变量引用**(如 `{product_title}`)实现全站商品、专辑、博客等页面的 Meta 标签**自动化生成**。这要求 SaaS 系统提供强大的**模板引擎**和**数据变量接口**。
+* **URL 重定向管理**:提供路径级和完整 URL 级的 **301/302 重定向**功能,用于修复死链和保留页面权重。这是维护网站健康度的关键技术。
+* **Sitemap 自动化与集成**:**实时或定时**生成并更新网站地图,并提供与 **Google Search Console** 的**集成接口**,实现一键提交。
+* **结构化数据(JSON-LD)**:自动生成并嵌入 **Google Snippets**(如面包屑、商品信息),以增强搜索结果的丰富度和可信度。
+* **Robots.txt 编辑器**:提供对爬虫访问权限的**高级控制**,满足个性化 SEO 需求。
+
+对于 SaaS 系统,这表明**自动化 SEO 规则引擎**和**外部工具集成**(如 Google Search Console)是提升产品专业度的重要方向。
+
+#### 使用案例:大型 3C 电子产品站的自动化 SEO 维护
+
+一个拥有数千 SKU 的电子产品站,通过设置 Meta 标签模板和 Alt 标签规则,实现了全站 SEO 的自动化覆盖。同时,利用重定向功能将所有旧款下架商品页面导向最新款分类页,成功避免了因死链导致的 SEO 惩罚。
+
+### 4. 图片 SEO 插件
+
+#### 解决的具体问题与市场需求
+
+图片 SEO 插件专注于解决**图片搜索流量**的获取问题 [4]。对于以视觉为驱动的电商(如服装、家居),图片是重要的流量入口。该插件通过**批量、智能地设置图片 Alt 标签**,解决了手动操作的低效性,并提升了网站的**无障碍访问**水平。
+
+#### 技术方案与 SaaS 系统方向
+
+该插件的技术方案是 SEO 规则引擎在图片领域的应用:
+
+* **智能 Alt 标签模板**:与 SEO 优化工具类似,使用**变量**(如产品标题、集合名称)来构造符合 SEO 规范的 Alt 文本。
+* **批量处理能力**:核心技术在于能够**高效地遍历**全站图片资源,并**批量写入** Alt 属性,同时支持对新上传图片的**自动应用**。
+* **场景覆盖**:支持商品图、专辑封面、博客配图等**多场景**的 Alt 标签优化。
+
+对于 SaaS 系统,这强调了**媒体资源管理**模块中应内置**智能 Alt 文本生成**功能,可以考虑集成 AI 能力,根据图片内容自动生成描述性 Alt 文本,进一步提升自动化水平。
+
+## 总结与对 SaaS 系统的方向建议
+
+通过对上述插件的分析,我们可以为贵公司的电商独立站 SaaS 系统在**搜索**和**推荐**方向提炼出以下关键市场需求和技术方向:
+
+| 领域 | 市场需求提炼 | 技术方案方向 |
+| :--- | :--- | :--- |
+| **站内搜索** | **精准度与引导**:用户需要快速找到目标商品,商家需要引导用户购买特定商品。 | **可配置的搜索排序引擎**:允许商家自定义权重(销量、价格、新品等)。
**智能搜索引导**:基于搜索日志的**热词推荐**和**联想词**功能。 |
+| **站外 SEO** | **自动化与规模化**:商品数量多,需要自动化工具进行全站 SEO 维护。 | **全站 SEO 规则模板引擎**:支持变量引用,批量生成 Meta 标签和 Alt 文本。
**网站健康度工具**:内置 **URL 重定向**、**Sitemap 自动化**、**Robots.txt 管理**等高级功能。 |
+| **推荐** | **关联性与转化**:在搜索结果页和商品详情页提供相关推荐,以提升转化。 | **智能关联推荐**:基于搜索词、浏览历史、商品属性的**实时推荐算法**。 |
+
+**核心建议:**
+
+1. **构建统一的规则引擎**:将 SEO 优化工具和图片 SEO 中的**模板/变量**技术方案整合,形成一个统一的**“元数据自动化规则引擎”**,用于批量管理 TDK、Alt 标签等,大幅提升商家运营效率。
+2. **深化站内搜索能力**:将“智能商品搜索”的功能作为核心竞争力,提供**多维度筛选**和**自定义排序**的灵活配置,并利用搜索数据进行**智能推荐**,将搜索功能从简单的匹配升级为**转化工具**。
+
+## 参考文献
+
+[1] 智能商品搜索 | 店匠应用商店:
+[2] Website SEO | 店匠应用商店:
+[3] SEO优化工具 | 店匠应用商店:
+[4] 图片 SEO | 店匠应用商店:
+[5] 智能商品搜索|配置筛选项 – Shoplazza 帮助中心:
+[6] 设置网站SEO – Shoplazza 帮助中心:
+[7] SEO优化工具使用说明 – Shoplazza 帮助中心:
diff --git a/docs/亚马逊到店匠格式转换分析.md b/docs/亚马逊到店匠格式转换分析.md
index d965362..8ebeea3 100644
--- a/docs/亚马逊到店匠格式转换分析.md
+++ b/docs/亚马逊到店匠格式转换分析.md
@@ -370,3 +370,4 @@ python scripts/amazon_xlsx_to_shoplazza_xlsx.py \
+
diff --git a/scripts/compare_index_mappings.py b/scripts/compare_index_mappings.py
new file mode 100644
index 0000000..7554e56
--- /dev/null
+++ b/scripts/compare_index_mappings.py
@@ -0,0 +1,189 @@
+#!/usr/bin/env python3
+"""
+对比不同租户索引的 mapping 结构
+"""
+
+import os
+import sys
+import json
+from pathlib import Path
+from typing import Dict, Any
+
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from utils.es_client import get_es_client_from_env
+
+
+def get_field_type(mapping_dict: Dict, field_path: str) -> Dict[str, Any]:
+ """递归获取字段的 mapping 信息"""
+ parts = field_path.split('.')
+ current = mapping_dict
+
+ for part in parts:
+ if isinstance(current, dict):
+ current = current.get(part)
+ if current is None:
+ return None
+ else:
+ return None
+ return current
+
+
+def compare_mappings(mapping1: Dict[str, Any], mapping2: Dict[str, Any], index1_name: str, index2_name: str):
+ """对比两个索引的 mapping"""
+ props1 = mapping1.get('mappings', {}).get('properties', {})
+ props2 = mapping2.get('mappings', {}).get('properties', {})
+
+ all_fields = set(props1.keys()) | set(props2.keys())
+
+ print(f"\n{'='*80}")
+ print(f"对比索引映射结构")
+ print(f"{'='*80}")
+ print(f"索引1: {index1_name}")
+ print(f"索引2: {index2_name}")
+ print(f"{'='*80}\n")
+
+ differences = []
+ same_fields = []
+
+ for field in sorted(all_fields):
+ field1 = props1.get(field)
+ field2 = props2.get(field)
+
+ if field1 is None:
+ differences.append((field, f"只在 {index2_name} 中存在", field2))
+ continue
+ if field2 is None:
+ differences.append((field, f"只在 {index1_name} 中存在", field1))
+ continue
+
+ type1 = field1.get('type')
+ type2 = field2.get('type')
+
+ if type1 != type2:
+ differences.append((field, f"类型不同: {index1_name}={type1}, {index2_name}={type2}", (field1, field2)))
+ else:
+ same_fields.append((field, type1))
+
+ # 打印相同的字段
+ print(f"✓ 相同字段 ({len(same_fields)} 个):")
+ for field, field_type in same_fields[:20]: # 只显示前20个
+ print(f" - {field}: {field_type}")
+ if len(same_fields) > 20:
+ print(f" ... 还有 {len(same_fields) - 20} 个相同字段")
+
+ # 打印不同的字段
+ if differences:
+ print(f"\n✗ 不同字段 ({len(differences)} 个):")
+ for field, reason, details in differences:
+ print(f"\n {field}:")
+ print(f" {reason}")
+ if isinstance(details, tuple):
+ print(f" {index1_name}: {json.dumps(details[0], indent=4, ensure_ascii=False)}")
+ print(f" {index2_name}: {json.dumps(details[1], indent=4, ensure_ascii=False)}")
+ else:
+ print(f" 详情: {json.dumps(details, indent=4, ensure_ascii=False)}")
+ else:
+ print(f"\n✓ 所有字段类型一致!")
+
+ # 特别检查 tags 字段
+ print(f"\n{'='*80}")
+ print(f"特别检查: tags 字段")
+ print(f"{'='*80}")
+
+ tags1 = get_field_type(props1, 'tags')
+ tags2 = get_field_type(props2, 'tags')
+
+ if tags1:
+ print(f"\n{index1_name}.tags:")
+ print(f" 类型: {tags1.get('type')}")
+ print(f" 完整定义: {json.dumps(tags1, indent=2, ensure_ascii=False)}")
+ else:
+ print(f"\n{index1_name}.tags: 不存在")
+
+ if tags2:
+ print(f"\n{index2_name}.tags:")
+ print(f" 类型: {tags2.get('type')}")
+ print(f" 完整定义: {json.dumps(tags2, indent=2, ensure_ascii=False)}")
+ else:
+ print(f"\n{index2_name}.tags: 不存在")
+
+
+def main():
+ import argparse
+
+ parser = argparse.ArgumentParser(description='对比 Elasticsearch 索引的 mapping 结构')
+ parser.add_argument('index1', help='第一个索引名称 (例如: search_products_tenant_171)')
+ parser.add_argument('index2', nargs='?', help='第二个索引名称 (例如: search_products_tenant_162)')
+ parser.add_argument('--list', action='store_true', help='列出所有以 index1 为前缀的索引')
+
+ args = parser.parse_args()
+
+ # 连接 ES
+ try:
+ es_client = get_es_client_from_env()
+ if not es_client.ping():
+ print("✗ 无法连接到 Elasticsearch")
+ return 1
+ print("✓ Elasticsearch 连接成功\n")
+ except Exception as e:
+ print(f"✗ 连接 Elasticsearch 失败: {e}")
+ return 1
+
+ # 如果指定了 --list,列出所有匹配的索引
+ if args.list or not args.index2:
+ try:
+ # 使用 cat API 列出所有索引
+ indices = es_client.client.cat.indices(format='json')
+ matching_indices = [idx['index'] for idx in indices if idx['index'].startswith(args.index1)]
+
+ if matching_indices:
+ print(f"找到 {len(matching_indices)} 个匹配的索引:")
+ for idx in sorted(matching_indices):
+ print(f" - {idx}")
+ return 0
+ else:
+ print(f"未找到以 '{args.index1}' 开头的索引")
+ return 1
+ except Exception as e:
+ print(f"✗ 列出索引失败: {e}")
+ return 1
+
+ # 获取两个索引的 mapping
+ index1 = args.index1
+ index2 = args.index2
+
+ print(f"正在获取索引映射...")
+ print(f" 索引1: {index1}")
+ print(f" 索引2: {index2}\n")
+
+ # 检查索引是否存在
+ if not es_client.index_exists(index1):
+ print(f"✗ 索引 '{index1}' 不存在")
+ return 1
+
+ if not es_client.index_exists(index2):
+ print(f"✗ 索引 '{index2}' 不存在")
+ return 1
+
+ # 获取 mapping
+ mapping1 = es_client.get_mapping(index1)
+ mapping2 = es_client.get_mapping(index2)
+
+ if not mapping1 or index1 not in mapping1:
+ print(f"✗ 无法获取索引 '{index1}' 的映射")
+ return 1
+
+ if not mapping2 or index2 not in mapping2:
+ print(f"✗ 无法获取索引 '{index2}' 的映射")
+ return 1
+
+ # 对比 mapping
+ compare_mappings(mapping1[index1], mapping2[index2], index1, index2)
+
+ return 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
+
diff --git a/scripts/test_cnclip_service.py b/scripts/test_cnclip_service.py
new file mode 100755
index 0000000..2fcfc7b
--- /dev/null
+++ b/scripts/test_cnclip_service.py
@@ -0,0 +1,115 @@
+#!/usr/bin/env python3
+"""
+CN-CLIP 服务测试脚本
+
+用途:
+ 测试 CN-CLIP 服务的文本和图像编码功能(使用 gRPC 协议)
+
+使用方法:
+ python scripts/test_cnclip_service.py [PORT]
+
+参数:
+ PORT: 服务端口(默认:51000)
+"""
+
+import sys
+import numpy as np
+from clip_client import Client
+
+
+def test_encoding(client, test_name, inputs):
+ """测试编码功能"""
+ print(f"\n{test_name}...")
+ try:
+ result = client.encode(inputs)
+ if isinstance(result, np.ndarray):
+ print(f"✓ 成功! 形状: {result.shape}")
+ print(f" 输入数量: {len(inputs)}")
+ print(f" 输出维度: {result.shape[1]}")
+
+ # 显示每个 embedding 的维度和前20个数字
+ for i in range(min(len(inputs), result.shape[0])):
+ emb = result[i]
+ first_20 = emb[:20].tolist()
+
+ # 计算 L2 归一化
+ norm = np.linalg.norm(emb)
+ normalized_emb = emb / norm if norm > 0 else emb
+ normalized_first_20 = normalized_emb[:20].tolist()
+
+ print(f" input: {inputs[i]}")
+ print(f" Embedding[{i}] 维度: {len(emb)}")
+ print(f" 前20个数字: {first_20}")
+ print(f" normalize后的前20个数字: {normalized_first_20}")
+ return True
+ else:
+ print(f"✗ 失败: 返回类型错误: {type(result)}")
+ return False
+ except Exception as e:
+ print(f"✗ 失败: {e}")
+ import traceback
+ traceback.print_exc()
+ return False
+
+
+def main():
+ # 获取端口参数
+ port = sys.argv[1] if len(sys.argv) > 1 else "51000"
+ grpc_url = f"grpc://localhost:{port}"
+
+ print("=" * 50)
+ print("CN-CLIP 服务测试")
+ print("=" * 50)
+ print(f"服务地址: {grpc_url} (gRPC 协议)")
+ print()
+
+ # 创建客户端
+ try:
+ client = Client(grpc_url)
+ except Exception as e:
+ print(f"✗ 客户端创建失败: {e}")
+ sys.exit(1)
+
+ # 运行测试
+ results = []
+
+ # 测试1: 文本编码
+ results.append(test_encoding(
+ client,
+ "测试1: 编码文本",
+ ['这是一个测试文本', '另一个测试文本']
+ ))
+
+ # 测试2: 图像编码
+ results.append(test_encoding(
+ client,
+ "测试2: 编码图像(远程 URL)",
+ ['https://oss.essa.cn/98532128-cf8e-456c-9e30-6f2a5ea0c19f.jpg']
+ ))
+
+ # 测试3: 混合编码
+ results.append(test_encoding(
+ client,
+ "测试3: 混合编码(文本和图像)",
+ ['这是一段文本', 'https://oss.essa.cn/98532128-cf8e-456c-9e30-6f2a5ea0c19f.jpg']
+ ))
+
+ # 汇总
+ print("\n" + "=" * 50)
+ print("测试结果汇总")
+ print("=" * 50)
+ print(f"总测试数: {len(results)}")
+ print(f"通过: {sum(results)}")
+ print(f"失败: {len(results) - sum(results)}")
+
+ if all(results):
+ print("\n✓ 所有测试通过!")
+ sys.exit(0)
+ else:
+ print("\n✗ 部分测试失败")
+ sys.exit(1)
+
+
+if __name__ == '__main__':
+ main()
+
diff --git a/scripts/test_cnclip_service.sh b/scripts/test_cnclip_service.sh
deleted file mode 100755
index a499b51..0000000
--- a/scripts/test_cnclip_service.sh
+++ /dev/null
@@ -1,131 +0,0 @@
-#!/bin/bash
-
-###############################################################################
-# CN-CLIP 服务测试脚本
-#
-# 用途:
-# 测试 CN-CLIP 服务的文本和图像编码功能(使用 gRPC 协议)
-#
-# 使用方法:
-# ./scripts/test_cnclip_service.sh [PORT]
-#
-# 参数:
-# PORT: 服务端口(默认:51000)
-#
-###############################################################################
-
-set -e
-
-# 颜色定义
-RED='\033[0;31m'
-GREEN='\033[0;32m'
-YELLOW='\033[1;33m'
-BLUE='\033[0;34m'
-NC='\033[0m' # No Color
-
-# 默认端口
-PORT=${1:-51000}
-GRPC_URL="grpc://localhost:${PORT}"
-
-echo -e "${BLUE}========================================${NC}"
-echo -e "${BLUE}CN-CLIP 服务测试${NC}"
-echo -e "${BLUE}========================================${NC}"
-echo ""
-echo -e "服务地址: ${GRPC_URL} (gRPC 协议)"
-echo ""
-
-# 检查 clip_client 是否安装
-if ! python3 -c "from clip_client import Client" 2>/dev/null; then
- echo -e "${RED}错误: clip_client 未安装${NC}"
- echo -e "${YELLOW}请运行: pip install clip-client${NC}"
- exit 1
-fi
-
-# 使用 Python 客户端测试(因为服务使用 gRPC 协议)
-python3 << PYTHON_EOF
-import sys
-import numpy as np
-from clip_client import Client
-
-def test_encoding(client, test_name, inputs):
- print(f"\n{test_name}...")
- try:
- result = client.encode(inputs)
- if isinstance(result, np.ndarray):
- print(f"✓ 成功! 形状: {result.shape}")
- print(f" 输入数量: {len(inputs)}")
- print(f" 输出维度: {result.shape[1]}")
-
- # 显示每个 embedding 的维度和前20个数字
- for i in range(min(len(inputs), result.shape[0])):
- emb = result[i]
- first_20 = emb[:20].tolist()
- print(f" Embedding[{i}] 维度: {len(emb)}")
- print(f" 前20个数字: {first_20}")
- return True
- else:
- print(f"✗ 失败: 返回类型错误: {type(result)}")
- return False
- except Exception as e:
- print(f"✗ 失败: {e}")
- import traceback
- traceback.print_exc()
- return False
-
-# 测试
-port = "${PORT}"
-client = Client(f'grpc://localhost:{port}')
-
-results = []
-
-# 测试1: 文本编码
-results.append(test_encoding(
- client,
- "测试1: 编码文本",
- ['这是一个测试文本', '另一个测试文本']
-))
-
-# 测试2: 图像编码
-results.append(test_encoding(
- client,
- "测试2: 编码图像(远程 URL)",
- ['https://oss.essa.cn/98532128-cf8e-456c-9e30-6f2a5ea0c19f.jpg']
-))
-
-# 测试3: 混合编码
-results.append(test_encoding(
- client,
- "测试3: 混合编码(文本和图像)",
- ['这是一段文本', 'https://oss.essa.cn/98532128-cf8e-456c-9e30-6f2a5ea0c19f.jpg']
-))
-
-# 汇总
-print("\n" + "="*50)
-print("测试结果汇总")
-print("="*50)
-print(f"总测试数: {len(results)}")
-print(f"通过: {sum(results)}")
-print(f"失败: {len(results) - sum(results)}")
-
-if all(results):
- print("\n✓ 所有测试通过!")
- sys.exit(0)
-else:
- print("\n✗ 部分测试失败")
- sys.exit(1)
-PYTHON_EOF
-
-TEST_RESULT=$?
-
-if [ $TEST_RESULT -eq 0 ]; then
- echo ""
- echo -e "${GREEN}========================================${NC}"
- echo -e "${GREEN}✓ 所有测试通过!${NC}"
- echo -e "${GREEN}========================================${NC}"
-else
- echo ""
- echo -e "${RED}========================================${NC}"
- echo -e "${RED}✗ 部分测试失败${NC}"
- echo -e "${RED}========================================${NC}"
- exit 1
-fi
diff --git a/search/searcher.py b/search/searcher.py
index f54b8de..e83b79a 100644
--- a/search/searcher.py
+++ b/search/searcher.py
@@ -275,6 +275,7 @@ class Searcher:
try:
# Generate tenant-specific index name
index_name = get_tenant_index_name(tenant_id)
+ index_name = "search_products"
# No longer need to add tenant_id to filters since each tenant has its own index
--
libgit2 0.21.2