Commit 5bac964944e8cd54c86dbb9a8713476fba74c50c

Authored by tangwang
1 parent 7214c2e7

文本 embedding 与图片 embedding 已拆分为两个独立进程 / 端口

docs/CNCLIP_SERVICE说明文档.md
... ... @@ -5,7 +5,7 @@
5 5 ## 1. 作用与边界
6 6  
7 7 - `cnclip` 是独立 gRPC 服务(默认 `grpc://127.0.0.1:51000`)。
8   -- `embedding` 服务(6005)在 `USE_CLIP_AS_SERVICE=true` 时调用它完成 `/embed/image`。
  8 +- 图片 embedding 服务(默认 `6008`)在 `USE_CLIP_AS_SERVICE=true` 时调用它完成 `/embed/image`。
9 9 - `cnclip` 不负责文本向量;文本向量由 TEI(8080)负责。
10 10  
11 11 ## 2. 代码与脚本入口
... ... @@ -90,7 +90,7 @@ CNCLIP_MODEL_NAME=CN-CLIP/ViT-H-14 ./scripts/service_ctl.sh start cnclip
90 90  
91 91 ```bash
92 92 ./scripts/service_ctl.sh start cnclip
93   -# 或一次启动可选能力:./scripts/service_ctl.sh start embedding tei cnclip
  93 +# 或一次启动可选能力:./scripts/service_ctl.sh start embedding embedding-image tei cnclip
94 94 ```
95 95  
96 96 ### 6.2 设备选择优先级
... ... @@ -162,9 +162,9 @@ GPU 模式下应出现 `clip_server` 相关 `python` 进程及显存占用。
162 162 ### 7.5 与 embedding 服务联调
163 163  
164 164 ```bash
165   -./scripts/start_embedding_service.sh
  165 +./scripts/start_embedding_image_service.sh
166 166  
167   -curl -sS -X POST "http://127.0.0.1:6005/embed/image" \
  167 +curl -sS -X POST "http://127.0.0.1:6008/embed/image" \
168 168 -H "Content-Type: application/json" \
169 169 -d '["https://oss.essa.cn/98532128-cf8e-456c-9e30-6f2a5ea0c19f.jpg"]'
170 170 ```
... ...
docs/DEVELOPER_GUIDE.md
... ... @@ -92,7 +92,8 @@ MySQL (店匠 SPU/SKU)
92 92 | backend | 6002 | 搜索 API(含 admin) | ✓ |
93 93 | indexer | 6004 | 索引 API(reindex/build-docs 等) | ✓ |
94 94 | frontend | 6003 | 调试 UI | ✓ |
95   -| embedding | 6005 | 向量服务(文本/图片) | 可选 |
  95 +| embedding | 6005 | 文本向量服务 | 可选 |
  96 +| embedding-image | 6008 | 图片向量服务 | 可选 |
96 97 | translator | 6006 | 翻译服务(`POST /translate` 支持单条或批量 list;批量失败用 `null` 占位) | 可选 |
97 98 | reranker | 6007 | 重排服务 | 可选 |
98 99  
... ... @@ -156,8 +157,8 @@ docs/ # 文档(含本指南)
156 157  
157 158 ### 4.6 embeddings
158 159  
159   -- **职责**:提供向量服务(FastAPI):`POST /embed/text`、`POST /embed/image`;服务内按配置加载文本后端(如 Qwen3-Embedding-0.6B)与图像后端(如 clip-as-service 或本地 CN-CLIP),实现协议即可插拔。
160   -- **原则**:图片后端实现 `embeddings/protocols.ImageEncoderProtocol`;文本后端与参数统一从 `config/config.yaml -> services.embedding.backend(s)` 读取。
  160 +- **职责**:提供向量服务(FastAPI):文本服务默认监听 `6005`,图片服务默认监听 `6008`;对外暴露 `POST /embed/text`、`POST /embed/image`、`GET /health`、`GET /ready`;服务内按配置加载文本后端(如 Qwen3-Embedding-0.6B)与图像后端(如 clip-as-service 或本地 CN-CLIP),实现协议即可插拔。
  161 +- **原则**:图片后端实现 `embeddings/protocols.ImageEncoderProtocol`;文本后端与参数统一从 `config/config.yaml -> services.embedding.backend(s)` 读取;文本与图片流量应通过独立进程和独立 inflight limit 做隔离。
161 162 - **详见**:`embeddings/README.md`、本指南 §7.5–§7.6。
162 163  
163 164 ### 4.7 reranker
... ...
docs/QUICKSTART.md
... ... @@ -57,7 +57,8 @@ source activate.sh
57 57 | backend | 6002 | ✓ | 搜索 API(`/search/*`)+ 管理接口(`/admin/*`) |
58 58 | indexer | 6004 | ✓ | 索引 API(`/indexer/*`) |
59 59 | frontend | 6003 | ✓ | 调试 UI |
60   -| embedding | 6005 | - | 向量服务(`/embed/text`, `/embed/image`) |
  60 +| embedding | 6005 | - | 文本向量服务(`/embed/text`) |
  61 +| embedding-image | 6008 | - | 图片向量服务(`/embed/image`) |
61 62 | translator | 6006 | - | 翻译服务(`/translate`) |
62 63 | reranker | 6007 | - | 重排服务(`/rerank`) |
63 64  
... ... @@ -135,21 +136,22 @@ curl -X POST http://localhost:6004/indexer/build-docs \
135 136  
136 137 API 文档:`http://localhost:6004/docs`
137 138  
138   -#### Embedding 服务(6005
  139 +#### Embedding 服务(文本 6005 / 图片 6008
139 140  
140 141 ```bash
141 142 # TEI(文本向量后端,默认)
142 143 # GPU(需 nvidia-container-toolkit)
143 144 TEI_DEVICE=cuda ./scripts/start_tei_service.sh
144 145  
145   -# Embedding API(会校验 TEI /health)
146   -./scripts/start_embedding_service.sh
  146 +# Embedding API(text 会校验 TEI /health)
  147 +./scripts/start_embedding_text_service.sh
  148 +./scripts/start_embedding_image_service.sh
147 149  
148 150 curl -X POST http://localhost:6005/embed/text \
149 151 -H "Content-Type: application/json" \
150 152 -d '["衣服", "Bohemian Maxi Dress"]'
151 153  
152   -curl -X POST http://localhost:6005/embed/image \
  154 +curl -X POST http://localhost:6008/embed/image \
153 155 -H "Content-Type: application/json" \
154 156 -d '["https://example.com/img.jpg"]'
155 157 ```
... ... @@ -157,7 +159,7 @@ curl -X POST http://localhost:6005/embed/image \
157 159 说明:
158 160 - TEI 默认镜像按 `TEI_VERSION` 组装:`cuda-<version>`(默认 `1.9`)。
159 161 - `TEI_DEVICE=cuda` 时会严格校验 Docker GPU runtime;未配置会直接报错退出。
160   -- `/embed/image` 依赖 `cnclip`(`grpc://127.0.0.1:51000`),未启动时 embedding 服务会启动失败。
  162 +- `/embed/image` 依赖 `cnclip`(`grpc://127.0.0.1:51000`),未启动时图片 embedding 服务会启动失败。
161 163  
162 164 #### Translator 服务(6006)
163 165  
... ... @@ -530,6 +532,7 @@ curl http://localhost:6002/health
530 532 curl http://localhost:6002/admin/health
531 533 curl http://localhost:6004/health
532 534 curl http://localhost:6005/health
  535 +curl http://localhost:6008/health
533 536 curl http://localhost:6006/health
534 537 curl http://localhost:6007/health
535 538 ```
... ...
docs/TEI_SERVICE说明文档.md
... ... @@ -5,7 +5,7 @@
5 5 ## 1. 作用与边界
6 6  
7 7 - TEI 提供文本向量 HTTP 服务(默认 `http://127.0.0.1:8080`)。
8   -- 本项目中 `embedding` 服务(6005)默认把文本向量请求转发到 TEI。
  8 +- 本项目中文本 embedding 服务(默认 `6005`)把文本向量请求转发到 TEI。
9 9 - TEI 仅负责文本向量,不负责图片向量(图片向量由 `cnclip` 提供)。
10 10  
11 11 ## 2. 代码与脚本入口
... ... @@ -13,7 +13,7 @@
13 13 - 启动脚本:`scripts/start_tei_service.sh`
14 14 - 停止脚本:`scripts/stop_tei_service.sh`
15 15 - 统一编排:`scripts/service_ctl.sh`
16   -- embedding 服务启动脚本(会校验 TEI 健康):`scripts/start_embedding_service.sh`
  16 +- 文本 embedding 服务启动脚本(会校验 TEI 健康):`scripts/start_embedding_text_service.sh`
17 17  
18 18 ## 3. 前置条件
19 19  
... ... @@ -117,7 +117,7 @@ curl -sS http://127.0.0.1:8080/embed \
117 117 ### 6.3 与 embedding 服务联调
118 118  
119 119 ```bash
120   -./scripts/start_embedding_service.sh
  120 +./scripts/start_embedding_text_service.sh
121 121  
122 122 curl -sS -X POST "http://127.0.0.1:6005/embed/text" \
123 123 -H "Content-Type: application/json" \
... ... @@ -152,7 +152,7 @@ curl -sS -X POST &quot;http://127.0.0.1:6005/embed/text&quot; \
152 152 启动全套(含 TEI):
153 153  
154 154 ```bash
155   -TEI_DEVICE=cuda ./scripts/service_ctl.sh start tei cnclip embedding translator reranker
  155 +TEI_DEVICE=cuda ./scripts/service_ctl.sh start tei cnclip embedding embedding-image translator reranker
156 156 ```
157 157  
158 158 仅启动 TEI:
... ...
docs/Usage-Guide.md
... ... @@ -335,7 +335,8 @@ python -m http.server 6003
335 335 | Backend API | 6002 | http://localhost:6002 |
336 336 | Indexer API | 6004 | http://localhost:6004 |
337 337 | Frontend Web | 6003 | http://localhost:6003 |
338   -| Embedding (optional) | 6005 | http://localhost:6005 |
  338 +| Embedding Text (optional) | 6005 | http://localhost:6005 |
  339 +| Embedding Image (optional) | 6008 | http://localhost:6008 |
339 340 | TEI (optional) | 8080 | http://localhost:8080 |
340 341 | Translation (optional) | 6006 | http://localhost:6006 |
341 342 | Reranker (optional) | 6007 | http://localhost:6007 |
... ... @@ -380,7 +381,8 @@ INDEXER_PORT=6004
380 381  
381 382 # Optional service ports
382 383 FRONTEND_PORT=6003
383   -EMBEDDING_PORT=6005
  384 +EMBEDDING_TEXT_PORT=6005
  385 +EMBEDDING_IMAGE_PORT=6008
384 386 TEI_PORT=8080
385 387 CNCLIP_PORT=51000
386 388 TRANSLATION_PORT=6006
... ...
docs/向量化模块和API说明文档.md
... ... @@ -4,12 +4,36 @@
4 4  
5 5 ## 服务接口
6 6  
7   -- `POST /embed/text`:文本向量,入参 `["text1", "text2"]`,出参 `[[...], [...]]`
8   -- `POST /embed/image`:图片向量,入参 `["url1", "url2"]`,出参 `[[...], [...]]`
  7 +- 文本服务:`POST http://localhost:6005/embed/text`
  8 +- 图片服务:`POST http://localhost:6008/embed/image`
  9 +- 健康检查:`GET /health`
  10 +- 就绪检查:`GET /ready`
  11 +
  12 +## 当前架构
  13 +
  14 +- 文本 embedding 与图片 embedding 已拆分为两个独立进程 / 端口:
  15 + - text: `6005`
  16 + - image: `6008`
  17 +- 两侧有独立并发控制:
  18 + - `TEXT_MAX_INFLIGHT`
  19 + - `IMAGE_MAX_INFLIGHT`
  20 +- 两侧都接入 Redis 向量缓存,value 统一使用 BF16 bytes 存储。
  21 +
  22 +## 缓存
  23 +
  24 +- 当前是双层缓存:
  25 + - 调用侧 client 先查 Redis
  26 + - 服务侧收到请求后再查 Redis
  27 +- 当前主 key 规则:
  28 + - 文本:`embedding:embed:norm{0|1}:{text}`
  29 + - 图片:`embedding:image:embed:norm{0|1}:{url_or_path}`
  30 +- full-cache-hit 时,服务会直接返回,不占用模型 lane。
9 31  
10 32 ## 配置
11 33  
12 34 - Provider/URL:`config/config.yaml` 的 `services.embedding`
  35 +- 文本服务 URL:`services.embedding.providers.http.text_base_url`
  36 +- 图片服务 URL:`services.embedding.providers.http.image_base_url`
13 37 - 文本模型:`embeddings/config.py` 的 `TEXT_MODEL_ID`(默认 `Qwen/Qwen3-Embedding-0.6B`)
14 38 - 运行参数:`TEXT_DEVICE`、`TEXT_BATCH_SIZE`、`TEXT_NORMALIZE_EMBEDDINGS`
15 39  
... ...
docs/工作总结-微服务性能优化与架构.md
... ... @@ -16,12 +16,12 @@
16 16 | **vLLM** | 高吞吐推理框架,更适合生成式与重排混合场景 | 纯 embedding 场景通常不作为首选 |
17 17 | **TEI** | HuggingFace 官方 embedding 专用推理服务,Docker 部署 | **当前最优选型** |
18 18  
19   -**当前方案**:以 **TEI** 为文本向量后端,模型为 `Qwen/Qwen3-Embedding-0.6B`embedding 服务(端口 **6005**)将 `POST /embed/text` 请求转发至 TEI(默认端口 **8080**)。
  19 +**当前方案**:以 **TEI** 为文本向量后端,模型为 `Qwen/Qwen3-Embedding-0.6B`;文本 embedding 服务(端口 **6005**)将 `POST /embed/text` 请求转发至 TEI(默认端口 **8080**)。
20 20  
21 21 **具体配置与脚本**:
22 22 - **配置**:`config/config.yaml` → `services.embedding.backend: "tei"`,`services.embedding.backends.tei.base_url: "http://127.0.0.1:8080"`、`model_id: "Qwen/Qwen3-Embedding-0.6B"`、`timeout_sec: 20`。
23 23 - **启动**:`scripts/start_tei_service.sh`(Docker 容器);环境变量:`TEI_DEVICE=cuda`(默认)、`TEI_PORT=8080`、`TEI_MODEL_ID=Qwen/Qwen3-Embedding-0.6B`、`TEI_VERSION=1.9`、`TEI_MAX_BATCH_TOKENS=4096`、`TEI_MAX_CLIENT_BATCH_SIZE=24`、`TEI_DTYPE=float16`;T4 自动选镜像 `ghcr.io/huggingface/text-embeddings-inference:turing-1.9`。
24   -- **编排**:`./scripts/service_ctl.sh start tei` 或 `start embedding`(embedding 会校验 TEI `/health` 后再就绪)。
  24 +- **编排**:`./scripts/service_ctl.sh start tei` 或 `start embedding`(text embedding 会校验 TEI `/health` 后再就绪)。
25 25  
26 26 **工程化收益**:
27 27 - **独立服务**:TEI 以 Docker 容器运行,与主程序 `.venv` 解耦;embedding 使用 `.venv-embedding`,便于独立扩缩容与升级。
... ... @@ -71,7 +71,7 @@ instruction: &quot;Given a shopping query, rank product titles by relevance&quot;
71 71  
72 72 **具体内容**:
73 73 - **端口**:clip-as-service 默认 **51000**(`CNCLIP_PORT`);文本走 TEI(8080),图片走 clip-as-service。
74   -- **API**:embedding 服务(6005)统一暴露 `POST /embed/text` 与 `POST /embed/image`;图片请求由 `embeddings/server.py` 按配置调用实现 `ImageEncoderProtocol` 的后端(clip-as-service 或本地 CN-CLIP)。
  74 +- **API**:文本 embedding 服务默认暴露 `POST /embed/text`(6005),图片 embedding 服务默认暴露 `POST /embed/image`(6008);图片请求由 `embeddings/server.py` 按配置调用实现 `ImageEncoderProtocol` 的后端(clip-as-service 或本地 CN-CLIP)。
75 75 - **环境与启停**:CN-CLIP 使用独立虚拟环境 `.venv-cnclip`;启动 `scripts/start_cnclip_service.sh`,或 `./scripts/service_ctl.sh start cnclip`;设备可通过 `CNCLIP_DEVICE=cuda`(默认)或 `cpu` 指定。
76 76 - **配置**:图片后端在 `config/config.yaml` 的 `services.embedding` 下配置(若存在 image 相关 backend);clip-as-service 默认模型由 `embeddings/config.py` 的 `CLIP_AS_SERVICE_MODEL_NAME` 控制,flow 配置在 `third-party/clip-as-service/server/torch-flow-temp.yml`。
77 77  
... ... @@ -127,8 +127,8 @@ instruction: &quot;Given a shopping query, rank product titles by relevance&quot;
127 127 - **脚本**:`scripts/service_ctl.sh` 统一负责各服务的生命周期与监控;依赖 `scripts/lib/load_env.sh` 与项目根目录 `.env`。
128 128 - **服务与端口**:
129 129 - 核心:backend **6002**、indexer **6004**、frontend **6003**。
130   - - 可选:embedding **6005**、translator **6006**、reranker **6007**、tei **8080**、cnclip **51000**。
131   - - 端口可由环境变量覆盖:`API_PORT`、`INDEXER_PORT`、`FRONTEND_PORT`、`EMBEDDING_PORT`、`TRANSLATION_PORT`、`RERANKER_PORT`、`TEI_PORT`、`CNCLIP_PORT`。
  130 + - 可选:embedding(text) **6005**、embedding-image **6008**、translator **6006**、reranker **6007**、tei **8080**、cnclip **51000**。
  131 + - 端口可由环境变量覆盖:`API_PORT`、`INDEXER_PORT`、`FRONTEND_PORT`、`EMBEDDING_TEXT_PORT`、`EMBEDDING_IMAGE_PORT`、`TRANSLATION_PORT`、`RERANKER_PORT`、`TEI_PORT`、`CNCLIP_PORT`。
132 132 - **命令**:
133 133 - `./scripts/service_ctl.sh start [service...]` 或 `start all`(all = tei cnclip embedding translator reranker backend indexer frontend,按依赖顺序);`stop`、`restart` 同参数;`status` 默认列出所有服务。
134 134 - 启动时:backend/indexer/frontend/embedding/translator/reranker 会写 pid 到 `logs/<service>.pid`,并执行 `wait_for_health`(GET `http://127.0.0.1:<port>/health`);reranker 健康重试 90 次,其余 30 次;TEI 校验 Docker 容器存在且 `/health` 成功;cnclip 无 HTTP 健康则仅校验进程/端口。
... ...
docs/搜索API对接指南.md
... ... @@ -157,8 +157,8 @@ curl -X POST &quot;http://43.166.252.75:6002/search/&quot; \
157 157  
158 158 | 服务 | 端口 | 接口 | 说明 |
159 159 |------|------|------|------|
160   -| 向量服务 | 6005 | `POST /embed/text` | 文本向量化 |
161   -| 向量服务 | 6005 | `POST /embed/image` | 图片向量化 |
  160 +| 向量服务(文本) | 6005 | `POST /embed/text` | 文本向量化 |
  161 +| 向量服务(图片) | 6008 | `POST /embed/image` | 图片向量化 |
162 162 | 翻译服务 | 6006 | `POST /translate` | 文本翻译(支持 qwen-mt / llm / deepl / 本地模型) |
163 163 | 重排服务 | 6007 | `POST /rerank` | 检索结果重排 |
164 164 | 内容理解(Indexer 内) | 6004 | `POST /indexer/enrich-content` | 根据商品标题生成 qanchors、tags 等,供 indexer 微服务组合方式使用 |
... ... @@ -1691,7 +1691,8 @@ curl -X POST &quot;http://localhost:6004/indexer/enrich-content&quot; \
1691 1691  
1692 1692 | 服务 | 默认端口 | Base URL | 说明 |
1693 1693 |------|----------|----------|------|
1694   -| 向量服务 | 6005 | `http://localhost:6005` | 文本/图片向量化,用于语义搜索与以图搜图 |
  1694 +| 向量服务(文本) | 6005 | `http://localhost:6005` | 文本向量化,用于 query/doc 语义检索 |
  1695 +| 向量服务(图片) | 6008 | `http://localhost:6008` | 图片向量化,用于以图搜图 |
1695 1696 | 翻译服务 | 6006 | `http://localhost:6006` | 多语言翻译(云端与本地模型统一入口) |
1696 1697 | 重排服务 | 6007 | `http://localhost:6007` | 对检索结果进行二次排序 |
1697 1698  
... ... @@ -1700,13 +1701,28 @@ curl -X POST &quot;http://localhost:6004/indexer/enrich-content&quot; \
1700 1701  
1701 1702 ### 7.1 向量服务(Embedding)
1702 1703  
1703   -- **Base URL**: `http://localhost:6005`(可通过 `EMBEDDING_SERVICE_URL` 覆盖)
1704   -- **启动**: `./scripts/start_embedding_service.sh`
  1704 +- **Base URL**:
  1705 + - 文本:`http://localhost:6005`(可通过 `EMBEDDING_TEXT_SERVICE_URL` 覆盖)
  1706 + - 图片:`http://localhost:6008`(可通过 `EMBEDDING_IMAGE_SERVICE_URL` 覆盖)
  1707 +- **启动**:
  1708 + - 文本:`./scripts/start_embedding_text_service.sh`
  1709 + - 图片:`./scripts/start_embedding_image_service.sh`
  1710 + - 兼容入口:`./scripts/start_embedding_service.sh text|image|all`
1705 1711 - **依赖**:
1706 1712 - 文本向量后端默认走 TEI(`http://127.0.0.1:8080`)
1707 1713 - 图片向量依赖 `cnclip`(`grpc://127.0.0.1:51000`)
1708 1714 - TEI 默认使用 GPU(`TEI_DEVICE=cuda`);当配置为 GPU 且不可用时会启动失败(不会自动降级到 CPU)
1709 1715 - cnclip 默认使用 `cuda`;若配置为 `cuda` 但 GPU 不可用会启动失败(不会自动降级到 `cpu`)
  1716 + - 当前单机部署建议保持单实例,通过**文本/图片拆分 + 独立限流**隔离压力
  1717 +
  1718 +补充说明:
  1719 +
  1720 +- 文本和图片现在已经拆成**不同进程 / 不同端口**,避免图片下载与编码波动影响文本向量化。
  1721 +- 服务端对 text / image 有**独立 admission control**:
  1722 + - `TEXT_MAX_INFLIGHT`
  1723 + - `IMAGE_MAX_INFLIGHT`
  1724 +- 当超过处理能力时,服务会直接返回过载错误,而不是无限排队。
  1725 +- `GET /health` 会返回各自的 `limits`、`stats`、`cache_enabled` 等状态;`GET /ready` 用于就绪探针。
1710 1726  
1711 1727 #### 7.1.1 `POST /embed/text` — 文本向量化
1712 1728  
... ... @@ -1724,7 +1740,7 @@ curl -X POST &quot;http://localhost:6004/indexer/enrich-content&quot; \
1724 1740  
1725 1741 **完整 curl 示例**:
1726 1742 ```bash
1727   -curl -X POST "http://localhost:6005/embed/text" \
  1743 +curl -X POST "http://localhost:6005/embed/text?normalize=true" \
1728 1744 -H "Content-Type: application/json" \
1729 1745 -d '["芭比娃娃 儿童玩具", "纯棉T恤 短袖"]'
1730 1746 ```
... ... @@ -1733,7 +1749,7 @@ curl -X POST &quot;http://localhost:6005/embed/text&quot; \
1733 1749  
1734 1750 将图片 URL 或路径转为向量,用于以图搜图。
1735 1751  
1736   -前置条件:`cnclip` 服务已启动(默认端口 `51000`)。若未启动,`/embed/image` 会返回 500
  1752 +前置条件:`cnclip` 服务已启动(默认端口 `51000`)。若未启动,图片 embedding 服务启动会失败或请求返回错误
1737 1753  
1738 1754 **请求体**(JSON 数组):
1739 1755 ```json
... ... @@ -1747,7 +1763,7 @@ curl -X POST &quot;http://localhost:6005/embed/text&quot; \
1747 1763  
1748 1764 **完整 curl 示例**:
1749 1765 ```bash
1750   -curl -X POST "http://localhost:6005/embed/image" \
  1766 +curl -X POST "http://localhost:6008/embed/image?normalize=true" \
1751 1767 -H "Content-Type: application/json" \
1752 1768 -d '["https://oss.essa.cn/98532128-cf8e-456c-9e30-6f2a5ea0c19f.jpg"]'
1753 1769 ```
... ... @@ -1756,9 +1772,32 @@ curl -X POST &quot;http://localhost:6005/embed/image&quot; \
1756 1772  
1757 1773 ```bash
1758 1774 curl "http://localhost:6005/health"
  1775 +curl "http://localhost:6008/health"
  1776 +```
  1777 +
  1778 +返回中会包含:
  1779 +
  1780 +- `service_kind`:`text` / `image` / `all`
  1781 +- `cache_enabled`:text/image Redis 缓存是否可用
  1782 +- `limits`:当前 inflight limit、active、rejected_total 等
  1783 +- `stats`:request_total、cache_hits、cache_misses、avg_latency_ms 等
  1784 +
  1785 +#### 7.1.4 `GET /ready` — 就绪检查
  1786 +
  1787 +```bash
  1788 +curl "http://localhost:6005/ready"
  1789 +curl "http://localhost:6008/ready"
1759 1790 ```
1760 1791  
1761   -#### 7.1.4 TEI 统一调优建议(主服务)
  1792 +#### 7.1.5 缓存与限流说明
  1793 +
  1794 +- 文本与图片都会先查 Redis 向量缓存。
  1795 +- Redis 中 value 仍是 **BF16 bytes**,读取后恢复成 `float32` 返回。
  1796 +- cache key 已区分 `normalize=true/false`,避免不同归一化策略命中同一条缓存。
  1797 +- 当服务端发现请求是 **full-cache-hit** 时,会直接返回,不占用模型并发槽位。
  1798 +- 当服务端发现超过 `TEXT_MAX_INFLIGHT` / `IMAGE_MAX_INFLIGHT` 时,会直接拒绝,而不是无限排队。
  1799 +
  1800 +#### 7.1.6 TEI 统一调优建议(主服务)
1762 1801  
1763 1802 使用单套主服务即可同时兼顾:
1764 1803 - 在线 query 向量化(低延迟,常见 `batch=1~4`)
... ... @@ -1773,7 +1812,8 @@ curl &quot;http://localhost:6005/health&quot;
1773 1812  
1774 1813 默认端口:
1775 1814 - TEI: `http://127.0.0.1:8080`
1776   -- 向量服务(`/embed/text`): `http://127.0.0.1:6005`
  1815 +- 文本向量服务(`/embed/text`): `http://127.0.0.1:6005`
  1816 +- 图片向量服务(`/embed/image`): `http://127.0.0.1:6008`
1777 1817  
1778 1818 当前主 TEI 启动默认值(已按 T4/短文本场景调优):
1779 1819 - `TEI_MAX_BATCH_TOKENS=4096`
... ...
docs/缓存与Redis使用说明.md
... ... @@ -19,7 +19,7 @@
19 19  
20 20 | 模块 / 场景 | Key 模板 | Value 内容示例 | 过期策略 | 备注 |
21 21 |------------|----------|----------------|----------|------|
22   -| 向量缓存(text/image embedding) | `{EMBEDDING_CACHE_PREFIX}:{query_or_url}` / `{EMBEDDING_CACHE_PREFIX}:image:{url_or_path}` | **BF16 bytes**(每维 2 字节大端存储),读取后恢复为 `np.float32` | TTL=`REDIS_CONFIG["cache_expire_days"]` 天;访问时滑动过期 | 见 `embeddings/text_encoder.py`(文本)与 `embeddings/image_encoder.py`(图片);前缀由 `REDIS_CONFIG["embedding_cache_prefix"]` 控制 |
  22 +| 向量缓存(text/image embedding) | 文本:`{EMBEDDING_CACHE_PREFIX}:embed:norm{0|1}:{text}`;图片:`{EMBEDDING_CACHE_PREFIX}:image:embed:norm{0|1}:{url_or_path}` | **BF16 bytes**(每维 2 字节大端存储),读取后恢复为 `np.float32` | TTL=`REDIS_CONFIG["cache_expire_days"]` 天;访问时滑动过期 | 见 `embeddings/text_encoder.py`、`embeddings/image_encoder.py`、`embeddings/server.py`;前缀由 `REDIS_CONFIG["embedding_cache_prefix"]` 控制 |
23 23 | 翻译结果缓存(translator service) | `trans:{model}:{target_lang}:{source_text[:4]}{sha256(source_text)}` | 机翻后的单条字符串 | TTL=`services.translation.cache.ttl_seconds` 秒;可配置滑动过期 | 见 `translation/service.py` + `config/config.yaml` |
24 24 | 商品内容理解缓存(anchors / 语义属性 / tags) | `{ANCHOR_CACHE_PREFIX}:{tenant_or_global}:{target_lang}:{md5(title)}` | `json.dumps(dict)`,包含 id/title/category/tags/anchor_text 等 | TTL=`ANCHOR_CACHE_EXPIRE_DAYS` 天 | 见 `indexer/product_enrich.py` |
25 25  
... ... @@ -27,23 +27,49 @@
27 27  
28 28 ---
29 29  
30   -## 2. 文本向量缓存(embeddings/text_encoder.py)
  30 +## 2. 向量缓存(embeddings/text_encoder.py / embeddings/image_encoder.py / embeddings/server.py)
31 31  
32   -- **代码位置**:`embeddings/text_encoder.py` 中 `TextEmbeddingEncoder`
33   -- **用途**:缓存调用向量服务(6005)的文本向量结果,避免重复计算。
  32 +- **代码位置**:
  33 + - 调用侧:`embeddings/text_encoder.py`、`embeddings/image_encoder.py`
  34 + - 服务侧:`embeddings/server.py`
  35 + - 公共 Redis/BF16 编解码:`embeddings/redis_embedding_cache.py`、`embeddings/bf16.py`
  36 +- **用途**:缓存文本/图片向量结果,避免重复推理、重复下载图片、重复占用模型并发槽位。
  37 +
  38 +### 2.0 当前缓存链路总览
  39 +
  40 +- 现在是**双层缓存**:
  41 + - **调用侧缓存**:`TextEmbeddingEncoder` / `CLIPImageEncoder` 在发 HTTP 请求前先查 Redis;
  42 + - **服务侧缓存**:`/embed/text` / `/embed/image` 在进入后端推理前再查 Redis。
  43 +- 这次改动里,**BF16 存储格式本身没有变化**:
  44 + - 写入:`float32 -> BF16 -> bytes -> Redis`
  45 + - 读取:`Redis bytes -> BF16 -> float32(np.float32)`
  46 +- 这次新增的核心变化是:
  47 + - **缓存 key 加入 normalize 维度**,避免 `normalize=true` 与 `normalize=false` 命中同一条缓存;
  48 + - **服务端也启用同一套 Redis 向量缓存**,而不是只有 client 侧缓存;
  49 + - **拆分 text / image 服务** 后,图片压力不会再拖慢文本服务;
  50 + - **服务侧 full-cache-hit 可直接返回**,不会再进入模型限流槽位。
34 51  
35 52 ### 2.1 Key 设计
36 53  
37   -- 函数:`_get_cache_key(query: str, normalize_embeddings: bool) -> str`
  54 +- 统一 helper:`embeddings/cache_keys.py`
  55 +- 文本主 key:`build_text_cache_key(text, normalize=...)`
  56 +- 图片主 key:`build_image_cache_key(url, normalize=...)`
38 57 - 模板:
39 58  
40 59 ```text
41   -{EMBEDDING_CACHE_PREFIX}:{query}
  60 +文本: {EMBEDDING_CACHE_PREFIX}:embed:norm{0|1}:{text}
  61 +图片: {EMBEDDING_CACHE_PREFIX}:image:embed:norm{0|1}:{url_or_path}
42 62 ```
43 63  
44 64 - 字段说明:
45 65 - `EMBEDDING_CACHE_PREFIX`:来自 `REDIS_CONFIG["embedding_cache_prefix"]`,默认值为 `"embedding"`,可通过环境变量 `REDIS_EMBEDDING_CACHE_PREFIX` 覆盖;
46   - - `query`:原始文本(未做哈希),注意长度特别长的 query 会直接出现在 key 中。
  66 + - `norm1` / `norm0`:分别表示 `normalize=true` / `normalize=false`;
  67 + - `text` / `url_or_path`:当前仍直接使用规范化后的原始输入,不做哈希。
  68 +
  69 +补充说明:
  70 +
  71 +- 本次把 raw key 格式统一成 `embed:norm{0|1}:...`,比以 `norm:` 开头更清晰,也更接近历史命名习惯。
  72 +- 当前实现**不再兼容历史 key 协议**,只保留这一套主 key 规则,以降低运行时复杂度和歧义。
47 73  
48 74 ### 2.2 Value 与类型
49 75  
... ... @@ -68,6 +94,23 @@
68 94 - 直接丢弃该缓存(并尝试 `delete` key);
69 95 - 回退为重新调用向量服务。
70 96  
  97 +### 2.5 服务拆分与缓存/限流关系
  98 +
  99 +- 当前默认部署形态:
  100 + - 文本 embedding 服务:`6005`
  101 + - 图片 embedding 服务:`6008`
  102 +- 调用方配置:
  103 + - `EMBEDDING_TEXT_SERVICE_URL`
  104 + - `EMBEDDING_IMAGE_SERVICE_URL`
  105 +- 服务端配置:
  106 + - `TEXT_MAX_INFLIGHT`
  107 + - `IMAGE_MAX_INFLIGHT`
  108 + - `EMBEDDING_SERVICE_KIND=all|text|image`
  109 +- 关键行为:
  110 + - **full-cache-hit** 请求在服务端会直接返回,不占用 `TEXT_MAX_INFLIGHT` / `IMAGE_MAX_INFLIGHT`;
  111 + - **cache miss** 才会进入对应模型 lane;
  112 + - 因此高频重复图片请求不会再因为严格的图片限流而大量阻塞。
  113 +
71 114 ---
72 115  
73 116 ## 3. 翻译结果缓存(translation/service.py)
... ...
embeddings/README.md
... ... @@ -16,7 +16,7 @@
16 16 - **统一配置**:`config.py`
17 17 - **接口契约**:`protocols.ImageEncoderProtocol`(图片编码统一为 `encode_image_urls(urls, batch_size, normalize_embeddings)`,本地 CN-CLIP 与 clip-as-service 均实现该接口)
18 18  
19   -说明:历史上的云端 embedding 试验实现(DashScope)已从主仓库移除,当前仅维护 6005 这条统一向量服务链路
  19 +说明:历史上的云端 embedding 试验实现(DashScope)已从主仓库移除。当前默认部署为**文本服务 6005** 与**图片服务 6008** 两条链路,也兼容 `all` 模式单进程启动
20 20  
21 21 ### 文本向量后端(默认)
22 22  
... ... @@ -27,6 +27,15 @@
27 27  
28 28 ### 服务接口
29 29  
  30 +- 文本服务(默认 `6005`)
  31 + - `POST /embed/text`
  32 + - `GET /health`
  33 + - `GET /ready`
  34 +- 图片服务(默认 `6008`)
  35 + - `POST /embed/image`
  36 + - `GET /health`
  37 + - `GET /ready`
  38 +
30 39 - `POST /embed/text`
31 40 - 入参:`["文本1", "文本2", ...]`
32 41 - 可选 query 参数:`normalize=true|false`(不传则使用服务端默认)
... ... @@ -37,6 +46,28 @@
37 46 - 可选 query 参数:`normalize=true|false`(不传则使用服务端默认)
38 47 - 出参:`[[...], [...], ...]`(与输入按 index 对齐,失败直接报错)
39 48  
  49 +### Redis 向量缓存
  50 +
  51 +- Value 格式没有变化,仍然是 **BF16 bytes**:
  52 + - 写入:`float32 -> BF16 -> bytes`
  53 + - 读取:`bytes -> BF16 -> float32`
  54 +- 现在是**双层缓存**:
  55 + - client 侧:`text_encoder.py` / `image_encoder.py`
  56 + - service 侧:`server.py`
  57 +- 当前主 key 格式:
  58 + - 文本:`embedding:embed:norm{0|1}:{text}`
  59 + - 图片:`embedding:image:embed:norm{0|1}:{url_or_path}`
  60 +- 当前实现不再兼容历史 key 规则,只保留这一套格式,减少代码路径和缓存歧义。
  61 +
  62 +### 压力隔离与拒绝策略
  63 +
  64 +- 文本与图片各自有独立 admission control:
  65 + - `TEXT_MAX_INFLIGHT`
  66 + - `IMAGE_MAX_INFLIGHT`
  67 +- 图片服务可以配置得比文本更严格。
  68 +- 请求若是 full-cache-hit,会在服务端直接返回,不占用模型并发槽位。
  69 +- 超过处理能力时直接拒绝,比无限排队更稳定。
  70 +
40 71 ### 图片向量:clip-as-service(推荐)
41 72  
42 73 默认使用 `third-party/clip-as-service` 的 Jina CLIP 服务生成图片向量。
... ... @@ -63,7 +94,7 @@
63 94  
64 95 ### 启动服务
65 96  
66   -使用仓库脚本启动(默认端口 6005)
  97 +使用仓库脚本启动
67 98  
68 99 ```bash
69 100 # GPU(需 nvidia-container-toolkit)
... ... @@ -72,16 +103,27 @@ TEI_DEVICE=cuda ./scripts/start_tei_service.sh
72 103 # CPU
73 104 TEI_DEVICE=cpu ./scripts/start_tei_service.sh
74 105  
75   -./scripts/start_embedding_service.sh
  106 +./scripts/start_embedding_text_service.sh
  107 +./scripts/start_embedding_image_service.sh
  108 +
  109 +# 或兼容入口
  110 +./scripts/start_embedding_service.sh text
  111 +./scripts/start_embedding_service.sh image
76 112 ```
77 113  
78 114 ### 修改配置
79 115  
80 116 编辑 `embeddings/config.py`:
81 117  
82   -- `PORT`: 服务端口(默认 6005)
  118 +- `PORT`: combined 模式默认端口(默认 6005)
83 119 - `TEXT_MODEL_ID`, `TEXT_DEVICE`, `TEXT_BATCH_SIZE`, `TEXT_NORMALIZE_EMBEDDINGS`
84 120 - `IMAGE_NORMALIZE_EMBEDDINGS`(默认 true)
85 121 - `USE_CLIP_AS_SERVICE`, `CLIP_AS_SERVICE_SERVER`, `CLIP_AS_SERVICE_MODEL_NAME`:图片向量(clip-as-service)
86 122 - `IMAGE_MODEL_NAME`, `IMAGE_DEVICE`:本地 CN-CLIP(当 `USE_CLIP_AS_SERVICE=false` 时)
87 123 - TEI 相关:`TEI_DEVICE`、`TEI_VERSION`、`TEI_MAX_BATCH_TOKENS`、`TEI_MAX_CLIENT_BATCH_SIZE`、`TEI_HEALTH_TIMEOUT_SEC`
  124 +- 分流/限流相关:
  125 + - `EMBEDDING_SERVICE_KIND=all|text|image`
  126 + - `EMBEDDING_TEXT_PORT`
  127 + - `EMBEDDING_IMAGE_PORT`
  128 + - `TEXT_MAX_INFLIGHT`
  129 + - `IMAGE_MAX_INFLIGHT`
... ...
embeddings/cache_keys.py
1   -"""Shared cache key helpers for embedding inputs."""
  1 +"""Shared cache key helpers for embedding inputs.
  2 +
  3 +Current canonical raw-key format:
  4 +- text: ``embed:norm1:<text>`` / ``embed:norm0:<text>``
  5 +- image: ``embed:norm1:<url>`` / ``embed:norm0:<url>``
  6 +
  7 +`RedisEmbeddingCache` adds the configured key prefix and optional namespace on top.
  8 +"""
2 9  
3 10 from __future__ import annotations
4 11  
5 12  
6 13 def build_text_cache_key(text: str, *, normalize: bool) -> str:
7 14 normalized_text = str(text or "").strip()
8   - return f"norm:{1 if normalize else 0}:text:{normalized_text}"
  15 + return f"embed:norm{1 if normalize else 0}:{normalized_text}"
9 16  
10 17  
11 18 def build_image_cache_key(url: str, *, normalize: bool) -> str:
12 19 normalized_url = str(url or "").strip()
13   - return f"norm:{1 if normalize else 0}:image:{normalized_url}"
  20 + return f"embed:norm{1 if normalize else 0}:{normalized_url}"
... ...
embeddings/image_encoder.py
... ... @@ -127,10 +127,10 @@ class CLIPImageEncoder:
127 127 cached = self.cache.get(cache_key)
128 128 if cached is not None:
129 129 results.append(cached)
130   - else:
131   - results.append(np.array([], dtype=np.float32)) # placeholder
132   - pending_positions.append(pos)
133   - pending_urls.append(url)
  130 + continue
  131 + results.append(np.array([], dtype=np.float32)) # placeholder
  132 + pending_positions.append(pos)
  133 + pending_urls.append(url)
134 134  
135 135 for i in range(0, len(pending_urls), batch_size):
136 136 batch_urls = pending_urls[i : i + batch_size]
... ...
embeddings/text_encoder.py
... ... @@ -164,12 +164,14 @@ class TextEmbeddingEncoder:
164 164 normalize_embeddings: bool,
165 165 ) -> Optional[np.ndarray]:
166 166 """Get embedding from cache if exists (with sliding expiration)."""
167   - embedding = self.cache.get(build_text_cache_key(query, normalize=normalize_embeddings))
  167 + cache_key = build_text_cache_key(query, normalize=normalize_embeddings)
  168 + embedding = self.cache.get(cache_key)
168 169 if embedding is not None:
169 170 logger.debug(
170   - "Cache hit for text embedding | normalize=%s query=%s",
  171 + "Cache hit for text embedding | normalize=%s query=%s key=%s",
171 172 normalize_embeddings,
172 173 query,
  174 + cache_key,
173 175 )
174 176 return embedding
175 177  
... ...
scripts/trace_indexer_calls.sh
... ... @@ -14,7 +14,8 @@ echo &quot;索引服务调用方排查&quot;
14 14 echo "=========================================="
15 15  
16 16 INDEXER_PORT="${INDEXER_PORT:-6004}"
17   -EMBEDDING_PORT="${EMBEDDING_PORT:-6005}"
  17 +EMBEDDING_TEXT_PORT="${EMBEDDING_TEXT_PORT:-${EMBEDDING_PORT:-6005}}"
  18 +EMBEDDING_IMAGE_PORT="${EMBEDDING_IMAGE_PORT:-6008}"
18 19  
19 20 echo ""
20 21 echo "1. 监听端口 6004 的进程(Indexer 服务)"
... ... @@ -37,10 +38,10 @@ else
37 38 fi
38 39  
39 40 echo ""
40   -echo "3. 连接到 6005 的客户端(Indexer 会调用 Embedding 服务)"
  41 +echo "3. 连接到 Embedding 服务的客户端"
41 42 echo "------------------------------------------"
42 43 if command -v ss >/dev/null 2>&1; then
43   - ss -tnp 2>/dev/null | grep ":${EMBEDDING_PORT}" || echo " (当前无活跃连接)"
  44 + ss -tnp 2>/dev/null | grep -E ":${EMBEDDING_TEXT_PORT}|:${EMBEDDING_IMAGE_PORT}" || echo " (当前无活跃连接)"
44 45 fi
45 46  
46 47 echo ""
... ... @@ -63,12 +64,13 @@ echo &quot; 全量: curl -X POST http://localhost:${INDEXER_PORT}/indexer/reindex
63 64 echo " 增量: curl -X POST http://localhost:${INDEXER_PORT}/indexer/index -d '{\"tenant_id\":\"170\",\"spu_ids\":[\"123\"]}'"
64 65 echo ""
65 66 echo " - Indexer 内部会调用:"
66   -echo " - Embedding 服务 (${EMBEDDING_PORT}): POST /embed/text"
  67 +echo " - Text Embedding 服务 (${EMBEDDING_TEXT_PORT}): POST /embed/text"
  68 +echo " - Image Embedding 服务 (${EMBEDDING_IMAGE_PORT}): POST /embed/image"
67 69 echo " - Qwen API: dashscope.aliyuncs.com (翻译、LLM 分析)"
68 70 echo " - MySQL: 商品数据"
69 71 echo " - Elasticsearch: 写入索引"
70 72 echo ""
71 73 echo "6. 实时监控连接(按 Ctrl+C 停止)"
72 74 echo "------------------------------------------"
73   -echo " 运行: watch -n 2 'ss -tnp | grep -E \":${INDEXER_PORT}|:${EMBEDDING_PORT}\"'"
  75 +echo " 运行: watch -n 2 'ss -tnp | grep -E \":${INDEXER_PORT}|:${EMBEDDING_TEXT_PORT}|:${EMBEDDING_IMAGE_PORT}\"'"
74 76 echo ""
... ...
utils/__init__.py
... ... @@ -2,7 +2,7 @@
2 2  
3 3 from .db_connector import create_db_connection, get_connection_from_config, test_connection
4 4 from .es_client import ESClient, get_es_client_from_env
5   -from .cache import EmbeddingCache, DictCache
  5 +from .cache import DictCache
6 6  
7 7 __all__ = [
8 8 'create_db_connection',
... ... @@ -10,6 +10,5 @@ __all__ = [
10 10 'test_connection',
11 11 'ESClient',
12 12 'get_es_client_from_env',
13   - 'EmbeddingCache',
14 13 'DictCache',
15 14 ]
... ...
utils/cache.py
1   -"""
2   -Cache utility for storing embedding results.
3   -"""
  1 +"""Small file-backed cache helpers."""
4 2  
5 3 import json
6   -import hashlib
7   -import pickle
8 4 from pathlib import Path
9 5 from typing import Any, Optional
10   -import numpy as np
11   -
12   -
13   -class EmbeddingCache:
14   - """
15   - Simple file-based cache for embeddings.
16   -
17   - Uses MD5 hash of input text/URL as cache key.
18   - """
19   -
20   - def __init__(self, cache_dir: str = ".cache/embeddings"):
21   - self.cache_dir = Path(cache_dir)
22   - self.cache_dir.mkdir(parents=True, exist_ok=True)
23   -
24   - def _get_cache_key(self, input_str: str) -> str:
25   - """Generate cache key from input string."""
26   - return hashlib.md5(input_str.encode('utf-8')).hexdigest()
27   -
28   - def get(self, input_str: str) -> Optional[np.ndarray]:
29   - """
30   - Get cached embedding.
31   -
32   - Args:
33   - input_str: Input text or URL
34   -
35   - Returns:
36   - Cached embedding or None if not found
37   - """
38   - cache_key = self._get_cache_key(input_str)
39   - cache_file = self.cache_dir / f"{cache_key}.npy"
40   -
41   - if cache_file.exists():
42   - try:
43   - return np.load(cache_file)
44   - except Exception as e:
45   - print(f"Failed to load cache for {input_str}: {e}")
46   - return None
47   - return None
48   -
49   - def set(self, input_str: str, embedding: np.ndarray) -> bool:
50   - """
51   - Store embedding in cache.
52   -
53   - Args:
54   - input_str: Input text or URL
55   - embedding: Embedding vector
56   -
57   - Returns:
58   - True if successful
59   - """
60   - cache_key = self._get_cache_key(input_str)
61   - cache_file = self.cache_dir / f"{cache_key}.npy"
62   -
63   - try:
64   - np.save(cache_file, embedding)
65   - return True
66   - except Exception as e:
67   - print(f"Failed to cache embedding for {input_str}: {e}")
68   - return False
69   -
70   - def exists(self, input_str: str) -> bool:
71   - """Check if embedding is cached."""
72   - cache_key = self._get_cache_key(input_str)
73   - cache_file = self.cache_dir / f"{cache_key}.npy"
74   - return cache_file.exists()
75   -
76   - def clear(self) -> int:
77   - """
78   - Clear all cached embeddings.
79   -
80   - Returns:
81   - Number of files deleted
82   - """
83   - count = 0
84   - for cache_file in self.cache_dir.glob("*.npy"):
85   - cache_file.unlink()
86   - count += 1
87   - return count
88   -
89   - def size(self) -> int:
90   - """Get number of cached embeddings."""
91   - return len(list(self.cache_dir.glob("*.npy")))
92 6  
93 7  
94 8 class DictCache:
... ...