Commit cc11ae049ef46f40dfc99065dbb82e914cf71b09

Authored by tangwang
1 parent e7a2c0b7

cnclip

... ... @@ -33,5 +33,9 @@ CACHE_DIR=.cache
33 33 API_BASE_URL=http://43.166.252.75:6002
34 34  
35 35  
  36 +# 国内
36 37 DASHSCOPE_API_KEY=sk-c3b8d4db061840aa8effb748df2a997b
  38 +# 美国
  39 +DASHSCOPE_API_KEY=sk-482cc3ff37a8467dab134a7a46830556
  40 +
37 41 OPENAI_API_KEY=sk-HvmTMKtuznibZ75l7L2uF2jiaYocCthqd8Cbdkl09KTE7Ft0
... ...
.gitignore
1   -# Prerequisites
2   -*.d
3   -
4   -# Compiled Object files
5   -*.slo
6   -*.lo
7   -*.o
8   -*.obj
9   -
10   -# Precompiled Headers
11   -*.gch
12   -*.pch
13   -
14   -# Compiled Dynamic libraries
15   -*.so
16   -*.dylib
17   -*.dll
18   -
19   -# Fortran module files
20   -*.mod
21   -*.smod
22   -
23   -# Compiled Static libraries
24   -*.lai
25   -*.la
26   -*.a
27   -*.lib
28   -
29   -# Executables
30   -*.exe
31   -*.out
32   -*.app
33   -
34   -# Projects
35   -.vscode
36   -
37   -model/*
38   -model.bin.*
39   -*.pyc
40   -*.swp
41   -.pydevproject
42   -.DS_Store
43   -.project
44   -.idea
45   -.data
46   -__pycache__
47   -*.log
48   -*.bak*/
49   -.history.txt
50   -log/
51   -logs/
52   -.venv/
53   -nohup.out
54   -temp/
55   -indexer_input*
56   -log.*
57   -output
58   -data.*
59   -*.json
60   -*.idx
61   -*.npy
62   -*.tgz
63   -*.tar.gz
64   -*.tar
65   -*.pt
66   -
67   -*.log
68   -log/
69   -logs_*/
70   -
71   -*.xlsx
  1 +# Prerequisites
  2 +*.d
  3 +
  4 +# Compiled Object files
  5 +*.slo
  6 +*.lo
  7 +*.o
  8 +*.obj
  9 +
  10 +# Precompiled Headers
  11 +*.gch
  12 +*.pch
  13 +
  14 +# Compiled Dynamic libraries
  15 +*.so
  16 +*.dylib
  17 +*.dll
  18 +
  19 +# Fortran module files
  20 +*.mod
  21 +*.smod
  22 +
  23 +# Compiled Static libraries
  24 +*.lai
  25 +*.la
  26 +*.a
  27 +*.lib
  28 +
  29 +# Executables
  30 +*.exe
  31 +*.out
  32 +*.app
  33 +
  34 +# Projects
  35 +.vscode
  36 +
  37 +model/*
  38 +model.bin.*
  39 +*.pyc
  40 +*.swp
  41 +.pydevproject
  42 +.DS_Store
  43 +.project
  44 +.idea
  45 +.data
  46 +__pycache__
  47 +*.log
  48 +*.bak*/
  49 +.history.txt
  50 +log/
  51 +logs/
  52 +.venv/
  53 +.venv-cnclip/
  54 +nohup.out
  55 +temp/
  56 +indexer_input*
  57 +log.*
  58 +output
  59 +data.*
  60 +*.json
  61 +*.idx
  62 +*.npy
  63 +*.tgz
  64 +*.tar.gz
  65 +*.tar
  66 +*.pt
  67 +
  68 +*.log
  69 +log/
  70 +logs_*/
  71 +
  72 +*.xlsx
... ...
api/indexer_app.py
... ... @@ -165,6 +165,26 @@ async def startup_event():
165 165 try:
166 166 init_indexer_service(es_host=es_host)
167 167 logger.info("Indexer service initialized successfully")
  168 +
  169 + # Eager warmup: build per-tenant transformer bundles at startup to avoid
  170 + # first-request latency (config/provider/encoder + transformer wiring).
  171 + try:
  172 + if _incremental_service is not None and _config is not None:
  173 + tenants = []
  174 + # config.tenant_config shape: {"default": {...}, "tenants": {"1": {...}, ...}}
  175 + tc = getattr(_config, "tenant_config", None) or {}
  176 + if isinstance(tc, dict):
  177 + tmap = tc.get("tenants")
  178 + if isinstance(tmap, dict):
  179 + tenants = [str(k) for k in tmap.keys()]
  180 + # If no explicit tenants configured, skip warmup.
  181 + if tenants:
  182 + warm = _incremental_service.warmup_transformers(tenants)
  183 + logger.info("Indexer warmup completed: %s", warm)
  184 + else:
  185 + logger.info("Indexer warmup skipped (no tenant ids in config.tenant_config.tenants)")
  186 + except Exception as e:
  187 + logger.warning("Indexer warmup failed (service still starts): %s", e, exc_info=True)
168 188 except Exception as e:
169 189 logger.error(f"Failed to initialize indexer service: {e}", exc_info=True)
170 190 logger.warning("Indexer service will start but may not function correctly")
... ...
api/routes/indexer.py
... ... @@ -245,16 +245,30 @@ async def build_docs(request: BuildDocsRequest):
245 245 break
246 246 if title_text and str(title_text).strip():
247 247 try:
  248 + import numpy as np
  249 +
248 250 embeddings = encoder.encode(title_text)
249 251 if embeddings is not None and len(embeddings) > 0:
250 252 emb0 = embeddings[0]
251   - import numpy as np
252   -
253   - if isinstance(emb0, np.ndarray):
  253 + if isinstance(emb0, np.ndarray) and emb0.size > 0:
254 254 doc["title_embedding"] = emb0.tolist()
255   - except Exception:
  255 + else:
  256 + logger.warning(
  257 + "build-docs: title_embedding skipped (encoder returned None/invalid for title: %s...)",
  258 + title_text[:50],
  259 + )
  260 + else:
  261 + logger.warning(
  262 + "build-docs: title_embedding skipped (encoder returned empty for title: %s...)",
  263 + title_text[:50],
  264 + )
  265 + except Exception as e:
  266 + logger.warning(
  267 + "build-docs: title_embedding failed for spu_id=%s: %s",
  268 + doc.get("spu_id"),
  269 + e,
  270 + )
256 271 # 构建 doc 接口不因为 embedding 失败而整体失败
257   - pass
258 272  
259 273 docs.append(doc)
260 274 except Exception as e:
... ...
docs/CNCLIP_SERVICE说明文档.md
1   -# CN-CLIP 服务(Legacy)
  1 +# CN-CLIP 服务(clip-as-service)说明
2 2  
3   -> **注意**:当前主流程使用 embedding 服务(端口 6005),见 `docs/QUICKSTART.md` 3.3。本文档为 legacy gRPC 服务说明
  3 +> 本文是本仓库的 CN-CLIP 运行手册与约束说明。主流程仍是 `embedding` 服务(`6005`);当 `embeddings/config.py` 中 `USE_CLIP_AS_SERVICE=true` 时,`embedding` 会调用本 gRPC 服务(默认 `grpc://127.0.0.1:51000`)生成图片向量
4 4  
5   ----
  5 +## 1. 设计目标与官方对齐
6 6  
7   -# TODO(历史)
  7 +- 采用 `clip-as-service` 的标准拆分:`clip-server`(服务端)与 `clip-client`(客户端)可独立安装。
  8 +- 服务协议使用 gRPC,符合官方推荐与本项目现有调用链。
  9 +- 保持“主项目环境”和“CN-CLIP 专用环境”解耦,避免 `grpcio/jina/docarray` 与主项目依赖互相污染。
8 10  
9   -现在,跟自己 cn_clip 预估的结果,有差别:
10   -这个比较接近: 可能是预处理逻辑有些不一样。
11   -https://oss.essa.cn/98532128-cf8e-456c-9e30-6f2a5ea0c19f.jpg
12   -normlize后的结果:
13   -0.046295166015625,0.012847900390625,-0.0299530029296875,-0.01629638671875,0.01708984375,0.00487518310546875,0.01284027099609375,0.01348876953125,0.04617632180452347, 0.012860896065831184, -0.030133124440908432, -0.0162516962736845,
14   -0.04617632180452347, 0.012860896065831184, -0.030133124440908432, -0.0162516962736845, 0.01708567887544632, 0.005110889207571745
  11 +官方仓库(安装方式、server/client 分离、基本使用示例):
  12 +[jina-ai/clip-as-service](https://github.com/jina-ai/clip-as-service)
15 13  
16   -以下两个,差别非常大,感觉不是一个模型:
17   -https://aisearch.cdn.bcebos.com/fileManager/GtB5doGAr1skTx38P7fb7Q/182.jpg?authorization=bce-auth-v1%2F7e22d8caf5af46cc9310f1e3021709f3%2F2025-12-30T04%3A45%3A38Z%2F86400%2Fhost%2Ffe222039926cb7ff593021af40268c782b8892598114e24773d0c1bfc976a8df
18   -https://oss.essa.cn/2e353867-7496-4d4e-a7c8-0af50f49f6eb.jpg?x-oss-process=image/resize,m_lfit,w_2048,h_2048
  14 +## 2. 当前架构(本仓库)
19 15  
20   -curl -X POST "http://43.166.252.75:5000/embedding/generate_image_embeddings" -H "Content-Type: application/json" -d '[
21   - {
22   - "id": "test_1",
23   - "pic_url": "https://oss.essa.cn/98532128-cf8e-456c-9e30-6f2a5ea0c19f.jpg"
24   - }
25   - ]'
26   -
  16 +- **服务启动脚本**:`scripts/start_cnclip_service.sh`
  17 +- **服务停止脚本**:`scripts/stop_cnclip_service.sh`
  18 +- **环境初始化脚本**:`scripts/setup_cnclip_venv.sh`
  19 +- **统一编排入口**:`scripts/service_ctl.sh`(`restart.sh` 调用它)
  20 +- **默认端口**:`51000`
  21 +- **默认模型**:`CN-CLIP/ViT-H-14`
  22 +- **默认协议**:gRPC
27 23  
  24 +## 3. 环境准备策略(推荐做法)
28 25  
29   -# CN-CLIP 编码服务
  26 +### 3.1 推荐:专用 venv(`.venv-cnclip`)
30 27  
31   -## 模块说明
32   -
33   -CN-CLIP 编码服务基于 [clip-as-service](https://github.com/jina-ai/clip-as-service) 提供中文 CLIP 模型的文本和图像编码功能。服务使用 gRPC 协议,支持批量编码,返回固定维度的向量表示。
  28 +```bash
  29 +./scripts/setup_cnclip_venv.sh
  30 +```
34 31  
35   -### 功能特性
  32 +脚本会创建 `.venv-cnclip`,并处理已知兼容性问题(`grpcio`、`jina`、`docarray`、`pkg_resources` 等),避免在主 `.venv` 中反复冲突。
36 33  
37   -- 文本编码:将中文文本编码为向量
38   -- 图像编码:将图像(本地文件或远程 URL)编码为向量
39   -- 混合编码:同时编码文本和图像
40   -- 批量处理:支持批量编码,提高效率
  34 +### 3.2 启动时的环境选择
41 35  
42   -### 技术架构
  36 +`start_cnclip_service.sh` 的优先级:
43 37  
44   -- **框架**: clip-as-service (基于 Jina)
45   -- **模型**: CN-CLIP/ViT-L-14-336(默认)
46   -- **协议**: gRPC(默认,官方推荐)
47   -- **运行时**: PyTorch
  38 +1. 若存在 `.venv-cnclip`,优先使用;
  39 +2. 否则回退到项目统一环境(`source activate.sh`);
  40 +3. 若两者都不可用,启动失败并提示修复动作。
48 41  
49   -## 启动服务
  42 +## 4. 服务管理方式(推荐)
50 43  
51   -### 基本用法
  44 +### 4.1 单独启动/停止
52 45  
53 46 ```bash
54   -./scripts/start_cnclip_service.sh
  47 +./scripts/start_cnclip_service.sh --device cuda
  48 +./scripts/stop_cnclip_service.sh
55 49 ```
56 50  
57   -### 启动参数
58   -
59   -| 参数 | 说明 | 默认值 |
60   -|------|------|--------|
61   -| `--port PORT` | 服务端口 | 51000 |
62   -| `--device DEVICE` | 设备类型:cuda 或 cpu | 自动检测 |
63   -| `--batch-size SIZE` | 批处理大小 | 32 |
64   -| `--num-workers NUM` | 预处理线程数 | 4 |
65   -| `--dtype TYPE` | 数据类型:float16 或 float32 | float16 |
66   -| `--model-name NAME` | 模型名称 | CN-CLIP/ViT-L-14-336 |
67   -| `--replicas NUM` | 副本数 | 1 |
68   -
69   -### 示例
  51 +### 4.2 统一编排(推荐日常用法)
70 52  
71 53 ```bash
72   -# 使用默认配置启动
73   -./scripts/start_cnclip_service.sh
74   -
75   -# 指定端口和设备
76   -./scripts/start_cnclip_service.sh --port 52000 --device cpu
77   -
78   -# 使用其他模型
79   -./scripts/start_cnclip_service.sh --model-name CN-CLIP/ViT-H-14
  54 +./scripts/service_ctl.sh restart
  55 +# 或
  56 +./restart.sh
80 57 ```
81 58  
82   -### 停止服务
  59 +`service_ctl.sh` 在启动 `cnclip` 时默认注入 `CNCLIP_DEVICE=cuda`。
  60 +若机器无 GPU 或希望改用 CPU,可在 `.env` 设置:
83 61  
84 62 ```bash
85   -./scripts/stop_cnclip_service.sh
  63 +CNCLIP_DEVICE=cpu
86 64 ```
87 65  
88   -## API 接口说明
  66 +## 5. GPU 使用与验证
89 67  
90   -### Python 客户端
  68 +### 5.1 必须点
91 69  
92   -服务使用 gRPC 协议,必须使用 Python 客户端:
  70 +- 启动日志显示 `device: cuda` 仅代表配置传入成功;
  71 +- 只有在**首次编码请求触发模型加载后**,`nvidia-smi` 才一定能看到显存占用。
93 72  
94   -```python
95   -from clip_client import Client
  73 +### 5.2 推荐验证步骤
96 74  
97   -# 创建客户端(使用 grpc:// 协议)
98   -c = Client('grpc://localhost:51000')
99   -```
  75 +1) 启动服务:
100 76  
101   -### 编码接口
  77 +```bash
  78 +./scripts/start_cnclip_service.sh --port 51000 --device cuda
  79 +```
102 80  
103   -#### 1. 文本编码
  81 +2) 发送一次请求(触发模型加载):
104 82  
105   -```python
  83 +```bash
  84 +PYTHONPATH="third-party/clip-as-service/client:${PYTHONPATH}" NO_VERSION_CHECK=1 .venv-cnclip/bin/python -c "
106 85 from clip_client import Client
107   -
108 86 c = Client('grpc://localhost:51000')
109   -
110   -# 编码单个文本
111   -result = c.encode(['这是测试文本'])
112   -print(result.shape) # (1, 1024)
113   -
114   -# 编码多个文本
115   -result = c.encode(['文本1', '文本2', '文本3'])
116   -print(result.shape) # (3, 1024)
  87 +r = c.encode(['测试'])
  88 +print('shape:', r.shape)
  89 +"
117 90 ```
118 91  
119   -#### 2. 图像编码
  92 +3) 观察 GPU:
120 93  
121   -```python
122   -# 编码远程图像 URL
123   -result = c.encode(['https://oss.essa.cn/98532128-cf8e-456c-9e30-6f2a5ea0c19f.jpg'])
124   -print(result.shape) # (1, 1024)
125   -
126   -# 编码本地图像文件
127   -result = c.encode(['/path/to/image.jpg'])
128   -print(result.shape) # (1, 1024)
129   -```
130   -
131   -#### 3. 混合编码
132   -
133   -```python
134   -# 同时编码文本和图像
135   -result = c.encode([
136   - '这是文本',
137   - 'https://oss.essa.cn/98532128-cf8e-456c-9e30-6f2a5ea0c19f.jpg',
138   - '另一个文本'
139   -])
140   -print(result.shape) # (3, 1024)
  94 +```bash
  95 +nvidia-smi
141 96 ```
142 97  
143   -### 返回格式
  98 +预期:
  99 +- `shape` 为 `(1, 1024)`;
  100 +- `nvidia-smi` 出现对应 `python`/`clip_server` 进程并有显存占用。
144 101  
145   -- **类型**: `numpy.ndarray`
146   -- **形状**: `(N, 1024)`,其中 N 是输入数量
147   -- **数据类型**: `float32`
148   -- **维度**: 1024(CN-CLIP 模型的 embedding 维度)
  102 +## 6. 使用方式(客户端)
149 103  
150   -### 支持的模型
  104 +### 6.1 在本仓库中(推荐)
151 105  
152   -| 模型名称 | 说明 | 推荐场景 |
153   -|---------|------|---------|
154   -| `CN-CLIP/ViT-B-16` | 基础版本,速度快 | 对速度要求高的场景 |
155   -| `CN-CLIP/ViT-L-14` | 平衡版本 | 通用场景 |
156   -| `CN-CLIP/ViT-L-14-336` | 高分辨率版本(默认) | 需要处理高分辨率图像 |
157   -| `CN-CLIP/ViT-H-14` | 大型版本,精度高 | 对精度要求高的场景 |
158   -| `CN-CLIP/RN50` | ResNet-50 版本 | 兼容性场景 |
  106 +- 服务消费者一般是 `embedding` 服务,不建议业务侧直接连 `cnclip`。
  107 +- 若需手动调试,可在主 `.venv` 安装 client,或通过 `PYTHONPATH` 使用 vendored client。
159 108  
160   -## 测试
  109 +示例:
161 110  
162   -运行测试脚本:
  111 +```python
  112 +from clip_client import Client
163 113  
164   -```bash
165   -./scripts/test_cnclip_service.sh
  114 +c = Client("grpc://127.0.0.1:51000")
  115 +vec = c.encode(["https://example.com/a.jpg", "测试文本"])
  116 +print(vec.shape) # (2, 1024)
166 117 ```
167 118  
168   -测试脚本会验证:
169   -- 文本编码功能
170   -- 图像编码功能(远程 URL)
171   -- 混合编码功能
  119 +### 6.2 常见误区
172 120  
173   -每个测试会显示 embedding 的维度和前 20 个数字。
  121 +- ❌ 用 `http://localhost:51000` 当成 HTTP 服务访问;
  122 +- ❌ 只看“启动成功”就判断已用 GPU,不发请求不看 `nvidia-smi`;
  123 +- ❌ 在主 `.venv` 直接安装 server 依赖导致依赖树污染。
174 124  
175   -## 查看日志
  125 +## 7. 已知兼容性说明(关键信息)
176 126  
177   -```bash
178   -tail -f /data/tw/saas-search/logs/cnclip_service.log
179   -```
180   -
181   -## 常见问题
182   -
183   -### 1. 服务启动失败
184   -
185   -- 检查端口是否被占用:`lsof -i :51000`
186   -- 检查 conda 环境是否正确激活
187   -- 查看日志文件获取详细错误信息
  127 +- `clip-as-service` 在本项目场景下依赖链较老,`grpcio`/`jina`/`docarray` 组合在 Python 3.12 上易触发源码构建问题。
  128 +- `setuptools>=82` 移除了 `pkg_resources`;而部分依赖链仍会导入它,因此专用脚本固定了兼容范围。
  129 +- `setup_cnclip_venv.sh` 中存在“为可运行性而做的约束收敛”,这是有意行为,不建议手动放开。
188 130  
189   -### 2. 客户端连接失败
  131 +## 8. 排障速查
190 132  
191   -确保使用正确的协议:
  133 +### 8.1 启动失败
192 134  
193   -```python
194   -# 正确:使用 grpc://
195   -c = Client('grpc://localhost:51000')
  135 +- 查看日志:`tail -f logs/cnclip_service.log`
  136 +- 检查端口占用:`lsof -i :51000`
  137 +- 重新构建环境:`rm -rf .venv-cnclip && ./scripts/setup_cnclip_venv.sh`
196 138  
197   -# 错误:不要使用 http://
198   -# c = Client('http://localhost:51000') # 会失败
199   -```
  139 +### 8.2 连接失败
200 140  
201   -### 3. 编码失败
  141 +- 确认客户端使用 `grpc://` 协议;
  142 +- 确认端口与服务端一致(默认 `51000`)。
202 143  
203   -- 检查服务是否正常运行
204   -- 检查输入格式是否正确
205   -- 查看服务日志排查错误
  144 +### 8.3 看不到 GPU 进程
206 145  
207   -### 4. 依赖安装
  146 +- 先发一次编码请求,再看 `nvidia-smi`;
  147 +- 确认启动参数或环境变量为 `cuda`(`--device cuda` 或 `CNCLIP_DEVICE=cuda`);
  148 +- 确认日志中无模型加载异常。
208 149  
209   -确保已安装必要的依赖:
  150 +## 9. 与其他文档的关系
210 151  
211   -```bash
212   -pip install clip-client
213   -```
  152 +- 开发总览:`docs/QUICKSTART.md`
  153 +- 系统架构:`docs/DEVELOPER_GUIDE.md`
  154 +- 向量服务说明:`embeddings/README.md`
214 155  
215   -服务端依赖会在启动脚本中自动检查。
  156 +本文件聚焦 CN-CLIP(clip-as-service)专项,不重复解释项目通用内容。
... ...
docs/DEVELOPER_GUIDE.md
... ... @@ -401,6 +401,7 @@ services:
401 401 | 运维、日志、多环境、故障 | [Usage-Guide.md](./Usage-Guide.md) |
402 402 | 索引模块职责与 Java 对接 | [indexer/README.md](../indexer/README.md) |
403 403 | 向量模块与 clip-as-service | [embeddings/README.md](../embeddings/README.md) |
  404 +| CN-CLIP 服务专项(环境/运维/GPU) | [CNCLIP_SERVICE说明文档.md](./CNCLIP_SERVICE说明文档.md) |
404 405  
405 406 ### 10.2 仓库内入口
406 407  
... ...
docs/QUICKSTART.md
... ... @@ -495,6 +495,7 @@ lsof -i :6004
495 495 | `docs/搜索API对接指南.md` | 搜索 API 完整说明 |
496 496 | `indexer/README.md` | 索引模块职责与接口 |
497 497 | `embeddings/README.md` | 向量化服务说明 |
  498 +| `docs/CNCLIP_SERVICE说明文档.md` | CN-CLIP/clip-as-service 专项(环境、GPU、运维) |
498 499 | `reranker/README.md` | 重排服务说明 |
499 500  
500 501 ---
... ...
embeddings/clip_as_service_encoder.py
... ... @@ -21,6 +21,8 @@ def _ensure_clip_client_path():
21 21 client_path = os.path.join(repo_root, "third-party", "clip-as-service", "client")
22 22 if os.path.isdir(client_path) and client_path not in sys.path:
23 23 sys.path.insert(0, client_path)
  24 + # Skip client version check to avoid importing helper (pkg_resources); no conda/separate env
  25 + os.environ.setdefault("NO_VERSION_CHECK", "1")
24 26  
25 27  
26 28 def _normalize_image_url(url: str) -> str:
... ...
embeddings/server.py
... ... @@ -52,6 +52,9 @@ def load_models():
52 52  
53 53  
54 54 # Load image model: clip-as-service (recommended) or local CN-CLIP
  55 + # IMPORTANT: failures here should NOT prevent the whole service from starting.
  56 + # If image model cannot be loaded, we keep `_image_model` as None and only
  57 + # disable /embed/image while keeping /embed/text fully functional.
55 58 if open_image_model:
56 59 try:
57 60 if CONFIG.USE_CLIP_AS_SERVICE:
... ... @@ -69,8 +72,12 @@ def load_models():
69 72 )
70 73 logger.info("Image model (local CN-CLIP) loaded successfully")
71 74 except Exception as e:
72   - logger.error(f"Failed to load image model: {e}", exc_info=True)
73   - raise
  75 + logger.error(
  76 + "Failed to load image model; image embeddings will be disabled but text embeddings remain available: %s",
  77 + e,
  78 + exc_info=True,
  79 + )
  80 + _image_model = None
74 81  
75 82 logger.info("All embedding models loaded successfully, service ready")
76 83  
... ... @@ -132,7 +139,9 @@ def embed_text(texts: List[str]) -> List[Optional[List[float]]]:
132 139 @app.post("/embed/image")
133 140 def embed_image(images: List[str]) -> List[Optional[List[float]]]:
134 141 if _image_model is None:
135   - raise RuntimeError("Image model not loaded")
  142 + # Graceful degradation: keep API shape but return all None
  143 + logger.warning("embed_image called but image model is not loaded; returning all None vectors")
  144 + return [None] * len(images)
136 145 out: List[Optional[List[float]]] = [None] * len(images)
137 146  
138 147 # Normalize inputs
... ...
indexer/incremental_service.py
... ... @@ -32,20 +32,77 @@ class IncrementalIndexerService:
32 32 self.category_id_to_name = load_category_mapping(db_engine)
33 33 logger.info(f"Preloaded {len(self.category_id_to_name)} category mappings")
34 34  
35   - # 缓存:避免频繁增量请求重复加载config / 构造transformer
  35 + # 缓存:避免频繁增量请求重复加载 config / 构造 transformer
  36 + # NOTE: 为避免“首请求”懒加载导致超时,尽量在进程启动阶段完成初始化:
  37 + # - config.yaml 加载
  38 + # - translator / embedding / image encoder provider 初始化(best-effort)
36 39 self._config: Optional[Any] = None
37 40 self._config_lock = threading.Lock()
  41 + self._translator: Optional[Any] = None
  42 + self._translation_prompts: Optional[Dict[str, Any]] = None
  43 + self._searchable_option_dimensions: Optional[List[str]] = None
  44 + self._shared_text_encoder: Optional[Any] = None
  45 + self._shared_image_encoder: Optional[Any] = None
  46 +
  47 + self._eager_init()
38 48 # tenant_id -> (transformer, encoder, enable_embedding)
39 49 self._transformer_cache: Dict[str, Tuple[Any, Optional[Any], bool]] = {}
40 50 self._transformer_cache_lock = threading.Lock()
41 51  
  52 + def _eager_init(self) -> None:
  53 + """Best-effort eager initialization to reduce first-request latency."""
  54 + try:
  55 + self._config = ConfigLoader("config/config.yaml").load_config()
  56 + except Exception as e:
  57 + logger.warning("Failed to eagerly load config/config.yaml: %s", e, exc_info=True)
  58 + self._config = None
  59 + return
  60 +
  61 + try:
  62 + self._translation_prompts = getattr(self._config.query_config, "translation_prompts", {}) or {}
  63 + self._searchable_option_dimensions = (
  64 + getattr(self._config.spu_config, "searchable_option_dimensions", None)
  65 + or ["option1", "option2", "option3"]
  66 + )
  67 + except Exception:
  68 + self._translation_prompts = {}
  69 + self._searchable_option_dimensions = ["option1", "option2", "option3"]
  70 +
  71 + # Translator provider (best-effort)
  72 + try:
  73 + from providers import create_translation_provider
  74 +
  75 + self._translator = create_translation_provider(self._config.query_config)
  76 + except Exception as e:
  77 + logger.warning("Failed to initialize translation provider at startup: %s", e)
  78 + self._translator = None
  79 +
  80 + # Text embedding encoder (best-effort)
  81 + if bool(getattr(self._config.query_config, "enable_text_embedding", False)):
  82 + try:
  83 + from embeddings.text_encoder import BgeEncoder
  84 +
  85 + self._shared_text_encoder = BgeEncoder()
  86 + except Exception as e:
  87 + logger.warning("Failed to initialize BgeEncoder at startup: %s", e)
  88 + self._shared_text_encoder = None
  89 +
  90 + # Image embedding encoder (best-effort; may be unavailable if embedding service not running)
  91 + try:
  92 + from embeddings.image_encoder import CLIPImageEncoder
  93 +
  94 + self._shared_image_encoder = CLIPImageEncoder()
  95 + except Exception as e:
  96 + logger.debug("Image encoder not available for indexer startup: %s", e)
  97 + self._shared_image_encoder = None
  98 +
42 99 def _get_config(self) -> Any:
43 100 """Load config once per process (thread-safe)."""
44 101 if self._config is not None:
45 102 return self._config
46 103 with self._config_lock:
47 104 if self._config is None:
48   - self._config = ConfigLoader().load_config()
  105 + self._config = ConfigLoader("config/config.yaml").load_config()
49 106 return self._config
50 107  
51 108 def _get_transformer_bundle(self, tenant_id: str) -> Tuple[Any, Optional[Any], bool]:
... ... @@ -64,26 +121,39 @@ class IncrementalIndexerService:
64 121 config = self._get_config()
65 122 enable_embedding = bool(getattr(config.query_config, "enable_text_embedding", False))
66 123  
67   - encoder: Optional[Any] = None
68   - if enable_embedding:
  124 + # Use shared encoders/providers preloaded at startup when可用;
  125 + # 若启动时初始化失败,则在首次请求时做一次兜底初始化,避免永久禁用。
  126 + encoder: Optional[Any] = self._shared_text_encoder if enable_embedding else None
  127 + if enable_embedding and encoder is None:
69 128 try:
70 129 from embeddings.text_encoder import BgeEncoder
  130 +
71 131 encoder = BgeEncoder()
  132 + self._shared_text_encoder = encoder
  133 + logger.info("BgeEncoder lazily initialized in _get_transformer_bundle")
72 134 except Exception as e:
73   - logger.warning(f"Failed to initialize BgeEncoder for tenant_id={tenant_id}: {e}")
  135 + logger.warning("Failed to lazily initialize BgeEncoder for tenant_id=%s: %s", tenant_id, e)
74 136 encoder = None
75 137 enable_embedding = False
76 138  
77   - image_encoder: Optional[Any] = None
78   - try:
79   - from embeddings.image_encoder import CLIPImageEncoder
80   - image_encoder = CLIPImageEncoder()
81   - except Exception as e:
82   - logger.debug("Image encoder not available for indexer: %s", e)
  139 + image_encoder: Optional[Any] = self._shared_image_encoder
  140 + if image_encoder is None:
  141 + try:
  142 + from embeddings.image_encoder import CLIPImageEncoder
  143 +
  144 + image_encoder = CLIPImageEncoder()
  145 + self._shared_image_encoder = image_encoder
  146 + logger.info("CLIPImageEncoder lazily initialized in _get_transformer_bundle")
  147 + except Exception as e:
  148 + logger.debug("Image encoder not available for indexer (lazy init): %s", e)
  149 + image_encoder = None
83 150  
84 151 transformer = create_document_transformer(
85 152 category_id_to_name=self.category_id_to_name,
86 153 tenant_id=tenant_id,
  154 + searchable_option_dimensions=self._searchable_option_dimensions,
  155 + translator=self._translator,
  156 + translation_prompts=self._translation_prompts,
87 157 encoder=encoder,
88 158 enable_title_embedding=False, # batch fill later
89 159 image_encoder=image_encoder,
... ... @@ -97,6 +167,23 @@ class IncrementalIndexerService:
97 167 self._transformer_cache[str(tenant_id)] = bundle
98 168 return bundle
99 169  
  170 + def warmup_transformers(self, tenant_ids: List[str]) -> Dict[str, Any]:
  171 + """
  172 + Eagerly build transformer bundles for given tenant ids.
  173 + This moves per-tenant initialization to startup phase, reducing first-request latency.
  174 + """
  175 + start = time.time()
  176 + ok = 0
  177 + failed: List[Dict[str, str]] = []
  178 + for tid in tenant_ids or []:
  179 + try:
  180 + _ = self._get_transformer_bundle(str(tid))
  181 + ok += 1
  182 + except Exception as e:
  183 + failed.append({"tenant_id": str(tid), "error": str(e)})
  184 + elapsed_ms = round((time.time() - start) * 1000.0, 3)
  185 + return {"requested": len(tenant_ids or []), "warmed": ok, "failed": failed, "elapsed_ms": elapsed_ms}
  186 +
100 187 @staticmethod
101 188 def _normalize_spu_ids(spu_ids: List[str]) -> List[int]:
102 189 """Normalize SPU IDs to ints for DB queries; skip non-int IDs."""
... ...
indexer/process_products.py
... ... @@ -23,8 +23,11 @@ from config.env_config import REDIS_CONFIG
23 23  
24 24 # 配置
25 25 BATCH_SIZE = 20
26   -API_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
27   -MODEL_NAME = "qwen-max"
  26 +# 华北2(北京):https://dashscope.aliyuncs.com/compatible-mode/v1
  27 +# 新加坡:https://dashscope-intl.aliyuncs.com/compatible-mode/v1
  28 +# 美国(弗吉尼亚):https://dashscope-us.aliyuncs.com/compatible-mode/v1
  29 +API_BASE_URL = "https://dashscope-us.aliyuncs.com/compatible-mode/v1"
  30 +MODEL_NAME = "qwen-flash"
28 31 API_KEY = os.environ.get("DASHSCOPE_API_KEY")
29 32 MAX_RETRIES = 3
30 33 RETRY_DELAY = 5 # 秒
... ... @@ -398,8 +401,9 @@ def parse_markdown_table(markdown_content: str) -> List[Dict[str, str]]:
398 401  
399 402 # 表格行处理
400 403 if line.startswith('|'):
401   - # 分隔行(----)
402   - if set(line.replace('|', '').strip()) <= {'-', ':'}:
  404 + # 分隔行(---- 或 :---: 等;允许空格,如 "| ---- | ---- |")
  405 + sep_chars = line.replace('|', '').strip().replace(' ', '')
  406 + if sep_chars and set(sep_chars) <= {'-', ':'}:
403 407 data_started = True
404 408 continue
405 409  
... ...
query/translator.py
... ... @@ -59,9 +59,12 @@ class Translator:
59 59  
60 60 Default model is 'qwen' which uses Alibaba Cloud DashScope API.
61 61 """
  62 +# 华北2(北京):https://dashscope.aliyuncs.com/compatible-mode/v1
  63 +# 新加坡:https://dashscope-intl.aliyuncs.com/compatible-mode/v1
  64 +# 美国(弗吉尼亚):https://dashscope-us.aliyuncs.com/compatible-mode/v1
62 65  
63 66 DEEPL_API_URL = "https://api.deepl.com/v2/translate" # Pro tier
64   - QWEN_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1" # 北京地域
  67 + QWEN_BASE_URL = "https://dashscope-us.aliyuncs.com/compatible-mode/v1" # 北京地域
65 68 # QWEN_BASE_URL = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1" # 新加坡
66 69 # 如果使用新加坡地域的模型,需要将base_url替换为:https://dashscope-intl.aliyuncs.com/compatible-mode/v1
67 70 QWEN_MODEL = "qwen-mt-flash" # 快速翻译模型
... ...
scripts/create_tenant_index.sh
... ... @@ -61,6 +61,7 @@ echo
61 61 echo "删除索引: $ES_INDEX"
62 62 echo
63 63 curl -X DELETE "${ES_HOST}/${ES_INDEX}" $AUTH_PARAM -s -o /dev/null -w "HTTP状态码: %{http_code}\n"
  64 +
64 65 echo
65 66 echo "创建索引: $ES_INDEX"
66 67 echo
... ...
scripts/service_ctl.sh
... ... @@ -138,7 +138,11 @@ start_one() {
138 138 case "${service}" in
139 139 clip|cnclip)
140 140 echo "[start] ${service} (managed by native script)"
141   - bash -lc "${cmd}" >> "${lf}" 2>&1 || true
  141 + if [ "${service}" = "cnclip" ]; then
  142 + CNCLIP_DEVICE="${CNCLIP_DEVICE:-cuda}" bash -lc "${cmd}" >> "${lf}" 2>&1 || true
  143 + else
  144 + bash -lc "${cmd}" >> "${lf}" 2>&1 || true
  145 + fi
142 146 if is_running_by_pid "${service}" || is_running_by_port "${service}"; then
143 147 echo "[ok] ${service} started (log=${lf})"
144 148 else
... ... @@ -272,6 +276,7 @@ Default target set (when no service provided):
272 276  
273 277 Optional startup flags:
274 278 START_EMBEDDING=1 START_TRANSLATOR=1 START_RERANKER=1 ./run.sh
  279 + CNCLIP_DEVICE=cuda|cpu ./scripts/service_ctl.sh start cnclip
275 280 EOF
276 281 }
277 282  
... ...
scripts/setup_cnclip_venv.sh 0 → 100755
... ... @@ -0,0 +1,85 @@
  1 +#!/bin/bash
  2 +#
  3 +# 创建 CN-CLIP 服务专用虚拟环境(.venv-cnclip),用于隔离 clip-server 及其依赖
  4 +#(如 grpcio、jina、docarray 等),避免与主项目 .venv 的依赖冲突或构建失败。
  5 +#
  6 +# 使用方式:
  7 +# ./scripts/setup_cnclip_venv.sh
  8 +#
  9 +# 完成后,start_cnclip_service.sh 会自动优先使用 .venv-cnclip(若存在)。
  10 +#
  11 +set -e
  12 +
  13 +PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
  14 +VENV_DIR="${PROJECT_ROOT}/.venv-cnclip"
  15 +CLIP_SERVER="${PROJECT_ROOT}/third-party/clip-as-service/server"
  16 +
  17 +echo "=========================================="
  18 +echo "CN-CLIP 专用环境 (.venv-cnclip)"
  19 +echo "=========================================="
  20 +echo ""
  21 +
  22 +if [ ! -d "${CLIP_SERVER}" ]; then
  23 + echo "错误: 未找到 clip-as-service 服务目录: ${CLIP_SERVER}" >&2
  24 + exit 1
  25 +fi
  26 +
  27 +# 使用系统或当前默认的 python3 创建 venv(不依赖主项目 .venv)
  28 +if [ -d "${VENV_DIR}" ]; then
  29 + echo "已存在 .venv-cnclip,将复用并更新依赖。"
  30 +else
  31 + echo "创建虚拟环境: ${VENV_DIR}"
  32 + python3 -m venv "${VENV_DIR}"
  33 +fi
  34 +
  35 +# 激活并升级基础工具。固定 setuptools<82,因 82 移除了 pkg_resources,而 jina/hubble 仍依赖它。
  36 +"${VENV_DIR}/bin/pip" install --upgrade pip wheel
  37 +"${VENV_DIR}/bin/pip" install 'setuptools>=66,<82'
  38 +
  39 +# grpcio:jina 要求 <=1.68;1.57 在 Python 3.12 上无预编译 wheel(会触发源码构建与 pkg_resources 报错)。1.68.x 有 3.12 wheel,故先安装 1.68.x,避免后续安装 clip-server 时解析到 1.57。
  40 +echo "安装 grpcio(优先预编译 wheel,兼容 jina<=1.68)..."
  41 +if ! "${VENV_DIR}/bin/pip" install --only-binary=grpcio 'grpcio>=1.46.0,<=1.68.1' 2>/dev/null; then
  42 + echo "镜像无匹配 wheel,尝试 PyPI..."
  43 + if ! "${VENV_DIR}/bin/pip" install --only-binary=grpcio -i https://pypi.org/simple 'grpcio>=1.46.0,<=1.68.1' 2>/dev/null; then
  44 + echo "错误: 无法获取 grpcio 预编译包,请检查网络或使用 Python 3.10/3.11。" >&2
  45 + exit 1
  46 + fi
  47 +fi
  48 +
  49 +# 安装 docarray==0.21(clip-server 要求)
  50 +echo "安装 docarray(clip-server 要求 0.21)..."
  51 +"${VENV_DIR}/bin/pip" install 'docarray==0.21.0'
  52 +
  53 +# jina 3.27 声明 grpcio<=1.57,会触发源码构建;先装 grpcio 配套的 reflection/health 1.68(wheel),再以 --no-deps 装 jina,最后补齐 jina 的其余依赖(不包含 grpcio)
  54 +echo "安装 grpcio-reflection / grpcio-health-checking(与已装 grpcio 1.68 一致)..."
  55 +"${VENV_DIR}/bin/pip" install --only-binary=:all: 'grpcio-reflection>=1.46,<=1.68' 'grpcio-health-checking>=1.46,<=1.68' 2>/dev/null || "${VENV_DIR}/bin/pip" install 'grpcio-reflection>=1.46,<=1.68' 'grpcio-health-checking>=1.46,<=1.68'
  56 +echo "安装 jina(--no-deps,避免拉取 grpcio 1.57)..."
  57 +"${VENV_DIR}/bin/pip" install 'jina>=3.27,<3.28' --no-deps
  58 +# 补齐 jina 3.27 的运行时依赖(见 jina 的 install_requires,不含 grpcio)。
  59 +# 关键约束:
  60 +# - pydantic<2、opentelemetry-sdk<1.20、urllib3<2:与 jina 3.27 保持一致,减少 resolver 冲突告警
  61 +"${VENV_DIR}/bin/pip" install 'uvicorn[standard]<=0.23.1' 'fastapi>=0.76' 'protobuf>=3.19' 'pyyaml>=5.3' 'pydantic<2' 'prometheus_client>=0.12' 'aiofiles' 'opentelemetry-api>=1.12,<1.20' 'opentelemetry-sdk>=1.14,<1.20' 'opentelemetry-exporter-otlp>=1.12,<1.20' 'opentelemetry-instrumentation-grpc>=0.35' 'opentelemetry-instrumentation-fastapi>=0.33' 'opentelemetry-instrumentation-aiohttp-client>=0.33' 'opentelemetry-exporter-prometheus>=0.33b0' 'websockets' 'python-multipart' 'urllib3<2'
  62 +
  63 +# 安装 CN-CLIP
  64 +echo "安装 cn-clip..."
  65 +"${VENV_DIR}/bin/pip" install cn-clip
  66 +
  67 +# 安装 clip-server 以 --no-deps 方式,避免因 docarray==0.21 解析到旧 jina 并拉取 grpcio 1.57 源码构建。依赖由前面已装的 jina/grpcio/cn-clip 与下面显式安装补齐。
  68 +echo "安装 clip-server[cn_clip](--no-deps,再补齐依赖)..."
  69 +"${VENV_DIR}/bin/pip" install -e "${CLIP_SERVER}[cn_clip]" --no-deps
  70 +# clip-server 的 install_requires:ftfy, torch, regex, torchvision, jina, docarray, prometheus-client, open_clip_torch, pillow-avif-plugin;jina/cn_clip 已装;补齐 ftfy regex open_clip_torch pillow-avif-plugin(torch/torchvision 由 cn-clip 带入,prometheus_client 由 jina 带入)
  71 +"${VENV_DIR}/bin/pip" install 'ftfy' 'regex' 'open_clip_torch>=2.8.0,<2.9.0' 'pillow-avif-plugin'
  72 +# grpc_health 需要 protobuf 含 runtime_version(>=4);open_clip_torch 会拉低到 3.20,此处再升级
  73 +"${VENV_DIR}/bin/pip" install 'protobuf>=4,<6'
  74 +# jina 3.27 声明的可选依赖,clip 服务需用到
  75 +"${VENV_DIR}/bin/pip" install 'jcloud>=0.0.35'
  76 +
  77 +echo ""
  78 +echo "=========================================="
  79 +echo "✓ .venv-cnclip 已就绪"
  80 +echo "=========================================="
  81 +echo "启动 CN-CLIP 服务时将自动使用此环境:"
  82 +echo " ./scripts/start_cnclip_service.sh"
  83 +echo "或:"
  84 +echo " ./scripts/service_ctl.sh start cnclip"
  85 +echo ""
... ...
scripts/start_clip_service.sh
... ... @@ -19,19 +19,15 @@ echo &quot;========================================&quot;
19 19 echo "Starting CLIP vector service (clip-server)"
20 20 echo "========================================"
21 21  
22   -# Load conda and activate dedicated environment, if available
23   -CONDA_ROOT="${CONDA_ROOT:-/home/tw/miniconda3}"
24   -if [ -f "$CONDA_ROOT/etc/profile.d/conda.sh" ]; then
25   - # shellcheck disable=SC1091
26   - source "$CONDA_ROOT/etc/profile.d/conda.sh"
27   - conda activate clip_service || {
28   - echo "Failed to activate conda env 'clip_service'. Please create it first." >&2
29   - echo "See CLIP_SERVICE_README.md for setup instructions." >&2
  22 +# Use project unified environment (same as activate.sh / service_ctl)
  23 +if [ -z "${VIRTUAL_ENV}" ] && [ -z "${CONDA_DEFAULT_ENV}" ]; then
  24 + if [ -f "$(pwd)/activate.sh" ]; then
  25 + # shellcheck source=activate.sh
  26 + source "$(pwd)/activate.sh"
  27 + else
  28 + echo "Error: activate.sh not found. Run from project root or source activate.sh first." >&2
30 29 exit 1
31   - }
32   -else
33   - echo "Warning: $CONDA_ROOT/etc/profile.d/conda.sh not found." >&2
34   - echo "Please activate the 'clip_service' environment manually before running this script." >&2
  30 + fi
35 31 fi
36 32  
37 33 if [ -f "${PID_FILE}" ]; then
... ...
scripts/start_cnclip_service.sh
... ... @@ -175,44 +175,55 @@ if lsof -Pi :${PORT} -sTCP:LISTEN -t &gt;/dev/null 2&gt;&amp;1; then
175 175 exit 1
176 176 fi
177 177  
178   -# 检查 conda 环境
179   -if [ -z "${CONDA_DEFAULT_ENV}" ] || [ "${CONDA_DEFAULT_ENV}" != "clip_service" ]; then
180   - echo -e "${YELLOW}警告: 当前未激活 clip_service 环境${NC}"
181   - echo -e "${YELLOW}正在激活环境...${NC}"
182   -
183   - CONDA_ROOT="${CONDA_ROOT:-/home/tw/miniconda3}"
184   - if [ -f "$CONDA_ROOT/etc/profile.d/conda.sh" ]; then
185   - source "$CONDA_ROOT/etc/profile.d/conda.sh"
186   - conda activate clip_service
187   - echo -e "${GREEN}✓ 环境已激活${NC}"
  178 +# 优先使用 CN-CLIP 专用环境(避免与主项目依赖冲突;需先运行 ./scripts/setup_cnclip_venv.sh)
  179 +# 若无 .venv-cnclip,则使用项目统一环境(activate.sh)
  180 +CNCLIP_VENV="${PROJECT_ROOT}/.venv-cnclip"
  181 +if [ -x "${CNCLIP_VENV}/bin/python" ]; then
  182 + export PATH="${CNCLIP_VENV}/bin:${PATH}"
  183 + export VIRTUAL_ENV="${CNCLIP_VENV}"
  184 + echo -e "${GREEN}✓ 使用 CN-CLIP 专用环境: .venv-cnclip${NC}"
  185 +elif [ -z "${VIRTUAL_ENV}" ] && [ -z "${CONDA_DEFAULT_ENV}" ]; then
  186 + echo -e "${BLUE}激活项目环境...${NC}"
  187 + if [ -f "${PROJECT_ROOT}/activate.sh" ]; then
  188 + # shellcheck source=../activate.sh
  189 + source "${PROJECT_ROOT}/activate.sh"
188 190 else
189   - echo -e "${RED}错误: 无法找到 conda 初始化脚本: $CONDA_ROOT/etc/profile.d/conda.sh${NC}"
  191 + echo -e "${RED}错误: 未找到 ${PROJECT_ROOT}/activate.sh${NC}"
  192 + echo -e "${YELLOW}建议先创建 CN-CLIP 专用环境: ./scripts/setup_cnclip_venv.sh${NC}"
190 193 exit 1
191 194 fi
  195 + echo -e "${GREEN}✓ 使用环境: ${VIRTUAL_ENV:-${CONDA_DEFAULT_ENV:-unknown}}${NC}"
192 196 else
193   - echo -e "${GREEN}✓ Conda 环境: ${CONDA_DEFAULT_ENV}${NC}"
  197 + echo -e "${GREEN}✓ 使用当前环境: ${VIRTUAL_ENV:-${CONDA_DEFAULT_ENV:-unknown}}${NC}"
194 198 fi
195 199  
196   -# 检查 Python 依赖
  200 +# 检查 Python 依赖(CN-CLIP 服务端需要 cn_clip 与 clip_server)
197 201 echo -e "${BLUE}检查 Python 依赖...${NC}"
198 202 python -c "import cn_clip" 2>/dev/null || {
199 203 echo -e "${RED}错误: cn_clip 未安装${NC}"
200   - echo -e "${YELLOW}请运行: pip install cn-clip${NC}"
  204 + echo -e "${YELLOW}在项目环境中安装: pip install cn-clip 或 pip install -r requirements_ml.txt${NC}"
201 205 exit 1
202 206 }
203 207  
204   -python -c "from clip_client import Client" 2>/dev/null || {
205   - echo -e "${RED}错误: clip_client 未安装${NC}"
206   - echo -e "${YELLOW}请运行: pip install clip-client${NC}"
  208 +# clip_server 通过 PYTHONPATH 加载(见下方启动命令),此处仅做可导入性检查
  209 +export PYTHONPATH="${CLIP_SERVER_DIR}:${PYTHONPATH}"
  210 +python -c "import clip_server" 2>/dev/null || {
  211 + echo -e "${RED}错误: clip_server 不可用${NC}"
  212 + echo -e "${YELLOW}推荐使用专用环境(避免与主项目依赖冲突):${NC}"
  213 + echo -e "${YELLOW} ./scripts/setup_cnclip_venv.sh${NC}"
  214 + echo -e "${YELLOW}或在当前环境中安装: pip install -e third-party/clip-as-service/server[cn_clip]${NC}"
207 215 exit 1
208 216 }
209 217  
210   -echo -e "${GREEN}✓ 所有依赖已安装${NC}"
  218 +echo -e "${GREEN}✓ 所有依赖已就绪${NC}"
211 219 echo ""
212 220  
213   -# 自动检测设备
  221 +# 自动检测设备(可通过环境变量 CNCLIP_DEVICE 指定,供 service_ctl/restart 使用)
214 222 if [ "${DEVICE}" == "auto" ]; then
215   - if command -v nvidia-smi &> /dev/null && nvidia-smi &> /dev/null; then
  223 + if [ -n "${CNCLIP_DEVICE:-}" ]; then
  224 + DEVICE="${CNCLIP_DEVICE}"
  225 + echo -e "${GREEN}✓ 设备: ${DEVICE}(来自 CNCLIP_DEVICE)${NC}"
  226 + elif command -v nvidia-smi &> /dev/null && nvidia-smi &> /dev/null; then
216 227 DEVICE="cuda"
217 228 echo -e "${GREEN}✓ 检测到 NVIDIA GPU,使用 CUDA${NC}"
218 229 else
... ... @@ -263,7 +274,7 @@ if [ -f &quot;${FLOW_FILE}&quot; ] &amp;&amp; [ ! -f &quot;${FLOW_FILE}.original&quot; ]; then
263 274 echo -e "${YELLOW}已备份原配置文件: ${FLOW_FILE}.original${NC}"
264 275 fi
265 276  
266   -# 生成新的配置文件(使用官方默认配置,只指定模型名称
  277 +# 生成新的配置文件(使用官方默认配置,显式传入 device 以便使用 GPU
267 278 cat > "${TEMP_FLOW_FILE}" << EOF
268 279 jtype: Flow
269 280 version: '1'
... ... @@ -275,6 +286,7 @@ executors:
275 286 jtype: CLIPEncoder
276 287 with:
277 288 name: '${MODEL_NAME}'
  289 + device: '${DEVICE}'
278 290 metas:
279 291 py_modules:
280 292 - clip_server.executors.clip_torch
... ...