# Elasticsearch → OpenSearch 迁移方案

> 创建日期: 2026-03-10
> 状态: 实施中

---

## 1. 背景与动机

当前 zm-rag 使用 Elasticsearch 8.19.12 作为向量+全文检索引擎。RRF（Reciprocal Rank Fusion）混合检索是系统核心功能，但 ES 要求 Platinum 以上许可证才能使用 RRF。

**当前方案的问题**：
- ES trial license 仅 30 天有效期（到期后 RRF 失效）
- 尝试 patched x-pack-core.jar + 自定义 platinum license：**会破坏 RRF 功能**（返回 403）
- 无法获得正式 Platinum license

**OpenSearch 方案的优势**：
- OpenSearch 是 ES 7.10 的 Apache 2.0 开源分支（AWS 主导）
- OpenSearch 2.19+ 的 RRF 混合检索**完全免费**，无许可证限制
- 功能上完全满足项目需求（BM25 + kNN + RRF + IK 分词）

---

## 2. 可行性评估

| 维度 | 评估 |
|------|------|
| **可行性** | ✅ 完全可行 |
| **改动量** | 中等（~12 个文件，核心改动集中在 3 个文件） |
| **风险** | 低-中（查询语法差异是主要风险，但有完整 fallback 机制） |
| **收益** | 彻底解决许可证问题、RRF 免费永久可用、Apache 2.0 无法律风险 |

---

## 3. 核心差异对照

| 功能 | ES 8.19 | OpenSearch 2.19 | 改动量 |
|------|---------|-----------------|--------|
| Python 客户端 | `elasticsearch[async]` | `opensearch-py[async]` | ~10 处 import |
| 向量字段类型 | `dense_vector` | `knn_vector` + `index.knn: true` | 1 处 mapping |
| RRF 混合检索 | `retriever` API | `hybrid` query + search pipeline | 2 处查询重写 |
| kNN 查询 | 嵌在 retriever 内 | 独立 `knn` query type | 随 RRF 一起改 |
| 同义词 filter | `updateable: true` 可选 | 不支持 updateable | 1 行删除 |
| 认证参数 | `basic_auth` | `http_auth` | ~6 处 |
| 许可证 | Trial/Platinum（需管理） | Apache 2.0（免费永久） | 删除 init-license |
| 可视化 | Kibana | OpenSearch Dashboards | Docker 换镜像 |
| IK 分词器 | infini.cloud ES 版 | infini.cloud OpenSearch 版 | Docker 换 URL |

**完全兼容（无需改动）的功能**：
- BM25 查询（`multi_match`, `bool`, `terms`, `range`）
- `collapse` 去重 + `inner_hits`
- 聚合（`cardinality`, `terms`, `date_histogram`）
- Highlight
- Bulk 索引（`async_bulk`）
- Painless 脚本（`update_by_query`, `update` with script）
- `match_phrase_prefix`（自动补全）
- 索引管理（`create`, `exists`, `delete`）
- 文档 CRUD（`get`, `index`, `delete`, `delete_by_query`, `count`）

---

## 4. 文件变更清单

### 4.1 后端代码（8 个文件）

#### `backend/pyproject.toml` — 依赖替换
```
# 替换前
elasticsearch[async]>=8.15.0

# 替换后
opensearch-py[async]>=2.4.0
```

#### `backend/app/infrastructure/es_client.py` — 核心改动（最大）
- Import: `AsyncElasticsearch` → `AsyncOpenSearch`
- Import: `elasticsearch.helpers` → `opensearchpy.helpers`
- 认证参数: `basic_auth=(user, pass)` → `http_auth=(user, pass)`
- 向量 mapping: `"type": "dense_vector"` → `"type": "knn_vector"` + 索引设置 `"index.knn": True`
- 移除同义词 filter 中的 `"updateable": True`
- `create_indices()` 末尾添加创建 RRF search pipeline

OpenSearch RRF search pipeline 示例：
```python
pipeline_body = {
    "description": "RRF hybrid search pipeline",
    "phase_results_processors": [{
        "normalization-processor": {
            "normalization": {"technique": "min_max"},
            "combination": {
                "technique": "rrf",
                "parameters": {"rank_constant": 60},
            },
        }
    }],
}
await self._client.http.put(
    "/_search/pipeline/hybrid_rrf_pipeline",
    body=pipeline_body,
)
```

#### `backend/app/core/search_engine.py` — RRF 查询重写
```python
# ES 8.14+ retriever API（当前写法）
body = {
    "retriever": {
        "rrf": {
            "retrievers": [
                {"standard": {"query": bm25_query}},
                {"knn": {"field": "content_vector", ...}},
            ],
            "rank_window_size": rrf_window,
            "rank_constant": 60,
        }
    }
}

# OpenSearch hybrid query（迁移后）
body = {
    "query": {
        "hybrid": {
            "queries": [
                bm25_query,
                {"knn": {"content_vector": {"vector": [...], "k": N}}},
            ]
        }
    }
}
# 搜索时指定 pipeline 参数：
response = await client.search(
    index=..., body=body,
    params={"search_pipeline": "hybrid_rrf_pipeline"},
)
```

#### `backend/app/core/research_engine.py` — 同上模式

#### Celery 任务和脚本（4 个文件，统一改动模式）
- `backend/app/core/ingest_pipeline.py`
- `backend/app/tasks/ingest_task.py`
- `backend/app/tasks/graph_task.py`
- `backend/scripts/bulk_build_graph.py`

改动模式：`AsyncElasticsearch` → `AsyncOpenSearch`，`basic_auth` → `http_auth`

### 4.2 Docker 配置（3 个文件）

#### 新建 `docker/opensearch/Dockerfile`
```dockerfile
FROM opensearchproject/opensearch:2.19.0
USER root
RUN /usr/share/opensearch/bin/opensearch-plugin install --batch \
      https://get.infini.cloud/opensearch/analysis-ik/2.19.0
COPY analysis/gov_synonyms.txt \
     /usr/share/opensearch/config/analysis/gov_synonyms.txt
USER opensearch
```

#### 更新 `docker/docker-compose.yml`
- `elasticsearch` service → `opensearch`
- `xpack.security.enabled=false` → `plugins.security.disabled=true`
- 删除 `es-init` service（不需要许可证管理）
- `kibana` → `opensearch-dashboards`

#### 删除文件
- `docker/elasticsearch/init-license.sh`

---

## 5. 最终效果对比

| 指标 | 迁移前（ES 8.19） | 迁移后（OpenSearch 2.19） |
|------|-------------------|--------------------------|
| 许可证 | Trial（30 天过期） | Apache 2.0（永久免费） |
| RRF 混合检索 | ✅ 可用（需 trial） | ✅ 可用（永久免费） |
| 搜索质量 | BM25 + kNN + RRF | BM25 + kNN + RRF（等效） |
| 中文分词 | IK 插件 | IK 插件（同源） |
| 向量检索 | HNSW cosine | HNSW cosine（等效） |
| 可视化 | Kibana | OpenSearch Dashboards |
| 安全功能 | 需 Platinum | 免费内置 |
| 运维复杂度 | 需管理许可证 | 零许可证管理 |

---

## 6. 风险与缓解

| 风险 | 严重度 | 缓解措施 |
|------|--------|----------|
| `hybrid` query 与聚合的兼容性 | 中 | 如不兼容，拆分为两个请求（hybrid 取结果 + BM25 取聚合） |
| IK 插件 OpenSearch 版本匹配 | 低 | infini.cloud 维护了对应版本，构建时验证 |
| `opensearch-py` 参数名差异 | 低 | 集中在 `es_client.py`，改动量小 |
| 数据需要重新入库 | 低 | 向量字段类型变了，需重建索引并重新入库（开发环境直接重灌） |

---

## 7. 验证方案

1. **Docker 启动**：`docker compose up -d` → OpenSearch healthy + Dashboards 可访问
2. **索引创建**：启动后端 → 日志显示 `es_index_created` × 2 + `search_pipeline_created`
3. **文档入库**：通过 MockOA 上传 PDF → Celery worker 完成入库 → status=completed
4. **RRF 搜索**：`POST /api/v1/search` → 返回 200 + 有结果 + 日志无 fallback 警告
5. **BM25 fallback**：禁用 embedding → 搜索仍返回结果（BM25 模式）
6. **Research**：`POST /api/v1/research` → SSE 流正常返回
7. **聚合**：搜索结果包含 `by_org`, `by_type`, `by_year` 分面数据
8. **权限过滤**：不同用户搜索返回不同文档集
