Metadata-Version: 2.4
Name: openai-simple-vectorstore
Version: 0.3.3
Summary: 可扩展的多向量数据库接入库（redis-search / milvus / milvus-lite / pgvector / elasticsearch / sqlite-vec / qdrant），集成 embeddings 与 rerank 模型，支持二阶段召回及插入、删除、刷新等管理功能。
Author: rRR0VrFP
Maintainer: rRR0VrFP
License-Expression: MIT
Project-URL: Homepage, https://gitee.com/rRR0VrFP/openai-simple-vectorstore
Keywords: openai-simple-vectorstore,vectorstore,redis-search,milvus,milvus-lite,pgvector,elasticsearch,sqlite-vec,qdrant,embeddings,rerank
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: PyYAML
Requires-Dist: httpx
Requires-Dist: numpy
Requires-Dist: pydantic>=2
Requires-Dist: python-environment-settings
Requires-Dist: zenutils
Provides-Extra: redis
Requires-Dist: redis; extra == "redis"
Requires-Dist: redisvl>=0.27.1; extra == "redis"
Provides-Extra: milvus
Requires-Dist: pymilvus>=2.4; extra == "milvus"
Provides-Extra: milvus-lite
Requires-Dist: pymilvus>=2.4; extra == "milvus-lite"
Requires-Dist: milvus-lite; extra == "milvus-lite"
Provides-Extra: pgvector
Requires-Dist: psycopg2-binary; extra == "pgvector"
Provides-Extra: elasticsearch
Requires-Dist: elasticsearch>=8.0; extra == "elasticsearch"
Provides-Extra: sqlite-vec
Requires-Dist: sqlite-vec; extra == "sqlite-vec"
Provides-Extra: qdrant
Requires-Dist: qdrant-client>=1.7; extra == "qdrant"
Provides-Extra: all
Requires-Dist: redis; extra == "all"
Requires-Dist: redisvl>=0.27.1; extra == "all"
Requires-Dist: pymilvus>=2.4; extra == "all"
Requires-Dist: milvus-lite; extra == "all"
Requires-Dist: psycopg2-binary; extra == "all"
Requires-Dist: elasticsearch>=8.0; extra == "all"
Requires-Dist: sqlite-vec; extra == "all"
Requires-Dist: qdrant-client>=1.7; extra == "all"
Dynamic: license-file

# openai-simple-vectorstore

可扩展的多向量数据库接入库，内置 **redis-search**、**milvus**、**milvus-lite**、
**pgvector**、**elasticsearch**、**sqlite-vec** 与 **qdrant** 等后端，并提供统一的 embeddings / rerank
二阶段召回、插入、删除、刷新等管理接口。

- redis-search 后端与 [openai-redis-vectorstore](https://gitee.com/rRR0VrFP/openai-redis-vectorstore)
  行为兼容（相同的 uid 规则与 relevance score，生成的 key 布局一致），但索引 `PREFIX` 由
  上游的 `index_name` 改为 `index_name + ":"`，以隔离同名前缀的其它索引（见版本记录）。
- 通过工厂 + 注册表机制可低成本扩展其它向量数据库后端。

## 安装

需要 Python >= 3.10。作为业务方使用时，从 PyPI 安装本库并选择所需后端：

```bash
# 仅核心依赖（不含任何后端驱动，需配合下面的某个后端 extra 使用）
pip3 install openai-simple-vectorstore

# redis-search 后端（也是 create_vector_store() 的默认后端）
pip3 install "openai-simple-vectorstore[redis]"

# 仅 milvus 后端（依赖 pymilvus）
pip3 install "openai-simple-vectorstore[milvus]"

# 仅 milvus-lite 后端（嵌入式 milvus，无需单独部署，适合本地开发）
pip3 install "openai-simple-vectorstore[milvus-lite]"

# 仅 pgvector 后端（依赖 psycopg2-binary）
pip3 install "openai-simple-vectorstore[pgvector]"

# 仅 elasticsearch 后端
pip3 install "openai-simple-vectorstore[elasticsearch]"

# 仅 sqlite-vec 后端（嵌入式 SQLite，无需单独部署）
pip3 install "openai-simple-vectorstore[sqlite-vec]"

# 仅 qdrant 后端（依赖 qdrant-client）
pip3 install "openai-simple-vectorstore[qdrant]"

# 全部后端
pip3 install "openai-simple-vectorstore[all]"
```

> 在仓库源码目录内本地开发时使用 `pip3 install -e ".[all]"`；离线安装本库自身依赖时，
> 先准备本地 wheel 目录，再执行
> `pip3 install --no-index --find-links <wheel 目录> -r requirements.txt`。

## 快速开始

### 选择 redis-search 后端（默认）

> 需先安装 redis 后端：`pip3 install "openai-simple-vectorstore[redis]"`。

```python
from openai_simple_vectorstore import create_vector_store

vs = create_vector_store()  # 默认后端由 OPENAI_SIMPLE_VECTORSTORE_VECTOR_DB 决定

# 插入
uid = vs.insert("今天天气很好", kb_id="kb1", doc_id="doc1", page_id="p1")

# 二阶段召回（向量检索 + rerank 重排）
docs = vs.similarity_search_and_rerank(
    query="今天天气怎么样",
    index_name="default",
    k=3,
)
for doc in docs:
    print(doc.vs_page_content, doc.vs_embeddings_score, doc.vs_rerank_score)

# 删除 / 刷新
vs.delete(uid)
vs.flush("default")
```

### 选择 milvus 后端

```python
from openai_simple_vectorstore import create_vector_store

vs = create_vector_store(  # 或环境变量 OPENAI_SIMPLE_VECTORSTORE_VECTOR_DB=milvus
    vector_db="milvus"
)
vs.insert("..." , kb_id="kb1", doc_id="doc1", page_id="p1")
docs = vs.similarity_search_and_rerank(query="...", index_name="default", k=3)
```

### 选择 milvus-lite 后端（嵌入式，本地开发）

milvus-lite 与 milvus 使用相同的 `MilvusClient` API，仅连接方式不同：milvus 通过
`http://host:19530` 连接服务端，milvus-lite 通过本地文件路径启动进程内嵌入式数据库，
无需部署 milvus 服务。

```python
from openai_simple_vectorstore import create_vector_store

vs = create_vector_store(
    vector_db="milvus-lite",  # 或环境变量 OPENAI_SIMPLE_VECTORSTORE_VECTOR_DB=milvus-lite
    milvus_lite_uri="./milvus_lite.db",  # 默认 OPENAI_SIMPLE_VECTORSTORE_MILVUS_LITE_URI
)
vs.insert("..." , kb_id="kb1", doc_id="doc1", page_id="p1")
docs = vs.similarity_search_and_rerank(query="...", index_name="default", k=3)
```


### 选择 sqlite-vec 后端（嵌入式，本地开发）

sqlite-vec 是 SQLite 的向量搜索扩展，所有数据存在本地文件中，无需单独部署服务。
注意：需要 sqlite3 支持 loadable-extension（多数发行版 CPython 均支持）。

```python
from openai_simple_vectorstore import create_vector_store

vs = create_vector_store(
    vector_db="sqlite-vec",  # 或环境变量 OPENAI_SIMPLE_VECTORSTORE_VECTOR_DB=sqlite-vec
    sqlite_vec_uri="./sqlite_vec.db",  # 默认 OPENAI_SIMPLE_VECTORSTORE_SQLITE_VEC_URI
)
vs.insert("..." , kb_id="kb1", doc_id="doc1", page_id="p1")
docs = vs.similarity_search_and_rerank(query="...", index_name="default", k=3)
```

### 选择 pgvector 后端（PostgreSQL 扩展）

需要 PostgreSQL 已安装 pgvector 扩展（程序启动时会尝试 `CREATE EXTENSION IF NOT EXISTS vector`）：

```python
from openai_simple_vectorstore import create_vector_store

vs = create_vector_store(
    vector_db="pgvector",  # 或环境变量 OPENAI_SIMPLE_VECTORSTORE_VECTOR_DB=pgvector
    pgvector_url="postgresql://user:pass@localhost:5432/postgres",  # 默认 OPENAI_SIMPLE_VECTORSTORE_PGVECTOR_URL
)
vs.insert("..." , kb_id="kb1", doc_id="doc1", page_id="p1")
docs = vs.similarity_search_and_rerank(query="...", index_name="default", k=3)
```

### 选择 elasticsearch 后端

需要 Elasticsearch 8.x（dense_vector + HNSW）：

```python
from openai_simple_vectorstore import create_vector_store

vs = create_vector_store(
    vector_db="elasticsearch",  # 或环境变量 OPENAI_SIMPLE_VECTORSTORE_VECTOR_DB=elasticsearch
    es_url="http://localhost:9200",
)
vs.insert("..." , kb_id="kb1", doc_id="doc1", page_id="p1")
docs = vs.similarity_search_and_rerank(query="...", index_name="default", k=3)
```

### 选择 qdrant 后端

需要部署 Qdrant 服务（或使用 Qdrant Cloud）。使用原生 payload 过滤，无需像
milvus / sqlite-vec 那样对候选集做放大：

```python
from openai_simple_vectorstore import create_vector_store

vs = create_vector_store(
    vector_db="qdrant",  # 或环境变量 OPENAI_SIMPLE_VECTORSTORE_VECTOR_DB=qdrant
    qdrant_url="http://localhost:6333",  # 默认 OPENAI_SIMPLE_VECTORSTORE_QDRANT_URL
    qdrant_api_key=None,                 # 默认 OPENAI_SIMPLE_VECTORSTORE_QDRANT_API_KEY
    # qdrant_prefer_grpc=True,            # 默认 OPENAI_SIMPLE_VECTORSTORE_QDRANT_PREFER_GRPC
    # qdrant_timeout=60,                  # 客户端请求超时（秒），默认 OPENAI_SIMPLE_VECTORSTORE_QDRANT_TIMEOUT
    # qdrant_on_disk=False,               # 总开关，默认 OPENAI_SIMPLE_VECTORSTORE_QDRANT_ON_DISK（False）
    # qdrant_vector_on_disk=True,         # 向量是否落盘，覆盖总开关
    # qdrant_hnsw_on_disk=True,           # HNSW 图是否落盘，覆盖总开关
    # qdrant_payload_on_disk=True,        # payload 索引是否落盘，覆盖总开关
)
vs.insert("..." , kb_id="kb1", doc_id="doc1", page_id="p1")
docs = vs.similarity_search_and_rerank(query="...", index_name="default", k=3)
```

qdrant 的向量、HNSW 图、payload 索引三部分是否落盘可**分别**配置：
`qdrant_vector_on_disk` / `qdrant_hnsw_on_disk` / `qdrant_payload_on_disk`
（对应环境变量 `OPENAI_SIMPLE_VECTORSTORE_QDRANT_{VECTOR,HNSW,PAYLOAD}_ON_DISK`）。
三者默认均为 `False`，即默认全部驻留内存，以获得最低检索延迟。`qdrant_on_disk`
是给三者兜底的总开关（默认 `False`）：未单独设置的项会继承它，单独设置项优先。
落盘（mmap）可避免大图常驻内存，但检索时的随机读会变慢，建议搭配 NVMe SSD。

本地快速启动（podman）：

```bash
podman run -d --name qdrant -p 6333:6333 -p 6334:6334 docker.io/qdrant/qdrant:latest
```

### 直接实例化（不依赖全局配置）

```python
from openai_simple_vectorstore import RedisVectorStore
from openai_simple_vectorstore.base import Connection
from openai_simple_vectorstore.utils import YamlSerializer

vs = RedisVectorStore(
    redis_stack_url="redis://localhost:6379/0",
    embeddings_llm=Connection(base_url="http://localhost/v1", api_key="sk-xxx"),
    rerank_llm=Connection(base_url="http://localhost/v1", api_key="sk-xxx"),
    embeddings_model="bge-m3",
    rerank_model="bge-reranker-v2-m3",
    metadata_serializer=YamlSerializer(),
)
```

- `metadata_serializer` 决定业务自定义元数据（`metadata` / `vs_metadata`）持久化时的序列化方式，
  默认 `YamlSerializer`；也可传入 `JsonSerializer`（均定义于 `openai_simple_vectorstore.utils`）。

## 使用说明

`VectorStore` 对业务屏蔽后端差异，统一提供写入 / 检索 / 读取 / 删除 / 索引管理接口。
下面所有示例中的 `vs` 均指已创建的 `VectorStore` 实例。

### 索引名归一化（逻辑名 → 物理名）

公开接口接收的是**逻辑索引名**，库内会按当前实例的后端规则归一化为物理合法名，
调用方无需（也不应）自己替换字符。逻辑名可以包含 `:`、`-`、`.` 等字符：

```python
# 逻辑名直接传给所有公开接口：写入 / 检索 / 读取 / 删除 / flush / schema / 多索引检索
vs.insert("今天天气很好", index_name="noah-ai-kb")
vs.insert("另一条", index_name="faq:kb")

# 排查或迁移时查看实际落到后端的物理名（按当前实例后端规则）
vs.get_index_name("noah-ai-kb")   # qdrant/milvus: "noah_ai_kb"；redis/es/pgvector/sqlite-vec: "noah-ai-kb"
vs.get_index_name("faq:kb")       # 所有后端: "faq_kb"
vs.normalize_index_name("faq:kb") # 与 get_index_name 相同（显式传名时）

# 不需要实例时可用模块级函数；它只实现 qdrant/milvus 系列的完整归一化规则
from openai_simple_vectorstore.base import normalize_index_name
normalize_index_name("faq:kb")    # "faq_kb"
normalize_index_name("noah-ai-kb")  # "noah_ai_kb"
```

| 后端 | 规则 | 示例 |
| --- | --- | --- |
| `qdrant` / `milvus` / `milvus-lite` | `[^A-Za-z0-9_]` → `_`；数字开头前置 `_`；空名回退 `_index`；截断到 255 | `noah-ai-kb` → `noah_ai_kb` |
| `redis` / `elasticsearch` / `pgvector` / `sqlite-vec` | 仅把 uid 分隔符 `:` 替换为 `_`，保留连字符等其余字符 | `faq:kb` → `faq_kb`，`noah-ai-kb` 不变 |

注意事项：

- **统一使用逻辑名**：不要自行 `replace(":", "_")` 或按后端手写归一化，否则容易与写入侧
  落到不同物理索引；需要展示/迁移时用 `vs.get_index_name(...)` 获取物理名即可。
- **uid 前缀是归一化后的索引名**：`vs.insert(..., index_name="faq:kb")` 返回
  `faq_kb:p1`；`get_item` / `delete` 可原样传入该 uid。
- **与上层封装一致**：规则与 `django-vectorstore-indexed-model` 对齐，ORM 与本库直连
  使用同一逻辑名时会命中同一物理索引。
- **0.3.3 升级提示**：旧版本对含 `:` 的索引名直接抛 `ValueError`，现在改为归一化；
  qdrant / milvus 系列中旧版本以连字符等非 `[A-Za-z0-9_]` 字符建出的 collection 会按
  新物理名查找。仍在使用的旧索引需按新名迁移数据，或显式 `rebuild_index` 重建。
- **自定义后端**：在子类上声明 `backend = "mydb"`；若其后端允许的字符集与默认规则不同，
  一并覆盖 `normalize_index_name()`。

### 写入

```python
# 单条：返回 uid（格式 <index_name>:<page_id>）
uid = vs.insert(
    "今天天气很好",
    page_id="p1",          # 缺省自动生成 uuid4
    kb_id="kb1",           # 缺省 "default"
    doc_id="doc1",         # 缺省 "default"
    category="news",       # 缺省 "default"
    index_name="default",
    metadata={"author": "alice", "score": 9.5},  # 业务自定义元数据，随记录持久化
)

# 批量：返回 uid 列表；texts / metadatas / page_ids 长度必须一致
uids = vs.insert_many(
    ["文本一", "文本二"],
    metadatas=[{"author": "alice"}, {"author": "bob"}],
    page_ids=["p1", "p2"],
    kb_id="kb1",
    index_name="default",
)
```

> 同一 `page_id` 重复写入是**覆盖**（upsert 语义），不会产生重复记录；调用方传入的
> `metadata` / `metadatas` 不会被就地修改。

### 元数据与序列化

核心字段（`vs_kb_id` / `vs_doc_id` / `vs_page_id` / `vs_category`）与业务自定义
`metadata` 一起持久化。`get_item` 返回**平铺的元数据 dict**；检索结果则把非标准字段
收进 `Document.vs_metadata`：

```python
uid = vs.insert(
    "今天天气很好",
    page_id="p1",
    kb_id="kb1",
    index_name="faq:kb",
    metadata={
        "author": "alice",
        "level": 2,
        "tags": ["news", "weather"],
        "extra": {"source": "crawler"},   # 可嵌套，随序列化器持久化
    },
)

item = vs.get_item(uid)
item["vs_page_id"]       # "p1"（核心字段平铺）
item["author"]           # "alice"
item["extra"]            # {"source": "crawler"}

doc = vs.similarity_search_with_relevance_scores("天气", index_name="faq:kb", k=1)[0]
doc.vs_metadata          # {"author": "alice", "level": 2, "tags": [...], "extra": {...}}
```

- 非核心字段默认用 `YamlSerializer`（`safe_dump` / `safe_load`）序列化；可改用
  `JsonSerializer`，或实现 `openai_simple_vectorstore.utils.Serializer` 的
  `dumps` / `loads` 自定义：

  ```python
  from openai_simple_vectorstore.utils import JsonSerializer
  vs = create_vector_store(metadata_serializer=JsonSerializer())
  ```

- 自定义元数据**默认只持久化、不可过滤**；要作为 `Filter` 目标，需在实例构造时用
  `filterable_metadata_fields` / `declared_fields` 声明（见下节）。
- 反序列化失败时保留存储原值，不会让整条记录读取失败。

### 检索

```python
# 仅向量检索：相关度 = 1 - distance，返回按相关度降序的 [Document, ...]
docs = vs.similarity_search_with_relevance_scores(
    "今天天气怎么样",
    index_name="default",
    k=5,
    kb_id="kb1",                       # 快捷过滤，等价 filters=[Filter("vs_kb_id", ["kb1"])]
    categories=["news", "tech"],       # 多值：命中其一即可
    embeddings_score_threshold=0.3,    # 低于阈值的结果直接丢弃（None 表示不过滤）
)

# 二阶段召回：先向量检索取 k*scale 条候选，再经 rerank 重排取前 k
docs = vs.similarity_search_and_rerank(
    "今天天气怎么样",
    index_name="default",
    k=3,
    scale=3,                           # 候选数 = k * scale，默认 2
    kb_ids=["kb1", "kb2"],
    rerank_score_threshold=0.5,
)

# 多索引联合检索（按 uid 去重、统一排序）
docs = vs.similarity_search_with_relevance_scores(
    "查询", index_names=["idx_a", "idx_b"], k=10,
)

# 需要命中记录的原始向量时显式开启（默认 False，见下方字段说明）
docs = vs.similarity_search_with_relevance_scores(
    "查询", index_name="default", k=5, include_embedding=True,
)
```

> `include_embedding` 默认 `False`：搜索命中通常不需要原始向量，默认不取回可将
> 取数从 O(k) 次网络往返降到 0（qdrant 直接复用检索 payload）；`vs_embedding` 因
> 此为 `None`。需要向量时传 `include_embedding=True` 恢复取回。该参数在
> `similarity_search_with_relevance_scores` 与 `similarity_search_and_rerank` 上均可用。

> 多索引联合检索时：
> - `index_names` 优先于 `index_name`；每个名字都会按后端规则归一化。
> - 各索引结果**按 uid 去重**后统一按相关度降序排序，再取前 `k` 条；uid 含索引名，
>   因此不同索引里的相同 `page_id` 视为不同记录。
> - query embedding 只与查询文本和 embeddings 配置有关，只计算一次并复用于所有索引，
>   避免重复请求。
> - 某个索引不存在时按空结果处理，不影响其它索引。

### 读取 / 删除 / 清空

```python
item = vs.get_item(uid)      # 返回元数据 dict（含 embedding 字节）；不存在返回 {}
count = vs.delete(uid)       # 返回实际删除条数
count = vs.delete_many(uids) # 按 index_name 自动分组批量删除
uids = list(vs.iter_uids("default"))  # 枚举该索引下全部 uid（<index_name>:<page_id>）
count = vs.count_uids("default")      # 枚举计数（基于 iter_uids）
vs.flush("default")          # 清空该索引数据（破坏性；是否删除底层索引/表随后端而异）
```

> `iter_uids(index_name=None)` 只读遍历索引下全部 uid，返回格式与 `insert` 一致
> （`<index_name>:<page_id>`），不加载向量与内容、按后端分页拉取（scroll / scan /
> `search_after` / keyset），因此不会像 `keys *` 那样一次性载入全量 key。索引不存在
> 时返回空迭代、不创建索引；`index_name` 经 `get_index_name` 按后端规则归一化，传入
> 逻辑名即可命中写入侧同一物理索引。
> `count_uids` 是通用实现，等价于 `len(list(iter_uids(...)))`。

> `delete` / `delete_many` 返回**实际删除条数**。milvus standalone 的服务端
> `delete_count` 会把请求中不存在的 id 也计入，因此本库以删除前实际存在的条数为准；
> milvus-lite 本身返回真实计数。

> `flush` 一律清空该索引下的数据（破坏性）。是否同时删除底层索引 / 表随后端而异：
> redis / milvus / milvus-lite / qdrant / sqlite-vec 会删除索引或表；elasticsearch
> 仅清空文档并保留 index 与 mapping；pgvector 删除共享表中该索引对应的行。

### 枚举 uid（一致性稽核 / 孤儿清理）

`iter_uids` 只读、分页地遍历索引下的全部 uid，不加载向量与正文，适合做全量稽核或
清理“业务侧已删除、向量库仍残留”的孤儿记录：

```python
# 逐个遍历（生成器，内存占用与索引大小无关）
for uid in vs.iter_uids("faq:kb"):
    print(uid)                 # 形如 "faq_kb:p1"（前缀是归一化后的索引名）

# 统计数量：通用实现等价于 len(list(iter_uids(...)))，会全量扫描
total = vs.count_uids("faq:kb")

# 与业务侧仍有效的 page_id 集合比对，删除孤儿向量
valid_page_ids = {"p1", "p2"}
orphans = [
    uid for uid in vs.iter_uids("faq:kb")
    if uid.split(":", 1)[1] not in valid_page_ids
]
for uid in orphans:
    vs.delete(uid)
```

注意事项：

- **索引不存在时返回空迭代**，且不会创建索引；需要区分“索引不存在”与“索引为空”时，
  先用 `describe_index(index_name)` 查看 `exists`。
- **`count_uids` 不是原生计数**，后端没有更廉价的计数接口时会遍历全部 uid；
  超大索引请评估耗时。
- **不是快照**：遍历期间若有并发写入 / 删除，结果可能包含新增记录或漏掉已删除记录；
  稽核 / 清理类任务建议在低峰或停写时执行。
- **redis 基于 `SCAN`**：可能重复返回同一个 key（Redis 允许，库不做去重），
  需要唯一结果时在调用方自行 `set(...)` 去重。
- **第三方后端**：未实现 `_engine_for_index` 时抛 `NotImplementedError`（与 `get_item`
  一致）；redis 这类“key 即完整 uid”的后端可直接覆盖 `iter_uids`。

### 检索结果字段（`Document`）

`similarity_search_*` 返回 `Document`，可用 `document_schema=YourModel` 换成自定义模型：

| 字段 | 含义 |
| --- | --- |
| `vs_page_content` | 原始文本内容 |
| `vs_embedding` | 向量（float32 字节）；默认不取回（`None`），传 `include_embedding=True` 才填充 |
| `vs_embeddings_score` | 向量相关度 `1 - distance`，取值 `[0, 1]` |
| `vs_rerank_score` | rerank 分数（仅 `similarity_search_and_rerank`） |
| `vs_uid` | `<index_name>:<page_id>` |
| `vs_index_name` | 命中的索引名 |
| `vs_kb_id` / `vs_doc_id` / `vs_page_id` / `vs_category` | 核心元数据 |
| `vs_metadata` | 业务自定义元数据 `dict`（如 `{"author": ..., "score": ...}`） |

每次检索可用 `document_schema` 换成自定义 Pydantic 模型，只取需要的字段：

```python
from typing import Optional
from pydantic import BaseModel

class MyHit(BaseModel):
    vs_uid: Optional[str] = None
    vs_page_content: Optional[str] = None
    vs_metadata: Optional[dict] = None

hits = vs.similarity_search_with_relevance_scores(
    "查询", index_name="default", k=5, document_schema=MyHit,
)
```

> `document_schema` 是**单次调用参数**（不是实例构造参数），字段应为 `Document` 的子集。
> 省略 `vs_embeddings_score` 不影响排序（内部使用计算的分数）；但二阶段召回
> `similarity_search_and_rerank` 必须能取到 `vs_page_content`，自定义模型缺该字段会
> 无法 rerank 并报错。

### 过滤

```python
from openai_simple_vectorstore.base import Filter

# tag 精确过滤：values 内多值为 OR 命中
docs = vs.similarity_search_with_relevance_scores(
    "查询", index_name="default", k=5,
    filters=[Filter(field="author", values=["alice", "bob"])],
)

# 多个 Filter 之间为 AND；numeric 用 op="range" 传 [low, high]
docs = vs.similarity_search_and_rerank(
    "查询", k=5,
    filters=[
        Filter(field="author", values=["alice"]),
        Filter(field="score", values=[7, 10], op="range"),
    ],
)

# 也接受 dict（等价 tag 精确过滤，多键为 AND）；dict 形式不支持 range
docs = vs.similarity_search_with_relevance_scores(
    "查询", k=5, filters={"author": "alice", "lang": "zh"},
)
```

> 过滤字段必须先在实例上声明，未声明 / 非法取值（空值、`None`、`low > high`）会明确报错，
> 而不是静默忽略。单个 `Filter` / `dict` 可直接传入（不必包成 list）；字符串取值按
> 单个值处理，不会被逐字符拆分；集合取值会排序以保证结果可复现。

### 声明可过滤字段

业务自定义元数据默认会随记录持久化并在 `vs_metadata` 中返回；**要作为过滤条件，必须显式声明**：

```python
from openai_simple_vectorstore.base import IndexField

# 简单方式：一批 tag 字段
vs = create_vector_store(filterable_metadata_fields=["author", "lang"])

# 精细方式（与上面二选一；一旦提供 declared_fields，则忽略 filterable_metadata_fields）
vs = create_vector_store(
    declared_fields=[
        IndexField(name="author", type="tag"),        # 字符串等值
        IndexField(name="tags", type="tag_multi"),    # 多值集合命中
        IndexField(name="score", type="numeric"),     # 数值，支持 op="range"
        IndexField(name="body", type="text"),         # 全文
        # 向量字段：dims 必须与 embeddings_dims / 模型输出一致，metric 只支持 cosine
        IndexField(name="embedding", type="vector", dims=1024, metric="cosine"),
    ],
)
```

- 自定义核心字段名：构造时传 `kb_id_field_name` / `doc_id_field_name` /
  `page_id_field_name` / `category_field_name`，检索结果的 `Document` 标准字段会自动回填：

  ```python
  vs = create_vector_store(
      kb_id_field_name="kb",
      doc_id_field_name="doc",
      page_id_field_name="page",
      category_field_name="category",
  )
  uid = vs.insert("文本", page_id="p1", kb_id="kb1")
  doc = vs.similarity_search_with_relevance_scores("文本", k=1)[0]
  doc.vs_page_id   # "p1"（由自定义的 page 字段回填到标准字段）
  doc.vs_kb_id     # "kb1"
  ```

- 更多字段模型与各后端过滤能力见下方《字段模型与过滤语义》。

### 索引管理

```python
# 建索引（幂等）：fields 优先于实例声明；已存在时仅增量补字段，不重建
vs.create_index("default", fields=[IndexField(name="author", type="tag")])

# 查看归一化 schema 与是否存在
info = vs.describe_index("default")
print(info.exists, [f.name for f in info.fields])

# 漂移检测与重建（重建是破坏性的，只能显式调用）
diff = vs.compare_index_schema("default")
if diff.drifted or not diff.exists:
    vs.rebuild_index("default")               # force=True：无条件重建
# vs.rebuild_index("default", force=False)    # 仅在检测到漂移时重建

# 原生过滤索引的检查与补建（缺字段时 qdrant 仅变慢，milvus/pgvector/sqlite-vec 由元数据兜底；redis/elasticsearch 可能报错或失配）
if vs.check_index_fields("default"):
    vs.build_index_fields("default")
```

注意事项：

- `create_index` 是幂等的：已存在的索引**不会重建**，只做增量补字段；要重建必须显式
  调用 `rebuild_index`。
- `rebuild_index(force=False)` 仅在索引已存在且检测到漂移时执行；索引不存在时返回
  `exists=False` 的 `IndexDiff`，不会创建。`force=True`（默认）在索引不存在时会抛
  `RuntimeError`。
- `compare_index_schema` 读不到线上 schema 时返回 `drifted=False`（表示“无法检测”，
  而不是“无漂移”），并记录日志。
- `check_index_fields` 只读，`bool(status)` 为真表示存在待补建的 `missing` 字段；
  `build_index_fields` 只补 `missing`、不删除/重建已有字段，索引未创建时会先创建。
- `rebuild_index` 是**破坏性**操作（删除并重建，丢失该索引下的数据），请先确认。

### 连接与模型参数

```python
from openai_simple_vectorstore.base import Connection

vs = create_vector_store(
    vector_db="redis",
    redis_stack_url="redis://localhost:6379/0",
    embeddings_llm=Connection(base_url="http://localhost/v1", api_key="sk-xxx"),
    rerank_llm=Connection(base_url="http://localhost/v1", api_key="sk-xxx"),
    embeddings_model="bge-m3",
    rerank_model="bge-reranker-v2-m3",
    embeddings_dims=1024,             # 嵌入维度，需与模型输出一致（默认 1024）
    embeddings_max_size=8192,         # 单段文本上限，超出截断（默认 1024）
    rerank_max_size=8192,             # rerank 单段文本上限，超出截断（默认 1024）
    embeddings_score_threshold=None,  # None 表示不过滤，见下
    rerank_score_threshold=None,      # None 表示不过滤，见下
    redis_timeout=60,
)
```

- 只传 `llm` 时同时用于 embeddings 与 rerank；分别配置请用 `embeddings_llm` / `rerank_llm`。
- 各远程后端的超时可用 `redis_timeout` / `milvus_timeout` / `pgvector_timeout` /
  `es_timeout` / `qdrant_timeout` 覆盖（默认 60 秒）。
- 两个阈值语义相同：`None`（默认）表示不过滤；给出数字时，低于该分数的结果被丢弃。
  区别只在作用对象：`embeddings_score_threshold` 作用于向量相关度 `1 - distance ∈ [0, 1]`
  （因此设 `0.0` 等同于不过滤），`rerank_score_threshold` 作用于 rerank 模型分数
  （取值范围由模型决定，如 bge-reranker 近似 `[0, 1]`）。
- 配置优先级：**显式传参 > 环境变量 > 默认值**。环境变量在 `import
  openai_simple_vectorstore` 时读取，运行中修改需重新导入，或改为显式传参。
- `Connection` 的 `base_url` 会回退到 `OPENAI_BASE_URL`，并统一规范化为
  `http://host/v1/` 形式；`api_key` 为空时不发送鉴权头。

## 后端对比速查

| 后端 | 部署形态 | 原生过滤索引 | `iter_uids` 分页 | `flush` 行为 | 特别提示 |
| --- | --- | --- | --- | --- | --- |
| `redis` | 外部 redis-stack | 是（tag / tag_multi / numeric） | `SCAN` | 删 key + FT 索引 | 必须带 RediSearch；`tag_multi` 单个标签不能含逗号 |
| `milvus` | 外部服务 | 否（元数据侧兜底） | `query_iterator` 或 limit/offset | 删除 collection | `delete` 返回删除前实际存在条数 |
| `milvus-lite` | 嵌入式文件 | 否（元数据侧兜底） | 同上 | 删除 collection | 需 `pymilvus` + `milvus-lite`，无需部署服务 |
| `pgvector` | PostgreSQL 扩展 | 否（JSONB 过滤） | keyset（`page_id` 游标） | 删除该索引对应的行 | 多个索引共享同一张表 |
| `elasticsearch` | 外部服务 | 是（mapping） | `search_after` | 清空文档，保留 index / mapping | `insert_many` 非事务，失败时尽力回滚 |
| `sqlite-vec` | 嵌入式文件 | 否（元数据侧兜底） | keyset（`rowid` 游标） | 删除虚拟表 | 需要 sqlite3 支持 loadable-extension |
| `qdrant` | 外部服务 | 是（payload index） | `scroll` | 删除 collection | 向量/HNSW/payload 默认内存，按需开启 on_disk |

> “原生过滤索引”为否的后端仍可过滤，只是由元数据 / JSON 侧兜底，性能与索引能力见
> 《字段模型与过滤语义》。

## 环境变量

| 变量 | 默认值 | 说明 |
| --- | --- | --- |
| `OPENAI_SIMPLE_VECTORSTORE_VECTOR_DB` | `redis` | 后端：`redis` / `redis-search` / `milvus` / `milvus-lite` / `pgvector` / `elasticsearch` / `sqlite-vec` / `qdrant` |
| `OPENAI_SIMPLE_VECTORSTORE_REDIS_STACK_URL` | `redis://localhost:6379/0` | redis-stack 地址（redis 后端） |
| `OPENAI_SIMPLE_VECTORSTORE_REDIS_TIMEOUT` | `60` | redis 连接/读写超时秒数（别名 `REDIS_TIMEOUT`） |
| `OPENAI_SIMPLE_VECTORSTORE_MILVUS_URI` | `http://localhost:19530` | milvus 地址 |
| `OPENAI_SIMPLE_VECTORSTORE_MILVUS_TOKEN` | 空 | milvus 鉴权 token |
| `OPENAI_SIMPLE_VECTORSTORE_MILVUS_CONNECTION_ALIAS` | `default` | milvus 连接别名 |
| `OPENAI_SIMPLE_VECTORSTORE_MILVUS_TIMEOUT` | `60` | milvus RPC 超时秒数（别名 `MILVUS_TIMEOUT`） |
| `OPENAI_SIMPLE_VECTORSTORE_MILVUS_LITE_URI` | `./milvus_lite.db` | milvus-lite 本地数据库文件路径 |
| `OPENAI_SIMPLE_VECTORSTORE_PGVECTOR_URL` | `postgresql://localhost:5432/postgres` | pgvector 连接串（pgvector 后端） |
| `OPENAI_SIMPLE_VECTORSTORE_PGVECTOR_TIMEOUT` | `60` | pgvector 连接超时秒数（别名 `PGVECTOR_TIMEOUT`） |
| `OPENAI_SIMPLE_VECTORSTORE_ELASTICSEARCH_URL` | `http://localhost:9200` | elasticsearch 地址 |
| `OPENAI_SIMPLE_VECTORSTORE_ELASTICSEARCH_API_KEY` | 空 | elasticsearch API key（优先于账号密码） |
| `OPENAI_SIMPLE_VECTORSTORE_ELASTICSEARCH_USERNAME` | 空 | elasticsearch 用户名 |
| `OPENAI_SIMPLE_VECTORSTORE_ELASTICSEARCH_PASSWORD` | 空 | elasticsearch 密码 |
| `OPENAI_SIMPLE_VECTORSTORE_ELASTICSEARCH_VERIFY_CERTS` | `True` | 是否校验证书（自签 HTTPS 集群可设 `false`） |
| `OPENAI_SIMPLE_VECTORSTORE_ELASTICSEARCH_TIMEOUT` | `60` | elasticsearch 请求超时秒数（别名 `ELASTICSEARCH_TIMEOUT` / `ES_TIMEOUT`） |
| `OPENAI_SIMPLE_VECTORSTORE_SQLITE_VEC_URI` | `./sqlite_vec.db` | sqlite-vec 本地数据库文件路径 |
| `OPENAI_SIMPLE_VECTORSTORE_QDRANT_URL` | `http://localhost:6333` | qdrant 服务地址（别名 `QDRANT_URL`） |
| `OPENAI_SIMPLE_VECTORSTORE_QDRANT_API_KEY` | 空 | qdrant API key（别名 `QDRANT_API_KEY`） |
| `OPENAI_SIMPLE_VECTORSTORE_QDRANT_PREFER_GRPC` | `False` | 是否优先使用 gRPC（别名 `QDRANT_PREFER_GRPC`） |
| `OPENAI_SIMPLE_VECTORSTORE_QDRANT_TIMEOUT` | `60` | qdrant-client 请求超时秒数（别名 `QDRANT_TIMEOUT`） |
| `OPENAI_SIMPLE_VECTORSTORE_QDRANT_ON_DISK` | `False` | 向量/HNSW/payload 是否落盘的总开关（别名 `QDRANT_ON_DISK`） |
| `OPENAI_SIMPLE_VECTORSTORE_QDRANT_VECTOR_ON_DISK` | 继承总开关 | 向量列是否落盘（别名 `QDRANT_VECTOR_ON_DISK`） |
| `OPENAI_SIMPLE_VECTORSTORE_QDRANT_HNSW_ON_DISK` | 继承总开关 | HNSW 图是否落盘（别名 `QDRANT_HNSW_ON_DISK`） |
| `OPENAI_SIMPLE_VECTORSTORE_QDRANT_PAYLOAD_ON_DISK` | 继承总开关 | payload 索引是否落盘（别名 `QDRANT_PAYLOAD_ON_DISK`） |
| `OPENAI_BASE_URL` | `http://localhost/v1` | OpenAI 兼容服务基础地址（embeddings / rerank 共用） |
| `OPENAI_API_KEY` | 空 | OpenAI 兼容服务密钥（embeddings / rerank 共用） |
| `OPENAI_EMBEDDINGS_BASE_URL` | 同 `OPENAI_BASE_URL` | embeddings 服务地址（单独覆盖） |
| `OPENAI_EMBEDDINGS_API_KEY` | 同 `OPENAI_API_KEY` | embeddings 服务密钥（单独覆盖） |
| `OPENAI_EMBEDDINGS_MODEL` | `bge-m3` | embeddings 模型名 |
| `OPENAI_EMBEDDINGS_MAX_SIZE` | `1024` | embeddings 单段文本上限，超出截断 |
| `OPENAI_EMBEDDINGS_DIMS` | `1024` | 嵌入向量维度（需与模型输出一致） |
| `OPENAI_RERANK_BASE_URL` | 同 `OPENAI_BASE_URL` | rerank 服务地址（单独覆盖） |
| `OPENAI_RERANK_API_KEY` | 同 `OPENAI_API_KEY` | rerank 服务密钥（单独覆盖） |
| `OPENAI_RERANK_MODEL` | `bge-reranker-v2-m3` | rerank 模型名 |
| `OPENAI_RERANK_MAX_SIZE` | `1024` | rerank 单段文本上限，超出截断 |

> 大多数变量提供无前缀短别名，常用如：`VECTOR_DB` / `VECTOR_DB_TYPE`、
> `REDIS_URL` / `REDIS_STACK_URL`、`MILVUS_URI` / `MILVUS_TOKEN`、
> `PGVECTOR_URL` / `PGVECTOR_DSN` / `DATABASE_URL`、
> `ES_URL` / `ES_API_KEY` / `ES_USER` / `ES_PASSWORD`、`SQLITE_VEC_URI`、
> `QDRANT_URL` / `QDRANT_API_KEY` / `QDRANT_TIMEOUT`、`BASE_URL` / `API_KEY`、
> `EMBEDDINGS_*` / `RERANK_*`。完整别名列表见 `openai_simple_vectorstore/settings.py`。
>
> 兼容 `openai-redis-vectorstore`：`OPENAI_REDIS_VECTORSTORE_REDIS_STACK_URL`、
> `OPENAI_EMBEDDINGS_*`、`OPENAI_RERANK_*`、`OPENAI_BASE_URL`、`OPENAI_API_KEY` 等变量可直接使用。
>
> 环境变量在 `import openai_simple_vectorstore` 时读取；运行中修改后需重新导入，或直接
> 给 `create_vector_store(...)` 传显式参数（优先级更高）。

## 扩展新的向量数据库后端

继承共享抽象基类 `VectorStore` 并实现以下方法，再注册到工厂即可：

1. 实现 `get_cached_vectorstore`（引擎的获取与缓存）
2. 实现 `_search_index`，返回 `[(page_id, distance, item), ...]`
3. 实现 `get_item` / `delete` / `delete_many` / `flush`
4. （可选）若要支持 `iter_uids`：实现 `_engine_for_index(index_name)`（索引不存在时
   返回 `None` 且不创建），并在引擎上提供 `iter_page_ids(batch_size)` 生成器，只读
   分页产出 `page_id`（不加载向量/内容）。base 的 `iter_uids` 会自动拼接
   `<index_name>:<page_id>` 前缀，`count_uids` 基于 `iter_uids` 计数。
   redis 这类 key 即完整 uid 的后端可直接覆盖 `iter_uids`。
5. （建议）声明 `backend = "mydb"`，索引名归一化会据此选择规则；未声明时默认采用
   保守规则（仅把 `:` 替换为 `_`）。若该后端允许的字符集与默认规则不同，同时覆盖
   `normalize_index_name()`。

```python
from openai_simple_vectorstore.base import VectorStore, IndexField
from openai_simple_vectorstore.registry import register_vector_store, create_vector_store

class MyStore(VectorStore):
    # 后端标识：决定索引名归一化规则等后端专属行为
    backend = "mydb"
    # 可选：当 ``_search_index`` 返回的 item 已是"与 get_item 同源的完整序列化
    # metadata"（如 qdrant 的 with_payload payload）时置 True，base 在
    # include_embedding=False 时可直接复用、省去逐条 get_item 的 N+1 往返。
    # 仅返回部分字段（如 redis 默认只返回 page_id）的后端必须保持 False。
    _search_payload_is_document = False

    def get_cached_vectorstore(self, **kwargs): ...
    def _search_index(self, engine, query_embedding, index_name,
                      filters, filter_expression, k): ...
    def get_item(self, uid): ...
    def delete(self, uid): ...
    def delete_many(self, uids): ...
    def flush(self, index_name=None): ...

    # 可选：后端的索引名字符集与默认规则不同时覆盖
    def normalize_index_name(self, index_name=None): ...

    # 可选：支持 iter_uids / count_uids
    def _engine_for_index(self, index_name): ...   # 不存在返回 None，不创建
    # 引擎侧：def iter_page_ids(self, batch_size=1000): yield page_id ...

    # 可选的统一字段模型 / 能力矩阵 / schema 查询契约
    def _capabilities(self): ...
    def _describe_index(self, index_name): ...
    def _ensure_index_created(self, index_name, fields): ...

register_vector_store("mydb", MyStore)
vs = create_vector_store(vector_db="mydb")
```

`base` 会统一处理 embeddings 生成、rerank、过滤、去重、排序、`relevance_score` 与
`Document` 组装，后端只需专注各自的检索实现。

### 通过 entry-points 分发第三方后端

除手动 `register_vector_store` 外，外部包也可在自己的 `pyproject.toml` 中声明
entry-point，本库导入时会自动发现并注册：

```toml
[project.entry-points."openai_simple_vectorstore.backends"]
mydb = "mypackage.backends.mydb:MyVectorStore"
```

- entry-point 的 `name` 即后端标识（如 `mydb`），`value` 指向 `VectorStore` 子类或
  返回该类的工厂函数。
- 任一后端加载失败只记录 warning，不影响其它后端；可用
  `discover_vector_stores()` 手动触发并拿到本次新注册的名称列表。
- 内置后端同样以 entry-points 声明（见本库 `pyproject.toml`），安装后即可用
  `create_vector_store(vector_db="mydb")` 创建。

`create_vector_store(vector_db=...)` 对常见写法做了别名归一（大小写、空格不敏感），
如 `redis-search` / `rediss` → `redis`，`es` / `opensearch` → `elasticsearch`，
`postgres` / `postgresql` / `pg` → `pgvector`，`sqlite` / `sqlite_vec` → `sqlite-vec`，
`milvus_lite` / `milvuslite` → `milvus-lite`。

> **`_search_payload_is_document` 契约**：置 `True` 表示 `_search_index` 返回的
> `item` 是完整的序列化 metadata（与 `get_item` 内部交给
> `get_unserialized_metadata` 的 dict 同源），base 会在 `include_embedding=False`
> 时直接反序列化复用它。若后端搜索只返回少量字段（如只返回 `page_id`），必须保持
> `False`，否则会静默返回缺失内容/核心字段的文档。

### 字段模型与过滤语义

各引擎通过统一的字段声明 / 能力矩阵 / schema 查询接口，向业务暴露"哪些扩展字段可过滤、索引现状如何"：

- **`IndexField`**：声明字段类型，支持 `tag` / `tag_multi` / `numeric` / `text` / `vector`；
  向量字段必须给出 `dims`，并与引擎配置的 `embeddings_dims` 一致。
- **`capabilities()`**：返回能力矩阵（`Capabilities`）——`indexed_types` 表示后端建成
  原生可过滤列的类型（如 redis、elasticsearch），`meta_filter_types` 表示在元数据上做
  Python/表达式侧兜底过滤的类型（如 milvus / sqlite-vec / pgvector 的部分类型）。
- **`describe_index(index_name)`**：返回归一化的 `IndexSchemaInfo`——`exists=False` 表示
  索引尚未创建（此时 `fields` 返回声明/期望字段，方便业务预创建）。索引存在且后端有可读的
  逐字段线上 schema 时返回线上真实字段（redis / elasticsearch / milvus / milvus-lite）；
  qdrant / pgvector / sqlite-vec 无逐字段线上 schema（payload / metadata JSON），`fields`
  恒为声明字段。
- **`create_index(index_name, fields)`**：按声明的 `IndexField` 列表构建索引（显式传入的
  `fields` 优先于实例构造时的 `declared_fields`）；已存在时仅补齐可增量添加的部分
  （redis `FT.ALTER` / elasticsearch `put_mapping` / qdrant payload 索引），不会重建。
- **`compare_index_schema(index_name)` / `rebuild_index(index_name, force=...)`**：只读检测
  线上 schema 与期望是否漂移；返回的 `IndexDiff` 中 `exists=False` 表示索引已不存在
  （如被外部删除），`drifted=True` 表示字段 / 维度 / 距离度量不一致。重建一律是破坏性的
  （删除并重新索引、丢失该索引下的数据），只能显式调用 `rebuild_index`（`force=True`
  无条件重建，`force=False` 仅在检测到漂移时重建），不会在创建 / ensure 索引时自动触发。
- **`check_index_fields(index_name)` / `build_index_fields(index_name)`**：检查声明字段中
  哪些缺少后端原生过滤索引（`IndexFieldStatus.missing`），并按需补建。补建是显式 API，
  不会在 ensure / 写入路径上隐式执行——缺字段时 qdrant 仍能正确过滤（仅变慢），
  milvus / pgvector / sqlite-vec 由元数据侧兜底，redis / elasticsearch 则会过滤报错或
  失配；建议由调用方主动调用补建。例外：首次创建索引（redis `FT.CREATE` /
  elasticsearch 建 index / qdrant 建 collection）时会按当时声明一并建好原生索引，
  仅当之后新增字段时才需要显式补建。

常用只读查询：

```python
caps = vs.capabilities()
caps.indexed_types        # 该后端原生建索引的字段类型，如 ("tag", "tag_multi", "numeric")
caps.meta_filter_types    # 只能元数据侧兜底过滤的类型
caps.supports("numeric")  # 该类型是否可过滤

info = vs.describe_index("faq:kb")
info.index_name, info.exists, info.fields   # fields 为归一化后的 IndexField 列表

diff = vs.compare_index_schema("faq:kb")
diff.exists, diff.drifted, diff.details     # drifted=True 表示字段/维度/度量与期望不一致

status = vs.check_index_fields("faq:kb")
status.indexed_names, status.missing_names, status.unsupported_names
```

过滤统一使用 `Filter` 对象（`tag` 精确、`tag_multi` 多值命中、`numeric` 区间 via `op="range"`）；
`base` 把归一化后的 `filters` 传入各后端的 `engine/search`。后端只需实现
`_build_filter_expression(filters, filter_expression)` 把归一化过滤器组装为原生过滤表达式。

- **索引初始化一次性**：引擎实例首次执行 `ensure_*`（建表 / 建 collection / 建 index）
  时完成创建或存在性探测，之后所有读写路径不再重复探测（内部 `_index_ready` /
  `_table_ready` 标记）。若索引被外部删除或 schema 漂移，用 `compare_index_schema`
  检测后显式 `rebuild_index`，而不是期望每次操作都自动 `ensure`。
- **redis `tag_multi` 的逗号限制**：RediSearch 对 HASH 字段的 TAG 以逗号分隔且
  **不解析反斜杠转义**（实测 `a\,b` 会被拆成两个标签），因此单个标签值内部不能包含
  逗号；普通多值标签（元素本身不含分隔符）可被正确索引与过滤。

## 概念说明

- **逻辑索引名 / 物理索引名**：公开接口接收逻辑名（可含 `:` / `-` / `.`），
  经 `get_index_name` 按实例后端规则归一化为物理名后落到后端；uid 前缀使用物理名。
  详见上文「索引名归一化」。
- **uid**：`<index_name>:<page_id>`，用于唯一定位一条记录；其中 `index_name` 是
  归一化后的物理索引名。
- **relevance_score**：`1 - distance`，取值 `[0, 1]`，越大越相关。该分数与
  `embeddings_score_threshold` 统一按**余弦**距离语义计算，因此所有后端的向量度量
  均固定为 cosine；redis 的 `vector_store_distance_metric` 也仅接受 `cosine`，
  声明的向量 `IndexField` 同样只接受 `metric="cosine"`，传入其它度量（IP / L2 等）
  会在实例化时报 `ValueError`，避免返回错误的相似度。
- **二阶段召回**：`similarity_search_and_rerank` 先用向量检索取 `k * scale` 条候选，
  再经 rerank 重排取前 `k` 条。
- **检索错误诊断**：任一后端的向量检索（含超时、连接失败等）都会在抛出前
  记录并返回带**后端地址 + 索引名 + 检索参数**的 `RuntimeError`，便于定位，
  不会把底层驱动的原始调用堆栈直接抛给调用方；索引不存在（未初始化）仍按
  空结果处理。
- **连接/查询超时**：各远程后端默认 60 秒（`OPENAI_SIMPLE_VECTORSTORE_*_TIMEOUT`，
  qdrant 见上文），也可在实例化时用 `redis_timeout` / `milvus_timeout` /
  `pgvector_timeout` / `es_timeout` 覆盖（`qdrant_timeout` 同理）。
- **elasticsearch 批量写入非事务**：`insert_many` 逐条写入，任一条失败时会**尽力**
  回滚（删除）本次已写入的文档后再抛出，尽量表现为“全成功或全不写入”，但 ES 无多
  文档事务，回滚本身失败时仅记录 warning，不掩盖原始异常。
- **elasticsearch numeric 字段**：`numeric` 映射为 `float`，ES 默认
  `coerce=true`，因此字符串数值（如 `"7"`）会被接受并转换为数值，属预期行为。

## API 速查

| 方法 | 作用 | 关键参数 / 返回 |
| --- | --- | --- |
| `create_vector_store(vector_db=None, **kwargs)` | 按后端标识创建实例 | `vector_db` 可省略（读环境变量）；其余参数透传给后端类 |
| `get_vector_store_class(vector_db=None)` | 获取后端类（不实例化） | 未知后端抛 `RuntimeError` |
| `register_vector_store(name, cls=None)` | 手动注册后端，可作装饰器 | 返回类本身 |
| `discover_vector_stores(group=...)` | 自动发现 entry-points 后端 | 返回新注册的后端名列表 |
| `insert(text, metadata=None, page_id=None, kb_id=None, doc_id=None, category=None, index_name=None, ...)` | 写入单条文本 | 返回 `<归一化 index>:<page_id>`；同 `page_id` 覆盖 |
| `insert_many(texts, metadatas=None, kb_id=None, doc_id=None, page_ids=None, category=None, index_name=None, ...)` | 批量写入 | 返回与输入同序的 uid 列表；长度不一致抛 `RuntimeError(422)` |
| `get_item(uid)` | 读取单条 | 返回平铺元数据 dict（含 `embedding` 字节）；不存在返回 `{}` |
| `delete(uid)` / `delete_many(uids)` | 删除单条 / 批量 | 返回实际删除条数 |
| `flush(index_name=None)` | 清空索引 | 破坏性；是否删除底层索引/表随后端而异 |
| `iter_uids(index_name=None)` | 只读枚举全部 uid | 生成器；索引不存在返回空迭代 |
| `count_uids(index_name=None)` | 统计 uid 数量 | 基于 `iter_uids` 全量计数 |
| `similarity_search_with_relevance_scores(query, ..., k=4, include_embedding=False)` | 向量检索 | 返回按相关度降序的 `Document` 列表 |
| `similarity_search_and_rerank(query, ..., k=4, scale=2, ...)` | 二阶段召回 | 先取 `k*scale` 候选，再 rerank 取前 `k` |
| `rerank(query, docs, k=4, ...)` | 对已有文档 rerank | 返回按 rerank 分数降序的 `Document` 列表 |
| `create_index(index_name=None, fields=None)` | 创建/补字段（幂等） | `fields` 会并入实例声明 |
| `describe_index(index_name=None)` | 查询归一化 schema | 返回 `IndexSchemaInfo`（`exists` / `fields`） |
| `compare_index_schema(index_name=None)` | 检测漂移 | 返回 `IndexDiff`（`exists` / `drifted` / `details`） |
| `rebuild_index(index_name=None, force=True)` | 重建索引 | 破坏性；`force=False` 仅在漂移时重建 |
| `check_index_fields(index_name=None)` | 检查缺失的原生过滤索引 | 返回 `IndexFieldStatus`；`bool(status)` 表示有待补建项 |
| `build_index_fields(index_name=None)` | 补建缺失的原生过滤索引 | 幂等，返回补建后的 `IndexFieldStatus` |
| `capabilities()` | 后端能力矩阵 | 返回 `Capabilities`（`indexed_types` / `meta_filter_types`） |
| `get_index_name(index_name=None)` / `normalize_index_name(index_name=None)` | 获取物理索引名 | 按实例后端规则归一化 |

## 错误与诊断

| 场景 | 表现 | 处理建议 |
| --- | --- | --- |
| 检索的索引不存在 / 未创建 | 按空结果处理，记录 warning，不抛异常 | 用 `describe_index(...).exists` 判断是否需要 `create_index` |
| 后端连接 / 检索失败 | `RuntimeError`，消息含后端地址、索引名、`k`、过滤条件、query 维度与原始错误 | 按消息中的 endpoint / index / params 定位；真实驱动异常保留在消息末尾 |
| 过滤字段未声明、不可过滤、空值、`None`、非法 `op`、`low > high` | `ValueError` | 先通过 `IndexField` / `filterable_metadata_fields` 声明字段 |
| `IndexField` 非法（未知类型、向量缺 `dims`、非 `cosine` 度量、字段重名） | 实例化 / 声明时 `ValueError` | 修正声明；向量度量固定为 `cosine` |
| `insert_many` 的 `metadatas` / `page_ids` 与 `texts` 长度不一致 | `RuntimeError(422)` | 调用前校验三个列表等长 |
| 后端未配置 URL / 驱动，或 embeddings / rerank 请求失败 | `RuntimeError(500)` | 补齐 `*_URL`、安装对应 extra 驱动，或检查模型服务与 API key |
| `get_item` / `delete` / `iter_uids` 的目标不存在 | 分别返回 `{}` / `0` / 空迭代 | 用 `describe_index` 或 `iter_uids` 先确认索引状态 |
| 第三方后端未实现必需钩子 | `NotImplementedError` | 实现对应引擎方法；`iter_uids` 需 `_engine_for_index` + `iter_page_ids` |

> 检索失败的 `RuntimeError` 带结构化 `index_missing` 标记（仅真正“索引未建”时才被当作
> 空结果）；其它错误不会被静默吞掉。库内日志使用标准 `logging`，Logger 名为
> `openai_simple_vectorstore`，可按需配置级别或处理器。

## 版本记录

### 0.3.3（2026-10-31）

- **索引名归一化下沉到库内（行为变更）**：`get_index_name` 不再拒绝含 `:` 的逻辑名，
  而是按实例后端规则归一化为物理合法名；新增公开的 `normalize_index_name()`（模块级与
  实例方法）及 `VectorStore.backend` 后端标识。qdrant / milvus / milvus-lite 把
  `[^A-Za-z0-9_]` 统一替换为 `_`、数字开头前置 `_`、空名回退 `_index`、截断到 255；
  redis / elasticsearch / pgvector / sqlite-vec 仅把 uid 分隔符 `:` 替换为 `_`，保留
  连字符等既有字符。归一化覆盖写入 / 读取 / 删除 / `flush` / schema 查询 / 多索引联合
  检索与 `_split_uid`，使绕过上层 ORM 直接调用本库时与写入侧命中的是同一物理索引，
  规则与 `django-vectorstore-indexed-model` 保持一致。
- **升级提示**：此前 `index_name` 含 `:` 会抛 `ValueError`，现在会被归一化而非报错；
  qdrant / milvus 系列中旧版本以连字符等非 `[A-Za-z0-9_]` 字符创建的物理 collection
  将按新名查找，如仍在使用这类索引，需按新名迁移数据或显式 `rebuild_index` 重建。
  其余后端的既有索引名（除 `:` 外）不受影响。
- **新增公开枚举接口 `iter_uids` / `count_uids`**：`VectorStore.iter_uids(index_name=None)`
  只读遍历索引下全部 uid（`<index_name>:<page_id>`，与 `insert` 返回值同格式），
  `count_uids` 基于其计数。此前业务侧要枚举 uid 只能绕过封装直接访问后端私有连接
  （qdrant `_client` / redis `redis_instance`），现统一收敛到公开接口。
- **全后端分页实现，不整表加载**：`iter_uids` 经 `_engine_for_index`（索引不存在返回
  `None` 且不创建）+ 引擎 `iter_page_ids(batch_size)` 生成器完成，仅取 `page_id`、不加载
  向量与内容，并按后端分页拉取（qdrant scroll / redis `scan_iter` / elasticsearch
  `search_after` / pgvector 与 sqlite-vec keyset / milvus `query_iterator` 或 limit-offset），
  避免一次性载入全量 key 或触发大偏移。索引不存在时返回空迭代、不抛异常。
- **redis 覆盖 `iter_uids`**：redis key 即完整 uid，用 `scan_iter(match=...)` 遍历，
  索引名按 glob 转义（与 `flush` 口径一致），避免 `*` 等元字符误匹配同前缀索引。
- **约定**：`iter_uids` 的索引名经 `get_index_name` 按后端规则归一化后精确匹配；第三方
  后端未实现 `_engine_for_index` 时抛 `NotImplementedError`（与 `get_item` 一致）。
- **修复 milvus（独立部署）`delete` 返回值失真**：milvus 服务端的
  `delete(ids=...)` 返回的 `delete_count` 恒等于**请求的 id 个数**（删除不存在的
  page_id 也计入），导致 `delete` 对不存在的 uid 误报删除 1 条。现改为以删除前
  实际存在的条数为准，`delete` / `delete_many` 返回值与真实删除数一致
  （milvus-lite 本就返回真实计数，行为不变）。

### 0.3.2（2026-10-05）

- **检索默认不再取回向量字段（性能修复）**：`similarity_search_with_relevance_scores` /
  `similarity_search_and_rerank` 新增 `include_embedding` 参数，默认 `False`。此前每个命中
  都会逐条 `get_item` 取回向量字段，向量检索本身是 O(1) 往返，而取数是 O(k)（rerank 为
  O(k*scale)）次网络往返，是高 k / 生产网络 RTT 下的主要延迟来源。默认关闭后检索结果
  `vs_embedding` 为 `None`，需要原始向量时传 `include_embedding=True` 恢复原行为。
- **qdrant 复用检索 payload 消除 N+1**：qdrant 检索使用 `with_payload=True`，命中已携带内容
  与全部元数据；关闭 `include_embedding` 时直接反序列化复用该 payload，完全省去逐条
  `get_item` 往返（实测 k=100 由 ~140ms 降至 ~17ms，rerank 100 候选由 ~250ms 降至 ~13ms）。
- **多索引检索只计算一次 query embedding**：`index_names` 联合检索时，query embedding 只与
  query 及 embeddings 配置有关、与索引无关；改为先解析各索引引擎，再在循环外只计算一次
  embedding 供所有索引复用，避免对每个索引重复发起相同的 embeddings 请求（并保持"后端未
  配置先于 embedding 报错"的既有次序）。
- **入口校验与防御性加固**：声明的向量 `IndexField` 仅接受 `metric="cosine"`；redis 的
  `vector_store_distance_metric` 也仅接受 `cosine`（非 cosine 在实例化时报 `ValueError`，
  避免 relevance score / `embeddings_score_threshold` 静默失真）；`Connection()` 未给
  `base_url` 时回退 `OPENAI_BASE_URL`（不再拼出 `"/v1/..."` 相对 URL）；numeric 过滤拒绝
  `NaN`（避免比较恒为 False 导致静默失配）；schema / 引擎钩子补 `NotImplementedError` 缺省实现
  （第三方后端遗漏实现时不再抛 `AttributeError`）；`create_index(fields=...)` 显式字段重名报错。
- **`include_embedding` 置于参数末尾**：`similarity_search_with_relevance_scores` /
  `similarity_search_and_rerank` 新增的 `include_embedding` 放在参数列表末尾，避免调用方按
  位置传参时与既有参数错绑。
- **`_split_uid` 收敛到基类**：uid 拆解统一由基类 `VectorStore._split_uid` 提供，删除 6 个
  引擎中的重复实现。

### 0.3.1（2026-10-02）

- **索引生命周期收敛**：`ensure_*`（建表 / collection / index）改为每个引擎实例只探测
  一次（内部 `_index_ready` / `_table_ready`），读写热路径不再重复探测；新增
  `IndexDiff.exists` 区分"未创建"与"漂移"；新增 `check_index_fields` / `build_index_fields`
  按需补建后端原生过滤索引（显式 API，不在 ensure / 写入路径隐式触发）。
- **qdrant 落盘拆分可配**：向量 / HNSW 图 / payload 索引三部分是否落盘可分别配置
  （`qdrant_vector_on_disk` / `qdrant_hnsw_on_disk` / `qdrant_payload_on_disk`，
  `qdrant_on_disk` 为兜底总开关）；默认全部走内存（`False`，相对 0.3.0 的行为变更）。
- **统一检索错误诊断**：任一后端的向量检索（含超时、连接失败）抛出带后端地址 + 索引名 +
  检索参数的 `RuntimeError`，并用结构化 `index_missing` 标记只吞"真正未建索引"，避免按
  文案子串匹配误吞真实错误。
- **可配置超时**：redis / milvus / pgvector / elasticsearch / qdrant 新增连接与请求超时
  （`*_timeout` 参数或 `OPENAI_SIMPLE_VECTORSTORE_*_TIMEOUT`，默认 60 秒），redisvl 的
  `SearchIndex` 同样受 socket 超时约束。
- **跨引擎正确性修复**：
  - redis：`tag_multi` 改为直接以 TAG 分隔符拼接/拆分（RediSearch HASH 不解析反斜杠转义），
    `FT.INFO` 兼容 RESP2/RESP3 与 `dims=` / `DIM` 两种向量参数，`describe_index` 返回线上
    FT schema，`flush` glob 转义并清理缓存引擎，`PREFIX` 改为 `index_name + ":"` 以隔离
    同名前缀索引（旧索引需 `rebuild_index` 后生效）；
  - pgvector：共享表 `to_regclass` 为 NULL 视为"未建表"（不再建幻影表），每连接锁改用
    `WeakKeyDictionary` 安装，批量写入改为整批事务 + 失败回滚，`filter_expression` 字面 `%`
    转义；
  - sqlite-vec：整批写入持有连接锁并支持回滚，读/删不再隐式建表；
  - milvus / milvus-lite：含单引号的 `page_id` 回退到自建转义表达式，删除不再因可见性滞后
    短路，`describe_index` 缺失向量维度时逐级回退；
  - elasticsearch：`insert_many` 中途失败时回滚，覆盖写恢复批处理前旧文档；
  - qdrant：`filter_expression` 的 must/should/must_not 归一，numeric 等值过滤改用
    `Range(gte=v, lte=v)`。
- **过滤与字段模型加固**：`Filter` 取值归一（字符串不逐字符拆分、集合排序、接受单个
  `Filter`/`dict`），拒绝未声明字段、空值 / `None`、非法 `op` 与 `low > high`；numeric 取值
  统一转 `float`；`tag_multi` 全后端恒返回 list；核心 id 字段恒按字符串解码；
  `create_index(index_name, fields=...)` 保留与向量同名的显式声明并合并进实例声明。
- **打包**：`requires-python` 提升至 `>=3.10`，核心依赖补 `numpy` 并同步 `requirements.txt`，
  `pydantic>=2` 与各 extras 增加版本下界，entry-points 导入时自动注册。
- **补充修复**：redis 向量 `datatype` 配置生效且 `capabilities` 不再把 `text` 当作可过滤类型；
  elasticsearch `num_candidates` 封顶 10000；qdrant 无 `FloatIndexParams` 时 numeric 索引
  保持 float；sqlite-vec `ensure_table` 加锁并双重检查；并统一澄清 `flush` /
  `describe_index` / 引擎缓存等注释与 README 表述。另修复：自定义 `document_schema`
  不含标准分数字段时（README 允许只传子模型）检索排序 / rerank 不再抛
  `AttributeError` / `ValueError`（排序改用内部计算的分数，写入 rerank 分数时容忍缺失
  字段）；milvus-lite 不再在模块顶层硬导入 `pymilvus.milvus_client.index.IndexParams`
  （改为回退分支内惰性导入，并优先尝试旧签名），避免旧版 pymilvus 下整个后端 import 失败。

### 0.3.0（2026-09-24）

- 新增 **qdrant** 后端（原生 payload 过滤、`uuid5` point id 映射、可配置 `on_disk`，
  同时作用于向量、HNSW 图与 payload 索引），并补齐 `capabilities` / `describe_index` /
  `create_index` / 漂移检测与重建。
- `create_index(index_name, fields=...)` 语义统一：显式传入的 `fields` 优先于实例构造时的
  `declared_fields`，redis / elasticsearch / qdrant 均生效；已存在索引时按可增量方式补齐
  字段（redis `FT.ALTER ... SCHEMA ADD`、elasticsearch `put_mapping`、qdrant payload 索引）。
- 索引重建收敛为显式调用：`rebuild_index` 不再在创建 / ensure 时自动触发；新增只读的
  `compare_index_schema` 漂移检测（`force=False` 仅在漂移时重建）。
- 修复 redis 索引 schema 漂移误报：`FT.INFO` 向量参数兼容 `dims=` / `DIM` 键值对两种格式，
  字段类型与向量维度 / 距离度量归一化后比对。
- 移除未被调用的死钩子 `_prepare_engine`（milvus / milvus-lite / pgvector / elasticsearch /
  sqlite-vec）。
- 包元数据完善：描述列出全部后端并补充 embeddings / rerank 关键词。

### 0.2.0（2026-09-08）

- 演进为完整字段模型：新增 `IndexField` 声明（`tag` / `tag_multi` / `numeric` / `text` /
  `vector`）、`capabilities()` 能力矩阵、`describe_index()` 归一化 schema 查询、`create_index()`
  schema 声明建索引。
- 过滤 DSL 升级为统一的 `Filter`（含 `numeric` 区间 `op="range"`）并新增 `validate_filters`；
  各引擎 `_search_index` 收敛为 `(engine, query_embedding, index_name, filters, filter_expression, k)`，
  移除旧的 `kb_ids` / `categories` 参数（内部经 `Filter` 适配为 `vs_kb_id` / `vs_category` 字段）。
- redis / elasticsearch / milvus / milvus-lite / pgvector / sqlite-vec 全部适配新签名，
  并补齐 `capabilities` / `describe_index` / `_ensure_index_created`。
- `Document` 新增 `vs_metadata` 字段：检索结果携带业务自定义元数据（如声明 `IndexField`
  的可过滤标量 `vs_score`），非标准 `vs_*` 模型字段在 `_extract_custom_metadata` 中聚拢。
- milvus（独立部署）insert / delete 不再显式 `flush()`：写入后检索可见依赖服务端自动落盘
  （`cacheFlushInterval`，默认约 3s），存在短暂写后读不一致窗口。

### 0.1.0（2026-09-08）

- 首个正式版本：在 redis-search 与 milvus 基础上新增 **pgvector**、**elasticsearch**、
  **sqlite-vec** 三个后端（factory + registry 注册，支持 extras 选择性安装）。
- 统一 embeddings / rerank 二阶段召回、插入 / 删除 / 更新（upsert）/ 查询 / 刷新等接口。
- 新增 kb / category 元数据过滤语义：各引擎在向量候选上按元数据过滤。
- milvus（独立部署）与 milvus-lite 支持同 page_id 更新（upsert）与删除后立即可见。
- elasticsearch 显式声明 kb / doc / category 等 keyword 映射以支持过滤，并自动迁移旧 schema。

## License

[MIT](LICENSE)
