From 5bac964944e8cd54c86dbb9a8713476fba74c50c Mon Sep 17 00:00:00 2001 From: tangwang Date: Thu, 19 Mar 2026 13:54:05 +0800 Subject: [PATCH] 文本 embedding 与图片 embedding 已拆分为两个独立进程 / 端口 --- docs/CNCLIP_SERVICE说明文档.md | 8 ++++---- docs/DEVELOPER_GUIDE.md | 7 ++++--- docs/QUICKSTART.md | 15 +++++++++------ docs/TEI_SERVICE说明文档.md | 8 ++++---- docs/Usage-Guide.md | 6 ++++-- docs/向量化模块和API说明文档.md | 28 ++++++++++++++++++++++++++-- docs/工作总结-微服务性能优化与架构.md | 10 +++++----- docs/搜索API对接指南.md | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++---------- docs/缓存与Redis使用说明.md | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++------- embeddings/README.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++---- embeddings/cache_keys.py | 13 ++++++++++--- embeddings/image_encoder.py | 8 ++++---- embeddings/text_encoder.py | 6 ++++-- scripts/trace_indexer_calls.sh | 12 +++++++----- utils/__init__.py | 3 +-- utils/cache.py | 88 +--------------------------------------------------------------------------------------- 16 files changed, 229 insertions(+), 150 deletions(-) diff --git a/docs/CNCLIP_SERVICE说明文档.md b/docs/CNCLIP_SERVICE说明文档.md index adb7484..5629688 100644 --- a/docs/CNCLIP_SERVICE说明文档.md +++ b/docs/CNCLIP_SERVICE说明文档.md @@ -5,7 +5,7 @@ ## 1. 作用与边界 - `cnclip` 是独立 gRPC 服务(默认 `grpc://127.0.0.1:51000`)。 -- `embedding` 服务(6005)在 `USE_CLIP_AS_SERVICE=true` 时调用它完成 `/embed/image`。 +- 图片 embedding 服务(默认 `6008`)在 `USE_CLIP_AS_SERVICE=true` 时调用它完成 `/embed/image`。 - `cnclip` 不负责文本向量;文本向量由 TEI(8080)负责。 ## 2. 代码与脚本入口 @@ -90,7 +90,7 @@ CNCLIP_MODEL_NAME=CN-CLIP/ViT-H-14 ./scripts/service_ctl.sh start cnclip ```bash ./scripts/service_ctl.sh start cnclip -# 或一次启动可选能力:./scripts/service_ctl.sh start embedding tei cnclip +# 或一次启动可选能力:./scripts/service_ctl.sh start embedding embedding-image tei cnclip ``` ### 6.2 设备选择优先级 @@ -162,9 +162,9 @@ GPU 模式下应出现 `clip_server` 相关 `python` 进程及显存占用。 ### 7.5 与 embedding 服务联调 ```bash -./scripts/start_embedding_service.sh +./scripts/start_embedding_image_service.sh -curl -sS -X POST "http://127.0.0.1:6005/embed/image" \ +curl -sS -X POST "http://127.0.0.1:6008/embed/image" \ -H "Content-Type: application/json" \ -d '["https://oss.essa.cn/98532128-cf8e-456c-9e30-6f2a5ea0c19f.jpg"]' ``` diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index c0e6e8a..9964e70 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -92,7 +92,8 @@ MySQL (店匠 SPU/SKU) | backend | 6002 | 搜索 API(含 admin) | ✓ | | indexer | 6004 | 索引 API(reindex/build-docs 等) | ✓ | | frontend | 6003 | 调试 UI | ✓ | -| embedding | 6005 | 向量服务(文本/图片) | 可选 | +| embedding | 6005 | 文本向量服务 | 可选 | +| embedding-image | 6008 | 图片向量服务 | 可选 | | translator | 6006 | 翻译服务(`POST /translate` 支持单条或批量 list;批量失败用 `null` 占位) | 可选 | | reranker | 6007 | 重排服务 | 可选 | @@ -156,8 +157,8 @@ docs/ # 文档(含本指南) ### 4.6 embeddings -- **职责**:提供向量服务(FastAPI):`POST /embed/text`、`POST /embed/image`;服务内按配置加载文本后端(如 Qwen3-Embedding-0.6B)与图像后端(如 clip-as-service 或本地 CN-CLIP),实现协议即可插拔。 -- **原则**:图片后端实现 `embeddings/protocols.ImageEncoderProtocol`;文本后端与参数统一从 `config/config.yaml -> services.embedding.backend(s)` 读取。 +- **职责**:提供向量服务(FastAPI):文本服务默认监听 `6005`,图片服务默认监听 `6008`;对外暴露 `POST /embed/text`、`POST /embed/image`、`GET /health`、`GET /ready`;服务内按配置加载文本后端(如 Qwen3-Embedding-0.6B)与图像后端(如 clip-as-service 或本地 CN-CLIP),实现协议即可插拔。 +- **原则**:图片后端实现 `embeddings/protocols.ImageEncoderProtocol`;文本后端与参数统一从 `config/config.yaml -> services.embedding.backend(s)` 读取;文本与图片流量应通过独立进程和独立 inflight limit 做隔离。 - **详见**:`embeddings/README.md`、本指南 §7.5–§7.6。 ### 4.7 reranker diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index b129f70..8deef89 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -57,7 +57,8 @@ source activate.sh | backend | 6002 | ✓ | 搜索 API(`/search/*`)+ 管理接口(`/admin/*`) | | indexer | 6004 | ✓ | 索引 API(`/indexer/*`) | | frontend | 6003 | ✓ | 调试 UI | -| embedding | 6005 | - | 向量服务(`/embed/text`, `/embed/image`) | +| embedding | 6005 | - | 文本向量服务(`/embed/text`) | +| embedding-image | 6008 | - | 图片向量服务(`/embed/image`) | | translator | 6006 | - | 翻译服务(`/translate`) | | reranker | 6007 | - | 重排服务(`/rerank`) | @@ -135,21 +136,22 @@ curl -X POST http://localhost:6004/indexer/build-docs \ API 文档:`http://localhost:6004/docs` -#### Embedding 服务(6005) +#### Embedding 服务(文本 6005 / 图片 6008) ```bash # TEI(文本向量后端,默认) # GPU(需 nvidia-container-toolkit) TEI_DEVICE=cuda ./scripts/start_tei_service.sh -# Embedding API(会校验 TEI /health) -./scripts/start_embedding_service.sh +# Embedding API(text 会校验 TEI /health) +./scripts/start_embedding_text_service.sh +./scripts/start_embedding_image_service.sh curl -X POST http://localhost:6005/embed/text \ -H "Content-Type: application/json" \ -d '["衣服", "Bohemian Maxi Dress"]' -curl -X POST http://localhost:6005/embed/image \ +curl -X POST http://localhost:6008/embed/image \ -H "Content-Type: application/json" \ -d '["https://example.com/img.jpg"]' ``` @@ -157,7 +159,7 @@ curl -X POST http://localhost:6005/embed/image \ 说明: - TEI 默认镜像按 `TEI_VERSION` 组装:`cuda-`(默认 `1.9`)。 - `TEI_DEVICE=cuda` 时会严格校验 Docker GPU runtime;未配置会直接报错退出。 -- `/embed/image` 依赖 `cnclip`(`grpc://127.0.0.1:51000`),未启动时 embedding 服务会启动失败。 +- `/embed/image` 依赖 `cnclip`(`grpc://127.0.0.1:51000`),未启动时图片 embedding 服务会启动失败。 #### Translator 服务(6006) @@ -530,6 +532,7 @@ curl http://localhost:6002/health curl http://localhost:6002/admin/health curl http://localhost:6004/health curl http://localhost:6005/health +curl http://localhost:6008/health curl http://localhost:6006/health curl http://localhost:6007/health ``` diff --git a/docs/TEI_SERVICE说明文档.md b/docs/TEI_SERVICE说明文档.md index 8271483..ef20504 100644 --- a/docs/TEI_SERVICE说明文档.md +++ b/docs/TEI_SERVICE说明文档.md @@ -5,7 +5,7 @@ ## 1. 作用与边界 - TEI 提供文本向量 HTTP 服务(默认 `http://127.0.0.1:8080`)。 -- 本项目中 `embedding` 服务(6005)默认把文本向量请求转发到 TEI。 +- 本项目中文本 embedding 服务(默认 `6005`)把文本向量请求转发到 TEI。 - TEI 仅负责文本向量,不负责图片向量(图片向量由 `cnclip` 提供)。 ## 2. 代码与脚本入口 @@ -13,7 +13,7 @@ - 启动脚本:`scripts/start_tei_service.sh` - 停止脚本:`scripts/stop_tei_service.sh` - 统一编排:`scripts/service_ctl.sh` -- embedding 服务启动脚本(会校验 TEI 健康):`scripts/start_embedding_service.sh` +- 文本 embedding 服务启动脚本(会校验 TEI 健康):`scripts/start_embedding_text_service.sh` ## 3. 前置条件 @@ -117,7 +117,7 @@ curl -sS http://127.0.0.1:8080/embed \ ### 6.3 与 embedding 服务联调 ```bash -./scripts/start_embedding_service.sh +./scripts/start_embedding_text_service.sh curl -sS -X POST "http://127.0.0.1:6005/embed/text" \ -H "Content-Type: application/json" \ @@ -152,7 +152,7 @@ curl -sS -X POST "http://127.0.0.1:6005/embed/text" \ 启动全套(含 TEI): ```bash -TEI_DEVICE=cuda ./scripts/service_ctl.sh start tei cnclip embedding translator reranker +TEI_DEVICE=cuda ./scripts/service_ctl.sh start tei cnclip embedding embedding-image translator reranker ``` 仅启动 TEI: diff --git a/docs/Usage-Guide.md b/docs/Usage-Guide.md index 6dad2a0..3270b0d 100644 --- a/docs/Usage-Guide.md +++ b/docs/Usage-Guide.md @@ -335,7 +335,8 @@ python -m http.server 6003 | Backend API | 6002 | http://localhost:6002 | | Indexer API | 6004 | http://localhost:6004 | | Frontend Web | 6003 | http://localhost:6003 | -| Embedding (optional) | 6005 | http://localhost:6005 | +| Embedding Text (optional) | 6005 | http://localhost:6005 | +| Embedding Image (optional) | 6008 | http://localhost:6008 | | TEI (optional) | 8080 | http://localhost:8080 | | Translation (optional) | 6006 | http://localhost:6006 | | Reranker (optional) | 6007 | http://localhost:6007 | @@ -380,7 +381,8 @@ INDEXER_PORT=6004 # Optional service ports FRONTEND_PORT=6003 -EMBEDDING_PORT=6005 +EMBEDDING_TEXT_PORT=6005 +EMBEDDING_IMAGE_PORT=6008 TEI_PORT=8080 CNCLIP_PORT=51000 TRANSLATION_PORT=6006 diff --git a/docs/向量化模块和API说明文档.md b/docs/向量化模块和API说明文档.md index d73751f..fcb705e 100644 --- a/docs/向量化模块和API说明文档.md +++ b/docs/向量化模块和API说明文档.md @@ -4,12 +4,36 @@ ## 服务接口 -- `POST /embed/text`:文本向量,入参 `["text1", "text2"]`,出参 `[[...], [...]]` -- `POST /embed/image`:图片向量,入参 `["url1", "url2"]`,出参 `[[...], [...]]` +- 文本服务:`POST http://localhost:6005/embed/text` +- 图片服务:`POST http://localhost:6008/embed/image` +- 健康检查:`GET /health` +- 就绪检查:`GET /ready` + +## 当前架构 + +- 文本 embedding 与图片 embedding 已拆分为两个独立进程 / 端口: + - text: `6005` + - image: `6008` +- 两侧有独立并发控制: + - `TEXT_MAX_INFLIGHT` + - `IMAGE_MAX_INFLIGHT` +- 两侧都接入 Redis 向量缓存,value 统一使用 BF16 bytes 存储。 + +## 缓存 + +- 当前是双层缓存: + - 调用侧 client 先查 Redis + - 服务侧收到请求后再查 Redis +- 当前主 key 规则: + - 文本:`embedding:embed:norm{0|1}:{text}` + - 图片:`embedding:image:embed:norm{0|1}:{url_or_path}` +- full-cache-hit 时,服务会直接返回,不占用模型 lane。 ## 配置 - Provider/URL:`config/config.yaml` 的 `services.embedding` +- 文本服务 URL:`services.embedding.providers.http.text_base_url` +- 图片服务 URL:`services.embedding.providers.http.image_base_url` - 文本模型:`embeddings/config.py` 的 `TEXT_MODEL_ID`(默认 `Qwen/Qwen3-Embedding-0.6B`) - 运行参数:`TEXT_DEVICE`、`TEXT_BATCH_SIZE`、`TEXT_NORMALIZE_EMBEDDINGS` diff --git a/docs/工作总结-微服务性能优化与架构.md b/docs/工作总结-微服务性能优化与架构.md index 221d1f2..abd3c31 100644 --- a/docs/工作总结-微服务性能优化与架构.md +++ b/docs/工作总结-微服务性能优化与架构.md @@ -16,12 +16,12 @@ | **vLLM** | 高吞吐推理框架,更适合生成式与重排混合场景 | 纯 embedding 场景通常不作为首选 | | **TEI** | HuggingFace 官方 embedding 专用推理服务,Docker 部署 | **当前最优选型** | -**当前方案**:以 **TEI** 为文本向量后端,模型为 `Qwen/Qwen3-Embedding-0.6B`,embedding 服务(端口 **6005**)将 `POST /embed/text` 请求转发至 TEI(默认端口 **8080**)。 +**当前方案**:以 **TEI** 为文本向量后端,模型为 `Qwen/Qwen3-Embedding-0.6B`;文本 embedding 服务(端口 **6005**)将 `POST /embed/text` 请求转发至 TEI(默认端口 **8080**)。 **具体配置与脚本**: - **配置**:`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`。 - **启动**:`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`。 -- **编排**:`./scripts/service_ctl.sh start tei` 或 `start embedding`(embedding 会校验 TEI `/health` 后再就绪)。 +- **编排**:`./scripts/service_ctl.sh start tei` 或 `start embedding`(text embedding 会校验 TEI `/health` 后再就绪)。 **工程化收益**: - **独立服务**:TEI 以 Docker 容器运行,与主程序 `.venv` 解耦;embedding 使用 `.venv-embedding`,便于独立扩缩容与升级。 @@ -71,7 +71,7 @@ instruction: "Given a shopping query, rank product titles by relevance" **具体内容**: - **端口**:clip-as-service 默认 **51000**(`CNCLIP_PORT`);文本走 TEI(8080),图片走 clip-as-service。 -- **API**:embedding 服务(6005)统一暴露 `POST /embed/text` 与 `POST /embed/image`;图片请求由 `embeddings/server.py` 按配置调用实现 `ImageEncoderProtocol` 的后端(clip-as-service 或本地 CN-CLIP)。 +- **API**:文本 embedding 服务默认暴露 `POST /embed/text`(6005),图片 embedding 服务默认暴露 `POST /embed/image`(6008);图片请求由 `embeddings/server.py` 按配置调用实现 `ImageEncoderProtocol` 的后端(clip-as-service 或本地 CN-CLIP)。 - **环境与启停**:CN-CLIP 使用独立虚拟环境 `.venv-cnclip`;启动 `scripts/start_cnclip_service.sh`,或 `./scripts/service_ctl.sh start cnclip`;设备可通过 `CNCLIP_DEVICE=cuda`(默认)或 `cpu` 指定。 - **配置**:图片后端在 `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`。 @@ -127,8 +127,8 @@ instruction: "Given a shopping query, rank product titles by relevance" - **脚本**:`scripts/service_ctl.sh` 统一负责各服务的生命周期与监控;依赖 `scripts/lib/load_env.sh` 与项目根目录 `.env`。 - **服务与端口**: - 核心:backend **6002**、indexer **6004**、frontend **6003**。 - - 可选:embedding **6005**、translator **6006**、reranker **6007**、tei **8080**、cnclip **51000**。 - - 端口可由环境变量覆盖:`API_PORT`、`INDEXER_PORT`、`FRONTEND_PORT`、`EMBEDDING_PORT`、`TRANSLATION_PORT`、`RERANKER_PORT`、`TEI_PORT`、`CNCLIP_PORT`。 + - 可选:embedding(text) **6005**、embedding-image **6008**、translator **6006**、reranker **6007**、tei **8080**、cnclip **51000**。 + - 端口可由环境变量覆盖:`API_PORT`、`INDEXER_PORT`、`FRONTEND_PORT`、`EMBEDDING_TEXT_PORT`、`EMBEDDING_IMAGE_PORT`、`TRANSLATION_PORT`、`RERANKER_PORT`、`TEI_PORT`、`CNCLIP_PORT`。 - **命令**: - `./scripts/service_ctl.sh start [service...]` 或 `start all`(all = tei cnclip embedding translator reranker backend indexer frontend,按依赖顺序);`stop`、`restart` 同参数;`status` 默认列出所有服务。 - 启动时:backend/indexer/frontend/embedding/translator/reranker 会写 pid 到 `logs/.pid`,并执行 `wait_for_health`(GET `http://127.0.0.1:/health`);reranker 健康重试 90 次,其余 30 次;TEI 校验 Docker 容器存在且 `/health` 成功;cnclip 无 HTTP 健康则仅校验进程/端口。 diff --git a/docs/搜索API对接指南.md b/docs/搜索API对接指南.md index ab968f1..f8dfdee 100644 --- a/docs/搜索API对接指南.md +++ b/docs/搜索API对接指南.md @@ -157,8 +157,8 @@ curl -X POST "http://43.166.252.75:6002/search/" \ | 服务 | 端口 | 接口 | 说明 | |------|------|------|------| -| 向量服务 | 6005 | `POST /embed/text` | 文本向量化 | -| 向量服务 | 6005 | `POST /embed/image` | 图片向量化 | +| 向量服务(文本) | 6005 | `POST /embed/text` | 文本向量化 | +| 向量服务(图片) | 6008 | `POST /embed/image` | 图片向量化 | | 翻译服务 | 6006 | `POST /translate` | 文本翻译(支持 qwen-mt / llm / deepl / 本地模型) | | 重排服务 | 6007 | `POST /rerank` | 检索结果重排 | | 内容理解(Indexer 内) | 6004 | `POST /indexer/enrich-content` | 根据商品标题生成 qanchors、tags 等,供 indexer 微服务组合方式使用 | @@ -1691,7 +1691,8 @@ curl -X POST "http://localhost:6004/indexer/enrich-content" \ | 服务 | 默认端口 | Base URL | 说明 | |------|----------|----------|------| -| 向量服务 | 6005 | `http://localhost:6005` | 文本/图片向量化,用于语义搜索与以图搜图 | +| 向量服务(文本) | 6005 | `http://localhost:6005` | 文本向量化,用于 query/doc 语义检索 | +| 向量服务(图片) | 6008 | `http://localhost:6008` | 图片向量化,用于以图搜图 | | 翻译服务 | 6006 | `http://localhost:6006` | 多语言翻译(云端与本地模型统一入口) | | 重排服务 | 6007 | `http://localhost:6007` | 对检索结果进行二次排序 | @@ -1700,13 +1701,28 @@ curl -X POST "http://localhost:6004/indexer/enrich-content" \ ### 7.1 向量服务(Embedding) -- **Base URL**: `http://localhost:6005`(可通过 `EMBEDDING_SERVICE_URL` 覆盖) -- **启动**: `./scripts/start_embedding_service.sh` +- **Base URL**: + - 文本:`http://localhost:6005`(可通过 `EMBEDDING_TEXT_SERVICE_URL` 覆盖) + - 图片:`http://localhost:6008`(可通过 `EMBEDDING_IMAGE_SERVICE_URL` 覆盖) +- **启动**: + - 文本:`./scripts/start_embedding_text_service.sh` + - 图片:`./scripts/start_embedding_image_service.sh` + - 兼容入口:`./scripts/start_embedding_service.sh text|image|all` - **依赖**: - 文本向量后端默认走 TEI(`http://127.0.0.1:8080`) - 图片向量依赖 `cnclip`(`grpc://127.0.0.1:51000`) - TEI 默认使用 GPU(`TEI_DEVICE=cuda`);当配置为 GPU 且不可用时会启动失败(不会自动降级到 CPU) - cnclip 默认使用 `cuda`;若配置为 `cuda` 但 GPU 不可用会启动失败(不会自动降级到 `cpu`) + - 当前单机部署建议保持单实例,通过**文本/图片拆分 + 独立限流**隔离压力 + +补充说明: + +- 文本和图片现在已经拆成**不同进程 / 不同端口**,避免图片下载与编码波动影响文本向量化。 +- 服务端对 text / image 有**独立 admission control**: + - `TEXT_MAX_INFLIGHT` + - `IMAGE_MAX_INFLIGHT` +- 当超过处理能力时,服务会直接返回过载错误,而不是无限排队。 +- `GET /health` 会返回各自的 `limits`、`stats`、`cache_enabled` 等状态;`GET /ready` 用于就绪探针。 #### 7.1.1 `POST /embed/text` — 文本向量化 @@ -1724,7 +1740,7 @@ curl -X POST "http://localhost:6004/indexer/enrich-content" \ **完整 curl 示例**: ```bash -curl -X POST "http://localhost:6005/embed/text" \ +curl -X POST "http://localhost:6005/embed/text?normalize=true" \ -H "Content-Type: application/json" \ -d '["芭比娃娃 儿童玩具", "纯棉T恤 短袖"]' ``` @@ -1733,7 +1749,7 @@ curl -X POST "http://localhost:6005/embed/text" \ 将图片 URL 或路径转为向量,用于以图搜图。 -前置条件:`cnclip` 服务已启动(默认端口 `51000`)。若未启动,`/embed/image` 会返回 500。 +前置条件:`cnclip` 服务已启动(默认端口 `51000`)。若未启动,图片 embedding 服务启动会失败或请求返回错误。 **请求体**(JSON 数组): ```json @@ -1747,7 +1763,7 @@ curl -X POST "http://localhost:6005/embed/text" \ **完整 curl 示例**: ```bash -curl -X POST "http://localhost:6005/embed/image" \ +curl -X POST "http://localhost:6008/embed/image?normalize=true" \ -H "Content-Type: application/json" \ -d '["https://oss.essa.cn/98532128-cf8e-456c-9e30-6f2a5ea0c19f.jpg"]' ``` @@ -1756,9 +1772,32 @@ curl -X POST "http://localhost:6005/embed/image" \ ```bash curl "http://localhost:6005/health" +curl "http://localhost:6008/health" +``` + +返回中会包含: + +- `service_kind`:`text` / `image` / `all` +- `cache_enabled`:text/image Redis 缓存是否可用 +- `limits`:当前 inflight limit、active、rejected_total 等 +- `stats`:request_total、cache_hits、cache_misses、avg_latency_ms 等 + +#### 7.1.4 `GET /ready` — 就绪检查 + +```bash +curl "http://localhost:6005/ready" +curl "http://localhost:6008/ready" ``` -#### 7.1.4 TEI 统一调优建议(主服务) +#### 7.1.5 缓存与限流说明 + +- 文本与图片都会先查 Redis 向量缓存。 +- Redis 中 value 仍是 **BF16 bytes**,读取后恢复成 `float32` 返回。 +- cache key 已区分 `normalize=true/false`,避免不同归一化策略命中同一条缓存。 +- 当服务端发现请求是 **full-cache-hit** 时,会直接返回,不占用模型并发槽位。 +- 当服务端发现超过 `TEXT_MAX_INFLIGHT` / `IMAGE_MAX_INFLIGHT` 时,会直接拒绝,而不是无限排队。 + +#### 7.1.6 TEI 统一调优建议(主服务) 使用单套主服务即可同时兼顾: - 在线 query 向量化(低延迟,常见 `batch=1~4`) @@ -1773,7 +1812,8 @@ curl "http://localhost:6005/health" 默认端口: - TEI: `http://127.0.0.1:8080` -- 向量服务(`/embed/text`): `http://127.0.0.1:6005` +- 文本向量服务(`/embed/text`): `http://127.0.0.1:6005` +- 图片向量服务(`/embed/image`): `http://127.0.0.1:6008` 当前主 TEI 启动默认值(已按 T4/短文本场景调优): - `TEI_MAX_BATCH_TOKENS=4096` diff --git a/docs/缓存与Redis使用说明.md b/docs/缓存与Redis使用说明.md index 2c75ce1..928cc16 100644 --- a/docs/缓存与Redis使用说明.md +++ b/docs/缓存与Redis使用说明.md @@ -19,7 +19,7 @@ | 模块 / 场景 | Key 模板 | Value 内容示例 | 过期策略 | 备注 | |------------|----------|----------------|----------|------| -| 向量缓存(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"]` 控制 | +| 向量缓存(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"]` 控制 | | 翻译结果缓存(translator service) | `trans:{model}:{target_lang}:{source_text[:4]}{sha256(source_text)}` | 机翻后的单条字符串 | TTL=`services.translation.cache.ttl_seconds` 秒;可配置滑动过期 | 见 `translation/service.py` + `config/config.yaml` | | 商品内容理解缓存(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` | @@ -27,23 +27,49 @@ --- -## 2. 文本向量缓存(embeddings/text_encoder.py) +## 2. 向量缓存(embeddings/text_encoder.py / embeddings/image_encoder.py / embeddings/server.py) -- **代码位置**:`embeddings/text_encoder.py` 中 `TextEmbeddingEncoder` -- **用途**:缓存调用向量服务(6005)的文本向量结果,避免重复计算。 +- **代码位置**: + - 调用侧:`embeddings/text_encoder.py`、`embeddings/image_encoder.py` + - 服务侧:`embeddings/server.py` + - 公共 Redis/BF16 编解码:`embeddings/redis_embedding_cache.py`、`embeddings/bf16.py` +- **用途**:缓存文本/图片向量结果,避免重复推理、重复下载图片、重复占用模型并发槽位。 + +### 2.0 当前缓存链路总览 + +- 现在是**双层缓存**: + - **调用侧缓存**:`TextEmbeddingEncoder` / `CLIPImageEncoder` 在发 HTTP 请求前先查 Redis; + - **服务侧缓存**:`/embed/text` / `/embed/image` 在进入后端推理前再查 Redis。 +- 这次改动里,**BF16 存储格式本身没有变化**: + - 写入:`float32 -> BF16 -> bytes -> Redis` + - 读取:`Redis bytes -> BF16 -> float32(np.float32)` +- 这次新增的核心变化是: + - **缓存 key 加入 normalize 维度**,避免 `normalize=true` 与 `normalize=false` 命中同一条缓存; + - **服务端也启用同一套 Redis 向量缓存**,而不是只有 client 侧缓存; + - **拆分 text / image 服务** 后,图片压力不会再拖慢文本服务; + - **服务侧 full-cache-hit 可直接返回**,不会再进入模型限流槽位。 ### 2.1 Key 设计 -- 函数:`_get_cache_key(query: str, normalize_embeddings: bool) -> str` +- 统一 helper:`embeddings/cache_keys.py` +- 文本主 key:`build_text_cache_key(text, normalize=...)` +- 图片主 key:`build_image_cache_key(url, normalize=...)` - 模板: ```text -{EMBEDDING_CACHE_PREFIX}:{query} +文本: {EMBEDDING_CACHE_PREFIX}:embed:norm{0|1}:{text} +图片: {EMBEDDING_CACHE_PREFIX}:image:embed:norm{0|1}:{url_or_path} ``` - 字段说明: - `EMBEDDING_CACHE_PREFIX`:来自 `REDIS_CONFIG["embedding_cache_prefix"]`,默认值为 `"embedding"`,可通过环境变量 `REDIS_EMBEDDING_CACHE_PREFIX` 覆盖; - - `query`:原始文本(未做哈希),注意长度特别长的 query 会直接出现在 key 中。 + - `norm1` / `norm0`:分别表示 `normalize=true` / `normalize=false`; + - `text` / `url_or_path`:当前仍直接使用规范化后的原始输入,不做哈希。 + +补充说明: + +- 本次把 raw key 格式统一成 `embed:norm{0|1}:...`,比以 `norm:` 开头更清晰,也更接近历史命名习惯。 +- 当前实现**不再兼容历史 key 协议**,只保留这一套主 key 规则,以降低运行时复杂度和歧义。 ### 2.2 Value 与类型 @@ -68,6 +94,23 @@ - 直接丢弃该缓存(并尝试 `delete` key); - 回退为重新调用向量服务。 +### 2.5 服务拆分与缓存/限流关系 + +- 当前默认部署形态: + - 文本 embedding 服务:`6005` + - 图片 embedding 服务:`6008` +- 调用方配置: + - `EMBEDDING_TEXT_SERVICE_URL` + - `EMBEDDING_IMAGE_SERVICE_URL` +- 服务端配置: + - `TEXT_MAX_INFLIGHT` + - `IMAGE_MAX_INFLIGHT` + - `EMBEDDING_SERVICE_KIND=all|text|image` +- 关键行为: + - **full-cache-hit** 请求在服务端会直接返回,不占用 `TEXT_MAX_INFLIGHT` / `IMAGE_MAX_INFLIGHT`; + - **cache miss** 才会进入对应模型 lane; + - 因此高频重复图片请求不会再因为严格的图片限流而大量阻塞。 + --- ## 3. 翻译结果缓存(translation/service.py) diff --git a/embeddings/README.md b/embeddings/README.md index 0dac9e7..d78de92 100644 --- a/embeddings/README.md +++ b/embeddings/README.md @@ -16,7 +16,7 @@ - **统一配置**:`config.py` - **接口契约**:`protocols.ImageEncoderProtocol`(图片编码统一为 `encode_image_urls(urls, batch_size, normalize_embeddings)`,本地 CN-CLIP 与 clip-as-service 均实现该接口) -说明:历史上的云端 embedding 试验实现(DashScope)已从主仓库移除,当前仅维护 6005 这条统一向量服务链路。 +说明:历史上的云端 embedding 试验实现(DashScope)已从主仓库移除。当前默认部署为**文本服务 6005** 与**图片服务 6008** 两条链路,也兼容 `all` 模式单进程启动。 ### 文本向量后端(默认) @@ -27,6 +27,15 @@ ### 服务接口 +- 文本服务(默认 `6005`) + - `POST /embed/text` + - `GET /health` + - `GET /ready` +- 图片服务(默认 `6008`) + - `POST /embed/image` + - `GET /health` + - `GET /ready` + - `POST /embed/text` - 入参:`["文本1", "文本2", ...]` - 可选 query 参数:`normalize=true|false`(不传则使用服务端默认) @@ -37,6 +46,28 @@ - 可选 query 参数:`normalize=true|false`(不传则使用服务端默认) - 出参:`[[...], [...], ...]`(与输入按 index 对齐,失败直接报错) +### Redis 向量缓存 + +- Value 格式没有变化,仍然是 **BF16 bytes**: + - 写入:`float32 -> BF16 -> bytes` + - 读取:`bytes -> BF16 -> float32` +- 现在是**双层缓存**: + - client 侧:`text_encoder.py` / `image_encoder.py` + - service 侧:`server.py` +- 当前主 key 格式: + - 文本:`embedding:embed:norm{0|1}:{text}` + - 图片:`embedding:image:embed:norm{0|1}:{url_or_path}` +- 当前实现不再兼容历史 key 规则,只保留这一套格式,减少代码路径和缓存歧义。 + +### 压力隔离与拒绝策略 + +- 文本与图片各自有独立 admission control: + - `TEXT_MAX_INFLIGHT` + - `IMAGE_MAX_INFLIGHT` +- 图片服务可以配置得比文本更严格。 +- 请求若是 full-cache-hit,会在服务端直接返回,不占用模型并发槽位。 +- 超过处理能力时直接拒绝,比无限排队更稳定。 + ### 图片向量:clip-as-service(推荐) 默认使用 `third-party/clip-as-service` 的 Jina CLIP 服务生成图片向量。 @@ -63,7 +94,7 @@ ### 启动服务 -使用仓库脚本启动(默认端口 6005): +使用仓库脚本启动: ```bash # GPU(需 nvidia-container-toolkit) @@ -72,16 +103,27 @@ TEI_DEVICE=cuda ./scripts/start_tei_service.sh # CPU TEI_DEVICE=cpu ./scripts/start_tei_service.sh -./scripts/start_embedding_service.sh +./scripts/start_embedding_text_service.sh +./scripts/start_embedding_image_service.sh + +# 或兼容入口 +./scripts/start_embedding_service.sh text +./scripts/start_embedding_service.sh image ``` ### 修改配置 编辑 `embeddings/config.py`: -- `PORT`: 服务端口(默认 6005) +- `PORT`: combined 模式默认端口(默认 6005) - `TEXT_MODEL_ID`, `TEXT_DEVICE`, `TEXT_BATCH_SIZE`, `TEXT_NORMALIZE_EMBEDDINGS` - `IMAGE_NORMALIZE_EMBEDDINGS`(默认 true) - `USE_CLIP_AS_SERVICE`, `CLIP_AS_SERVICE_SERVER`, `CLIP_AS_SERVICE_MODEL_NAME`:图片向量(clip-as-service) - `IMAGE_MODEL_NAME`, `IMAGE_DEVICE`:本地 CN-CLIP(当 `USE_CLIP_AS_SERVICE=false` 时) - TEI 相关:`TEI_DEVICE`、`TEI_VERSION`、`TEI_MAX_BATCH_TOKENS`、`TEI_MAX_CLIENT_BATCH_SIZE`、`TEI_HEALTH_TIMEOUT_SEC` +- 分流/限流相关: + - `EMBEDDING_SERVICE_KIND=all|text|image` + - `EMBEDDING_TEXT_PORT` + - `EMBEDDING_IMAGE_PORT` + - `TEXT_MAX_INFLIGHT` + - `IMAGE_MAX_INFLIGHT` diff --git a/embeddings/cache_keys.py b/embeddings/cache_keys.py index 1bb887a..cbe2e3a 100644 --- a/embeddings/cache_keys.py +++ b/embeddings/cache_keys.py @@ -1,13 +1,20 @@ -"""Shared cache key helpers for embedding inputs.""" +"""Shared cache key helpers for embedding inputs. + +Current canonical raw-key format: +- text: ``embed:norm1:`` / ``embed:norm0:`` +- image: ``embed:norm1:`` / ``embed:norm0:`` + +`RedisEmbeddingCache` adds the configured key prefix and optional namespace on top. +""" from __future__ import annotations def build_text_cache_key(text: str, *, normalize: bool) -> str: normalized_text = str(text or "").strip() - return f"norm:{1 if normalize else 0}:text:{normalized_text}" + return f"embed:norm{1 if normalize else 0}:{normalized_text}" def build_image_cache_key(url: str, *, normalize: bool) -> str: normalized_url = str(url or "").strip() - return f"norm:{1 if normalize else 0}:image:{normalized_url}" + return f"embed:norm{1 if normalize else 0}:{normalized_url}" diff --git a/embeddings/image_encoder.py b/embeddings/image_encoder.py index 94c7ada..e497874 100644 --- a/embeddings/image_encoder.py +++ b/embeddings/image_encoder.py @@ -127,10 +127,10 @@ class CLIPImageEncoder: cached = self.cache.get(cache_key) if cached is not None: results.append(cached) - else: - results.append(np.array([], dtype=np.float32)) # placeholder - pending_positions.append(pos) - pending_urls.append(url) + continue + results.append(np.array([], dtype=np.float32)) # placeholder + pending_positions.append(pos) + pending_urls.append(url) for i in range(0, len(pending_urls), batch_size): batch_urls = pending_urls[i : i + batch_size] diff --git a/embeddings/text_encoder.py b/embeddings/text_encoder.py index ca95b23..93afa66 100644 --- a/embeddings/text_encoder.py +++ b/embeddings/text_encoder.py @@ -164,12 +164,14 @@ class TextEmbeddingEncoder: normalize_embeddings: bool, ) -> Optional[np.ndarray]: """Get embedding from cache if exists (with sliding expiration).""" - embedding = self.cache.get(build_text_cache_key(query, normalize=normalize_embeddings)) + cache_key = build_text_cache_key(query, normalize=normalize_embeddings) + embedding = self.cache.get(cache_key) if embedding is not None: logger.debug( - "Cache hit for text embedding | normalize=%s query=%s", + "Cache hit for text embedding | normalize=%s query=%s key=%s", normalize_embeddings, query, + cache_key, ) return embedding diff --git a/scripts/trace_indexer_calls.sh b/scripts/trace_indexer_calls.sh index f79894d..3ddb6ca 100755 --- a/scripts/trace_indexer_calls.sh +++ b/scripts/trace_indexer_calls.sh @@ -14,7 +14,8 @@ echo "索引服务调用方排查" echo "==========================================" INDEXER_PORT="${INDEXER_PORT:-6004}" -EMBEDDING_PORT="${EMBEDDING_PORT:-6005}" +EMBEDDING_TEXT_PORT="${EMBEDDING_TEXT_PORT:-${EMBEDDING_PORT:-6005}}" +EMBEDDING_IMAGE_PORT="${EMBEDDING_IMAGE_PORT:-6008}" echo "" echo "1. 监听端口 6004 的进程(Indexer 服务)" @@ -37,10 +38,10 @@ else fi echo "" -echo "3. 连接到 6005 的客户端(Indexer 会调用 Embedding 服务)" +echo "3. 连接到 Embedding 服务的客户端" echo "------------------------------------------" if command -v ss >/dev/null 2>&1; then - ss -tnp 2>/dev/null | grep ":${EMBEDDING_PORT}" || echo " (当前无活跃连接)" + ss -tnp 2>/dev/null | grep -E ":${EMBEDDING_TEXT_PORT}|:${EMBEDDING_IMAGE_PORT}" || echo " (当前无活跃连接)" fi echo "" @@ -63,12 +64,13 @@ echo " 全量: curl -X POST http://localhost:${INDEXER_PORT}/indexer/reindex echo " 增量: curl -X POST http://localhost:${INDEXER_PORT}/indexer/index -d '{\"tenant_id\":\"170\",\"spu_ids\":[\"123\"]}'" echo "" echo " - Indexer 内部会调用:" -echo " - Embedding 服务 (${EMBEDDING_PORT}): POST /embed/text" +echo " - Text Embedding 服务 (${EMBEDDING_TEXT_PORT}): POST /embed/text" +echo " - Image Embedding 服务 (${EMBEDDING_IMAGE_PORT}): POST /embed/image" echo " - Qwen API: dashscope.aliyuncs.com (翻译、LLM 分析)" echo " - MySQL: 商品数据" echo " - Elasticsearch: 写入索引" echo "" echo "6. 实时监控连接(按 Ctrl+C 停止)" echo "------------------------------------------" -echo " 运行: watch -n 2 'ss -tnp | grep -E \":${INDEXER_PORT}|:${EMBEDDING_PORT}\"'" +echo " 运行: watch -n 2 'ss -tnp | grep -E \":${INDEXER_PORT}|:${EMBEDDING_TEXT_PORT}|:${EMBEDDING_IMAGE_PORT}\"'" echo "" diff --git a/utils/__init__.py b/utils/__init__.py index cee112f..55203f3 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -2,7 +2,7 @@ from .db_connector import create_db_connection, get_connection_from_config, test_connection from .es_client import ESClient, get_es_client_from_env -from .cache import EmbeddingCache, DictCache +from .cache import DictCache __all__ = [ 'create_db_connection', @@ -10,6 +10,5 @@ __all__ = [ 'test_connection', 'ESClient', 'get_es_client_from_env', - 'EmbeddingCache', 'DictCache', ] diff --git a/utils/cache.py b/utils/cache.py index 26abea0..6d28209 100644 --- a/utils/cache.py +++ b/utils/cache.py @@ -1,94 +1,8 @@ -""" -Cache utility for storing embedding results. -""" +"""Small file-backed cache helpers.""" import json -import hashlib -import pickle from pathlib import Path from typing import Any, Optional -import numpy as np - - -class EmbeddingCache: - """ - Simple file-based cache for embeddings. - - Uses MD5 hash of input text/URL as cache key. - """ - - def __init__(self, cache_dir: str = ".cache/embeddings"): - self.cache_dir = Path(cache_dir) - self.cache_dir.mkdir(parents=True, exist_ok=True) - - def _get_cache_key(self, input_str: str) -> str: - """Generate cache key from input string.""" - return hashlib.md5(input_str.encode('utf-8')).hexdigest() - - def get(self, input_str: str) -> Optional[np.ndarray]: - """ - Get cached embedding. - - Args: - input_str: Input text or URL - - Returns: - Cached embedding or None if not found - """ - cache_key = self._get_cache_key(input_str) - cache_file = self.cache_dir / f"{cache_key}.npy" - - if cache_file.exists(): - try: - return np.load(cache_file) - except Exception as e: - print(f"Failed to load cache for {input_str}: {e}") - return None - return None - - def set(self, input_str: str, embedding: np.ndarray) -> bool: - """ - Store embedding in cache. - - Args: - input_str: Input text or URL - embedding: Embedding vector - - Returns: - True if successful - """ - cache_key = self._get_cache_key(input_str) - cache_file = self.cache_dir / f"{cache_key}.npy" - - try: - np.save(cache_file, embedding) - return True - except Exception as e: - print(f"Failed to cache embedding for {input_str}: {e}") - return False - - def exists(self, input_str: str) -> bool: - """Check if embedding is cached.""" - cache_key = self._get_cache_key(input_str) - cache_file = self.cache_dir / f"{cache_key}.npy" - return cache_file.exists() - - def clear(self) -> int: - """ - Clear all cached embeddings. - - Returns: - Number of files deleted - """ - count = 0 - for cache_file in self.cache_dir.glob("*.npy"): - cache_file.unlink() - count += 1 - return count - - def size(self) -> int: - """Get number of cached embeddings.""" - return len(list(self.cache_dir.glob("*.npy"))) class DictCache: -- libgit2 0.21.2