
Byted Bytehouse Multimodal Search
- 31 installs
- 411 repo stars
- Updated August 4, 2026
- bytedance/agentkit-samples
Vectorizes text, images, and video and stores them in ByteHouse for multimodal vector storage and hybrid retrieval.
About
Handles multimodal vectorization and hybrid retrieval of text, image, and video data in ByteHouse. A developer uses it to store and search multimodal embeddings.
- Text, image, and video vectorization
- Multimodal storage and hybrid retrieval in ByteHouse
Byted Bytehouse Multimodal Search by the numbers
- 31 all-time installs (skills.sh)
- Ranked #500 of 911 Databases skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/bytedance/agentkit-samples --skill byted-bytehouse-multimodal-searchAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 31 |
|---|---|
| repo stars | ★ 411 |
| Last updated | August 4, 2026 |
| Repository | bytedance/agentkit-samples ↗ |
What it does
Vectorizes text, images, and video and stores them in ByteHouse for multimodal vector storage and hybrid retrieval.
Files
ByteHouse 多模态检索 Skill
🚀 快速开始
环境准备
pip install clickhouse-connect volcengine-python-sdk[ark] numpy配置说明
配置保存在 ~/.bytehouse_config.json ,如果该文件存在且非空,则直接使用文件中的配置。如果不存在,则让用户提供ByteHouse连接信息( 把这个文档也发给客户,文档里面介绍了如何获取主机地址和密码:https://www.volcengine.com/docs/6517/1121919?lang=zh )。用户提供信息后,保存到json文件,避免重复向用户请求连接信息。当用户切换ByteHouse集群时,一并修改该文件。
{
"BYTEHOUSE_HOST": "<ByteHouse-host>",
"BYTEHOUSE_PORT": "8123",
"BYTEHOUSE_USER": "bytehouse",
"BYTEHOUSE_PASSWORD": "<ByteHouse-password>",
"BYTEHOUSE_SECURE": true,
"BYTEHOUSE_VERIFY": true,
"BH_ARK_API_KEY": "<火山引擎方舟API密钥>",
"BH_ARK_BASE_URL": "https://ark.cn-beijing.volces.com/api/v3",
"BH_EMBEDDING_MODEL": "doubao-embedding-vision-251215"
}其中BYTEHOUSE_HOST(主机地址)和BYTEHOUSE_PASSWORD(密码)必须由用户提供。BH_ARK_API_KEY为可选配置,仅在embedding时使用,用户初次使用时可忽略。其余配置固定。
执行 scripts/export_config.sh 把配置信息导入环境变量中
source scripts/export_config.sh---
📚 核心能力
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` - 使用示例
- `scripts/export_config.sh` - 把配置文件中的信息导入环境变量
快速使用
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: 向量维度怎么选?
- 推荐 2048 维作为通用值
- 维度越高精度越高,但成本也越高
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 json
import numpy as np
from volcenginesdkarkruntime import Ark
from typing import List, Union, Dict
class MultimodalEmbedding:
"""多模态向量化客户端"""
def __init__(self):
# 先尝试从环境变量读取配置
api_key = os.environ.get("BH_ARK_API_KEY")
base_url = os.environ.get("BH_ARK_BASE_URL")
# 如果环境变量没有配置,尝试从OpenClaw配置文件读取
if not api_key or not base_url:
config_path = os.path.expanduser("~/.openclaw/openclaw.json")
if os.path.exists(config_path):
try:
with open(config_path, "r", encoding="utf-8") as f:
config = json.load(f)
ark_config = config.get("models", {}).get("providers", {}).get("ark", {})
if not api_key:
api_key = ark_config.get("apiKey")
if not base_url:
base_url = ark_config.get("baseUrl")
except Exception as e:
print(f"读取OpenClaw配置文件失败: {str(e)}")
# 检查必要配置是否存在
if not api_key:
raise ValueError("未找到ARK API Key,请配置BH_ARK_API_KEY环境变量或在openclaw.json中配置models.providers.ark.apiKey")
if not base_url:
raise ValueError("未找到ARK Base URL,请配置BH_ARK_BASE_URL环境变量或在openclaw.json中配置models.providers.ark.baseUrl")
self.client = Ark(
api_key=api_key,
base_url=base_url
)
self.model = os.environ.get("BH_EMBEDDING_MODEL", "doubao-embedding-vision-251215")
self.dimensions = int(os.environ.get("EMBEDDING_DIMENSIONS", 2048))
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'):
embedding = resp.data.embedding
vec = np.array(embedding).flatten().tolist()
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)
def main():
"""命令行入口"""
print("Hello, World!")
multimodal_embedding = MultimodalEmbedding()
print(multimodal_embedding.encode_text("你好"))
if __name__ == "__main__":
main()# 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"
)
"""
#!/bin/bash
load_config() {
# 检查 jq 是否安装
if ! command -v jq &> /dev/null; then
echo "Error: jq is not installed. Please install jq first (e.g. brew install jq or sudo apt install jq)."
return 1
fi
# 解析 json,将每个 key-value 转成 export KEY="VALUE" 的格式
local exports
exports=$(jq -r 'to_entries | .[] | "export \(.key)=\(.value | @sh)"' ~/.bytehouse_config.json)
# 执行生成的 export 命令
eval "$exports"
echo "Configuration loaded from ~/.bytehouse_config.json"
}
load_config# 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", 2048))
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 _check_dql(self, sql: str, force: bool = False):
"""检查SQL是否为DQL,如果不是则要求确认"""
import sys
sql_upper = sql.strip().upper()
is_dql = any(sql_upper.startswith(prefix) for prefix in ("SELECT", "SHOW", "DESC", "DESCRIBE", "EXPLAIN", "WITH", "CREATE"))
if not is_dql and not force:
if sys.stdin.isatty():
confirm = input(f"警告: 检测到非DQL查询:\n{sql}\n是否确认执行?(y/N): ")
if confirm.lower() not in ['y', 'yes']:
print("已取消执行。", file=sys.stderr)
sys.exit(0)
else:
print("Error: 这是一个非DQL查询(如INSERT/UPDATE/DROP等)。作为AI助手,请立即中断执行,并向用户明确询问是否确认执行此操作(展示出具体的SQL)。如果用户明确同意,请在调用时传递 force=True 参数再次执行。", file=sys.stderr)
sys.exit(1)
def _execute_sql(self, sql: str, query_type: str = "select", force: bool = False):
"""内部通用SQL执行方法,自动适配MCP和原生驱动"""
self._check_dql(sql, force)
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,
force: bool = False):
"""
创建多模态检索表
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('standard', '{{\"version\":\"v4\"}}')" 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 = CnchMergeTree
ORDER BY id
SETTINGS
index_granularity = 1024,
index_granularity_bytes = 0,
enable_vector_index_preload = 1
"""
self._execute_sql(create_sql, query_type="ddl", force=True)
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", force=True)
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", force=True)
else:
self._check_dql(f"INSERT INTO {table_name} (批量插入 {len(rows)} 条数据)", True)
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)
query_escaped = self.transform_string(query_text)
text_search_sql = f"""
SELECT id, content_type, content, title, metadata, create_time, _text_search_score AS text_score
FROM {table_name}
WHERE textSearch(content, '{query_escaped}')
{f'AND {filter_condition}' if filter_condition else ''}
ORDER BY text_score DESC
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)
def transform_string(self, s: str) -> str:
# Remove all single quotes
s = s.replace("'", "")
# Split each character with |
return "|".join(s)