
Byted Bytehouse Multimodal Search
- 2 installs
- 408 repo stars
- Updated August 3, 2026
- volcengine/agentkit-samples
Stores vectorized text, image, and video embeddings in ByteHouse and runs hybrid multimodal retrieval using Volcengine Ark embedding models.
About
Vectorizes text, image, and video data with Volcengine Ark embeddings and stores it in ByteHouse for hybrid multimodal retrieval. A developer uses it to build multimodal vector search on top of a ByteHouse database.
- Uses clickhouse-connect plus the Volcengine Ark SDK with the doubao-embedding-vision model
- Supports vectorized storage and hybrid search across text, image, and video
Byted Bytehouse Multimodal Search by the numbers
- 2 all-time installs (skills.sh)
- Ranked #741 of 911 Databases skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/volcengine/agentkit-samples --skill byted-bytehouse-multimodal-searchAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 408 |
| Last updated | August 3, 2026 |
| Repository | volcengine/agentkit-samples ↗ |
What it does
Stores vectorized text, image, and video embeddings in ByteHouse and runs hybrid multimodal retrieval using Volcengine Ark embedding models.
Files
ByteHouse 多模态检索 Skill
🚀 快速开始
环境准备
pip install clickhouse-connect volcengine-python-sdk[ark] numpy环境变量配置
优先从环境变量读取配置,禁止硬编码明文敏感信息:
# ByteHouse 配置
export BYTEHOUSE_HOST="<你的ByteHouse连接地址>"
export BYTEHOUSE_PORT="<ByteHouse端口>"
export BYTEHOUSE_USER="<ByteHouse用户名>"
export BYTEHOUSE_PASSWORD="<ByteHouse密码>"
export BYTEHOUSE_DATABASE="<默认数据库,可选,默认default>"
export BYTEHOUSE_SECURE="<是否启用加密,可选,默认true>"
# 火山引擎方舟 API 配置
export ARK_API_KEY="<火山引擎方舟API密钥>"
export ARK_BASE_URL="https://ark.cn-beijing.volces.com/api/v3"
export EMBEDDING_MODEL="doubao-embedding-vision-251215"
export EMBEDDING_DIMENSIONS="1536" # 可选,默认1536如果环境变量未配置,会自动提示用户输入。
---
📚 核心能力
1. 多模态向量化
基于豆包多模态向量化模型 doubao-embedding-vision-251215:
| 输入类型 | 支持格式 | 最大限制 |
|---|---|---|
| 文本 | 纯文本字符串 | 无长度限制 |
| 图片 | JPG/PNG/GIF/WEBP/BMP | <10MB,宽高>14px |
| 视频 | MP4/AVI/MOV | <50MB |
关键约束:
- 多模态向量化必须调用
/embeddings/multimodal接口 - 图片/视频输入格式:
{"type": "image_url", "image_url": {"url": "xxx"}} - 部分模型不支持
dimensions参数
2. 向量检索功能
| 功能 | 方法 | 说明 |
|---|---|---|
| 纯向量检索 | vector_search() | 基于向量相似度检索 |
| 混合检索 | hybrid_search() | 向量+全文检索融合 |
| 以文搜图 | text_search_image() | 文本搜索图片 |
| 以图搜图 | image_search_image() | 图片搜索相似图片 |
| 以文搜视频 | text_search_video() | 文本搜索视频 |
---
📖 代码实现
完整示例代码实现位于 scripts/ 目录:
- `scripts/embedding.py` - 多模态向量化模块
- `scripts/search_client.py` - ByteHouse 检索客户端
- `scripts/examples.py` - 使用示例
快速使用
from scripts import ByteHouseMultimodalSearch
# 初始化客户端
search = ByteHouseMultimodalSearch(connection_type="http")
# 创建表
search.create_multimodal_table("my_index")
# 插入文档
search.insert_document("my_index", doc_id=1, content_type="text",
content="ByteHouse 多模态检索", title="介绍")
# 向量检索
results = search.vector_search("my_index", query_embedding=embedding, top_k=10)---
⚙️ 最佳实践
索引选择
| 数据规模 | 索引类型 | 适用场景 |
|---|---|---|
| <100万 | HNSW | 中小规模,低延迟 |
| 100万-1亿 | HNSW_SQ | 大规模,平衡性能成本 |
| >1亿 | IVF_PQ_FS | 超大规模 |
性能优化
SETTINGS
index_granularity = 1024,
index_granularity_bytes = 0,
enable_vector_index_preload = 1指令优化
| 场景 | Query 侧指令 |
|---|---|
| 通用文搜图 | Target_modality: image. Instruction:根据文本描述找到对应的图片. |
| 电商商品检索 | Target_modality: image. Instruction:找到和描述匹配的同款商品图片. |
| 原图检索 | Target_modality: image. Instruction:查找和本图完全相同的图片. |
---
❓ 常见问题
Q1: 向量维度怎么选?
- 推荐 1536 维作为通用值
- 维度越高精度越高,但成本也越高
Q2: 如何处理低召回问题? 1. 增大 hnsw_ef_s 参数
Q3: API 调用失败排查
- 404: 检查路径是否为
/embeddings/multimodal - 400: 检查输入格式,部分模型不支持
dimensions - 401: 检查
ARK_API_KEY是否正确 - 429: 降低请求频率
---
🔗 参考文档
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
ByteHouse 多模态检索参考实现
提供多模态向量化与向量检索的核心功能实现
"""
from .embedding import MultimodalEmbedding
from .search_client import ByteHouseMultimodalSearch
__all__ = ['MultimodalEmbedding', 'ByteHouseMultimodalSearch']
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
多模态向量化模块
基于豆包多模态向量化模型实现文本、图片、视频的向量化
"""
import os
import numpy as np
from volcenginesdkarkruntime import Ark
from typing import List, Union, Dict
class MultimodalEmbedding:
"""多模态向量化客户端"""
def __init__(self):
self.client = Ark(
api_key=os.environ.get("ARK_API_KEY"),
base_url=os.environ.get("ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3")
)
self.model = os.environ.get("EMBEDDING_MODEL", "doubao-embedding-vision-251215")
self.dimensions = int(os.environ.get("EMBEDDING_DIMENSIONS", 1536))
def encode(self,
input_data: Union[str, List[Dict]],
modality: str = "text",
instruction: str = None) -> List[float]:
"""
多模态向量化接口
Args:
input_data: 输入数据
- 文本:直接传入字符串
- 图片/视频:传入 {"type": "image_url"/"video_url", "url": "xxx"} 格式
modality: 数据类型,可选 text/image/video
instruction: 自定义指令,用于提升特定场景检索精度
Returns:
向量列表
"""
try:
if isinstance(input_data, str):
input_item = {"type": "text", "text": input_data}
else:
input_item = input_data
# 输入格式校验
if modality in ["image", "video"] and not isinstance(input_item, dict):
raise ValueError(f"{modality}类型输入必须为包含url的字典格式")
# 构造请求参数
request_params = {
"model": self.model,
"encoding_format": "float",
"input": [input_item],
"dimensions": self.dimensions
}
# 添加自定义指令(251215及以上版本支持)
if instruction and "251215" in self.model:
request_params["instructions"] = instruction
# 调用 API
resp = self.client.multimodal_embeddings.create(**request_params)
if hasattr(resp, 'data') and len(resp.data) > 0 and hasattr(resp.data[0], 'embedding'):
embedding = resp.data[0].embedding
vec = np.array(embedding).flatten().tolist()
# 校验向量维度
if len(vec) != self.dimensions:
raise ValueError(f"向量维度不匹配:期望{self.dimensions}维,实际返回{len(vec)}维")
return vec
else:
raise ValueError("API响应格式错误,未找到embedding字段")
except Exception as e:
error_msg = str(e).lower()
if "api key" in error_msg or "unauthorized" in error_msg or "permission" in error_msg:
raise PermissionError(f"向量化失败:API密钥无效或权限不足。错误详情:{e}")
elif "connection" in error_msg or "timeout" in error_msg or "network" in error_msg:
raise ConnectionError(f"向量化失败:网络连接异常。错误详情:{e}")
elif "invalid" in error_msg or "parameter" in error_msg or "format" in error_msg:
raise ValueError(f"向量化失败:输入参数或格式错误。错误详情:{e}")
else:
raise Exception(f"向量化失败:{e}")
def encode_text(self, text: str, instruction: str = None) -> List[float]:
"""文本向量化"""
return self.encode(text, "text", instruction)
def encode_image(self, image_url: str, instruction: str = None) -> List[float]:
"""图片URL向量化"""
input_item = {"type": "image_url", "image_url": {"url": image_url}}
return self.encode(input_item, "image", instruction)
def encode_video(self, video_url: str, instruction: str = None) -> List[float]:
"""视频URL向量化"""
input_item = {"type": "video_url", "video_url": {"url": video_url}}
return self.encode(input_item, "video", instruction)
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
使用示例代码
"""
# ==================== 示例1: 初始化客户端 ====================
"""
from reference import ByteHouseMultimodalSearch
# 方式1:HTTP连接(默认,适合大多数场景)
search = ByteHouseMultimodalSearch(
connection_type="http",
secure=True,
compress="zstd"
)
# 方式2:TCP连接(适合高并发、大数据量写入)
search = ByteHouseMultimodalSearch(
connection_type="tcp",
connect_timeout=300,
send_receive_timeout=1000
)
"""
# ==================== 示例2: 创建多模态检索表 ====================
"""
search.create_multimodal_table(
table_name="multimodal_index",
enable_text_search=True,
index_type="HNSW",
metric="COSINE"
)
"""
# ==================== 示例3: 插入数据 ====================
"""
# 插入文本
search.insert_document(
table_name="multimodal_index",
doc_id=1,
content_type="text",
content="ByteHouse 是火山引擎推出的云原生数据仓库",
title="ByteHouse 介绍",
metadata={"category": "文档"}
)
# 插入图片
search.insert_document(
table_name="multimodal_index",
doc_id=2,
content_type="image",
content="https://example.com/image.jpg",
title="架构图",
metadata={"category": "图片"}
)
# 批量插入
documents = [
{"doc_id": 3, "content_type": "text", "content": "向量检索能力", "title": "功能介绍"},
{"doc_id": 4, "content_type": "image", "content": "https://example.com/img2.jpg", "title": "示意图"}
]
result = search.insert_batch_documents(
table_name="multimodal_index",
documents=documents
)
print(f"成功插入 {result['success_count']} 条")
"""
# ==================== 示例4: 向量检索 ====================
"""
query_embedding = search.embedding.encode_text("云原生数据仓库")
results = search.vector_search(
table_name="multimodal_index",
query_embedding=query_embedding,
top_k=5
)
"""
# ==================== 示例5: 以文搜图 ====================
"""
results = search.text_search_image(
table_name="multimodal_index",
query_text="ByteHouse 架构图",
top_k=3
)
"""
# ==================== 示例6: 以图搜图 ====================
"""
results = search.image_search_image(
table_name="multimodal_index",
image_url="https://example.com/query-image.jpg",
top_k=5
)
"""
# ==================== 示例7: 混合检索 ====================
"""
results = search.hybrid_search(
table_name="multimodal_index",
query_text="ByteHouse 视频教程",
top_k=5,
vector_weight=0.6,
text_weight=0.4
)
"""
# ==================== 示例8: 带条件过滤的检索 ====================
"""
results = search.text_search_image(
table_name="multimodal_index",
query_text="架构图",
top_k=5,
filter_condition="create_time >= now() - INTERVAL 7 DAY"
)
"""
# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
ByteHouse 多模态检索客户端
支持向量检索、混合检索、以文搜图、以图搜图等功能
"""
import os
import json
import asyncio
from typing import List, Dict, Any
from .embedding import MultimodalEmbedding
class ByteHouseMultimodalSearch:
"""ByteHouse多模态检索客户端"""
def __init__(self,
connection_type: str = "http",
secure: bool = True,
compress: str = "zstd",
connect_timeout: int = 300,
send_receive_timeout: int = 1000,
prefer_mcp: bool = True):
"""
初始化ByteHouse多模态检索客户端
Args:
connection_type: 连接方式,可选 http/tcp
secure: 是否启用加密连接
compress: 压缩方式,可选 zstd/lz4/False
connect_timeout: 连接超时时间,单位秒
send_receive_timeout: 请求超时时间,单位秒
prefer_mcp: 是否优先使用ByteHouse MCP Skill
"""
self.connection_type = connection_type
self.dimensions = int(os.environ.get("EMBEDDING_DIMENSIONS", 1536))
self.embedding = MultimodalEmbedding()
self.use_mcp = False
self.mcp_client = None
# 优先尝试使用MCP连接
if prefer_mcp:
try:
from mcp_client import ByteHouseMCPClient
async def test_mcp_connection():
async with ByteHouseMCPClient() as client:
await client.connect()
return client
self.mcp_client = asyncio.run(test_mcp_connection())
self.use_mcp = True
except Exception:
self.use_mcp = False
# MCP不可用时使用原生驱动连接
if not self.use_mcp:
if connection_type == "http":
import clickhouse_connect
self.client = clickhouse_connect.get_client(
host=os.environ.get("BYTEHOUSE_HOST"),
port=int(os.environ.get("BYTEHOUSE_PORT", 8123)),
username=os.environ.get("BYTEHOUSE_USER"),
password=os.environ.get("BYTEHOUSE_PASSWORD"),
database=os.environ.get("BYTEHOUSE_DATABASE", "default"),
secure=secure,
compress=compress,
send_receive_timeout=send_receive_timeout
)
elif connection_type == "tcp":
from clickhouse_driver import Client
self.client = Client(
host=os.environ.get("BYTEHOUSE_HOST"),
port=int(os.environ.get("BYTEHOUSE_PORT", 9000)),
user=os.environ.get("BYTEHOUSE_USER"),
password=os.environ.get("BYTEHOUSE_PASSWORD"),
database=os.environ.get("BYTEHOUSE_DATABASE", "default"),
connect_timeout=connect_timeout,
send_receive_timeout=send_receive_timeout,
compression=compress if compress else False,
secure=secure,
client_revision=54430
)
else:
raise ValueError(f"不支持的连接类型: {connection_type}")
def _execute_sql(self, sql: str, query_type: str = "select"):
"""内部通用SQL执行方法,自动适配MCP和原生驱动"""
try:
if self.use_mcp:
tool_name = "run_select_query" if query_type == "select" else "run_dml_ddl_query"
async def run_mcp_query():
return await self.mcp_client.call_tool(tool_name, {"query": sql})
result = asyncio.run(run_mcp_query())
if result and len(result) > 0:
try:
return [list(item.values()) for item in json.loads(result[0])]
except:
return [line.split('\t') for line in result[0].strip().split('\n')]
return []
else:
if query_type == "select":
result = self.client.query(sql)
return result.result_rows if hasattr(result, 'result_rows') else result
else:
return self.client.command(sql)
except Exception as e:
error_msg = str(e).lower()
if "connection" in error_msg or "timeout" in error_msg:
raise ConnectionError(f"数据库连接异常:{e}")
elif "syntax" in error_msg or "parse" in error_msg:
raise ValueError(f"SQL语法错误:{e}")
elif "permission" in error_msg or "auth" in error_msg:
raise PermissionError(f"权限不足:{e}")
else:
raise Exception(f"数据库操作失败:{e}")
def create_multimodal_table(self,
table_name: str,
enable_text_search: bool = True,
index_type: str = "HNSW",
metric: str = "COSINE",
hnsw_m: int = 32,
hnsw_ef_construction: int = 512):
"""
创建多模态检索表
Args:
table_name: 表名
enable_text_search: 是否开启全文检索
index_type: 索引类型,可选 HNSW/HNSW_SQ/IVF_FLAT/IVF_PQ/IVF_PQ_FS
metric: 距离度量,可选 COSINE/L2
hnsw_m: HNSW 每个节点最大连接数
hnsw_ef_construction: HNSW 构建时探索因子
"""
if index_type in ["HNSW", "HNSW_SQ"]:
index_config = f"TYPE {index_type}('DIM={self.dimensions}, METRIC={metric}, M={hnsw_m}, EF_CONSTRUCTION={hnsw_ef_construction}')"
else:
index_config = f"TYPE {index_type}('dim={self.dimensions}', 'metric={metric}')"
text_index = f"INDEX text_idx (title, content) TYPE inverted" if enable_text_search else ""
create_sql = f"""
CREATE TABLE IF NOT EXISTS {table_name} (
id UInt64 COMMENT '唯一ID',
content_type Enum('text' = 1, 'image' = 2, 'video' = 3) COMMENT '内容类型',
content String COMMENT '原始内容或URL',
title String COMMENT '标题/描述',
embedding Array(Float32) COMMENT '向量',
CONSTRAINT cons_vec_len CHECK length(embedding) = {self.dimensions},
metadata Map(String, String) COMMENT '元数据',
create_time DateTime DEFAULT now() COMMENT '创建时间',
INDEX vec_idx embedding {index_config},
{text_index}
) ENGINE = MergeTree
ORDER BY id
SETTINGS
index_granularity = 1024,
index_granularity_bytes = 0,
enable_vector_index_preload = 1
"""
self._execute_sql(create_sql, query_type="ddl")
def insert_document(self,
table_name: str,
doc_id: int,
content_type: str,
content: str,
title: str = "",
metadata: Dict = None,
embedding: List[float] = None,
instruction: str = None) -> bool:
"""插入单条文档"""
if embedding is None:
if content_type == "text":
embedding = self.embedding.encode_text(content, instruction)
elif content_type == "image":
embedding = self.embedding.encode_image(content, instruction)
elif content_type == "video":
embedding = self.embedding.encode_video(content, instruction)
else:
raise ValueError(f"不支持的内容类型: {content_type}")
if len(embedding) != self.dimensions:
raise ValueError(f"向量维度错误:期望{self.dimensions}维,实际{len(embedding)}维")
metadata_str = json.dumps(metadata).replace("'", "''") if metadata else "{}"
insert_sql = f"""
INSERT INTO {table_name}
(id, content_type, content, title, embedding, metadata)
VALUES
({doc_id}, '{content_type}', '{content.replace("'", "''")}',
'{title.replace("'", "''")}', {embedding}, '{metadata_str}')
"""
self._execute_sql(insert_sql, query_type="dml")
return True
def insert_batch_documents(self,
table_name: str,
documents: List[Dict],
instruction: str = None,
skip_error: bool = True) -> Dict:
"""批量插入文档"""
rows = []
failed = []
for idx, doc in enumerate(documents):
try:
required_fields = ["doc_id", "content_type", "content"]
for field in required_fields:
if field not in doc:
raise ValueError(f"缺少必填字段: {field}")
if doc.get('embedding'):
embedding = doc['embedding']
if len(embedding) != self.dimensions:
raise ValueError(f"向量维度错误")
else:
if doc['content_type'] == "text":
embedding = self.embedding.encode_text(doc['content'], instruction)
elif doc['content_type'] == "image":
embedding = self.embedding.encode_image(doc['content'], instruction)
elif doc['content_type'] == "video":
embedding = self.embedding.encode_video(doc['content'], instruction)
else:
raise ValueError(f"不支持的内容类型: {doc['content_type']}")
metadata = doc.get('metadata', {})
metadata_str = json.dumps(metadata).replace("'", "''")
rows.append([
doc['doc_id'],
doc['content_type'],
doc['content'].replace("'", "''"),
doc.get('title', '').replace("'", "''"),
embedding,
metadata_str
])
except Exception as e:
failed.append({"doc_id": doc.get("doc_id", idx), "error": str(e)})
if not skip_error:
raise
if rows:
try:
if self.use_mcp:
values_str = [f"({row[0]}, '{row[1]}', '{row[2]}', '{row[3]}', {row[4]}, '{row[5]}')" for row in rows]
insert_sql = f"INSERT INTO {table_name} VALUES {','.join(values_str)}"
self._execute_sql(insert_sql, query_type="dml")
else:
if self.connection_type == "http":
self.client.insert(
table_name,
rows,
column_names=['id', 'content_type', 'content', 'title', 'embedding', 'metadata'],
column_type_names=['UInt64', 'Enum', 'String', 'String', 'Array(Float32)', 'Map(String, String)']
)
else:
self.client.execute(
f'INSERT INTO {table_name} VALUES',
rows
)
success_count = len(rows)
except Exception as e:
for row in rows:
failed.append({"doc_id": row[0], "error": f"批量插入失败: {str(e)}"})
success_count = 0
else:
success_count = 0
return {
"success_count": success_count,
"failed_count": len(failed),
"failed_details": failed
}
def vector_search(self,
table_name: str,
query_embedding: List[float],
top_k: int = 10,
filter_condition: str = None,
metric: str = "COSINE",
hnsw_ef_s: int = 200) -> List[Dict]:
"""纯向量检索"""
distance_func = "cosineDistance" if metric == "COSINE" else "L2Distance"
sql = f"""
SELECT
id, content_type, content, title, metadata, create_time,
{distance_func}(embedding, {query_embedding}) AS score
FROM {table_name}
{f"WHERE {filter_condition}" if filter_condition else ""}
ORDER BY score ASC
LIMIT {top_k}
SETTINGS enable_new_ann = 1, hnsw_ef_s = {hnsw_ef_s}
"""
rows = self._execute_sql(sql, query_type="select")
columns = ['id', 'content_type', 'content', 'title', 'metadata', 'create_time', 'score']
return [dict(zip(columns, row)) for row in rows]
def hybrid_search(self,
table_name: str,
query_text: str,
query_embedding: List[float] = None,
top_k: int = 10,
filter_condition: str = None,
vector_weight: float = 0.7,
text_weight: float = 0.3,
metric: str = "COSINE") -> List[Dict]:
"""混合检索:向量检索 + 全文检索"""
if query_embedding is None:
query_embedding = self.embedding.encode_text(query_text)
vector_results = self.vector_search(table_name, query_embedding, top_k * 2, filter_condition, metric)
text_search_sql = f"""
SELECT id, content_type, content, title, metadata, create_time, 1.0 AS text_score
FROM {table_name}
WHERE (title LIKE '%{query_text.replace("'", "''")}%' OR content LIKE '%{query_text.replace("'", "''")}%')
{f'AND {filter_condition}' if filter_condition else ''}
LIMIT {top_k * 2}
"""
text_rows = self._execute_sql(text_search_sql, query_type="select")
text_results = [dict(zip(['id', 'content_type', 'content', 'title', 'metadata', 'create_time', 'text_score'], row))
for row in text_rows]
# RRF 融合算法
all_results = {}
k = 60
for rank, item in enumerate(vector_results):
doc_id = item['id']
if doc_id not in all_results:
all_results[doc_id] = item
all_results[doc_id]['vector_rank'] = rank
for rank, item in enumerate(text_results):
doc_id = item['id']
if doc_id not in all_results:
all_results[doc_id] = item
all_results[doc_id]['text_rank'] = rank
for doc_id, item in all_results.items():
vector_score = 1.0 / (k + item.get('vector_rank', 10000))
text_score = 1.0 / (k + item.get('text_rank', 10000))
item['final_score'] = vector_weight * vector_score + text_weight * text_score
sorted_results = sorted(all_results.values(), key=lambda x: x['final_score'], reverse=True)
return sorted_results[:top_k]
def text_search_image(self, table_name: str, query_text: str, top_k: int = 10, **kwargs) -> List[Dict]:
"""以文搜图"""
filter_cond = "content_type = 'image'"
if 'filter_condition' in kwargs:
filter_cond += f" AND {kwargs.pop('filter_condition')}"
instruction = kwargs.pop('instruction', None)
query_embedding = self.embedding.encode_text(query_text, instruction)
return self.vector_search(table_name, query_embedding, top_k, filter_condition=filter_cond, **kwargs)
def image_search_image(self, table_name: str, image_url: str, top_k: int = 10, **kwargs) -> List[Dict]:
"""以图搜图"""
filter_cond = "content_type = 'image'"
if 'filter_condition' in kwargs:
filter_cond += f" AND {kwargs.pop('filter_condition')}"
instruction = kwargs.pop('instruction', None)
query_embedding = self.embedding.encode_image(image_url, instruction)
return self.vector_search(table_name, query_embedding, top_k, filter_condition=filter_cond, **kwargs)
def text_search_video(self, table_name: str, query_text: str, top_k: int = 10, **kwargs) -> List[Dict]:
"""以文搜视频"""
filter_cond = "content_type = 'video'"
if 'filter_condition' in kwargs:
filter_cond += f" AND {kwargs.pop('filter_condition')}"
instruction = kwargs.pop('instruction', None)
query_embedding = self.embedding.encode_text(query_text, instruction)
return self.vector_search(table_name, query_embedding, top_k, filter_condition=filter_cond, **kwargs)