package doris
import (
"context"
"fmt"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/logger"
)
// 默认的桶数 / 副本数。Doris 在 PROPERTIES 不指定时会用集群默认值,
// 这里给一个对单机/小集群更友好的保守值。
const (
defaultBucketsNum = 10
defaultReplicationNum = 1
// ANN 索引就绪轮询的最大等待时间。索引未就绪不阻塞写入路径,
// 只阻塞 ensureTable 自身(首次建表场景),所以 30s 是可接受的。
annReadyTimeout = 30 * time.Second
annReadyPoll = 1 * time.Second
)
// getTableName 返回某个维度对应的物理表名:_。
//
// 与 Qdrant/Milvus/Weaviate 的 collection 命名约定一致,
// 这样不同 embedding 模型(不同维度)的数据互不冲突。
func (r *dorisRepository) getTableName(dimension int) string {
return fmt.Sprintf("%s_%d", r.tableBaseName, dimension)
}
// ensureTable 保证目标维度对应的表已经存在;
// 不存在则用 CREATE TABLE IF NOT EXISTS 创建,并在创建后轮询 ANN 索引就绪。
//
// 该方法在每次 Save / BatchSave 之前调用,结果缓存在 initializedTables 中,
// 同一进程内同一 dimension 只会真正打一次 SHOW TABLES + DDL。
func (r *dorisRepository) ensureTable(ctx context.Context, dimension int) error {
if _, ok := r.initializedTables.Load(dimension); ok {
return nil
}
compatMode, err := r.resolveCompatMode(ctx)
if err != nil {
return err
}
log := logger.GetLogger(ctx)
tableName := r.getTableName(dimension)
exists, err := r.tableExists(ctx, tableName)
if err != nil {
log.Errorf("[Doris] Failed to check table existence: %v", err)
return fmt.Errorf("check table existence: %w", err)
}
if !exists {
log.Infof("[Doris] Creating table %s with dimension %d in compat mode %s", tableName, dimension, compatMode)
if err := r.createTable(ctx, tableName, dimension, compatMode); err != nil {
log.Errorf("[Doris] Failed to create table: %v", err)
return fmt.Errorf("create table: %w", err)
}
// ANN 索引在 Doris 端异步构建。这里在后台 goroutine 里轮询就绪,
// 写入路径不阻塞——索引未就绪期间检索会退化为 brute-force(结果对、速度慢),
// 比让首批写入卡 30s 更可接受。
go func(tn string) {
// 用独立 context(带 timeout),避免请求级 ctx 取消把后台轮询也带走。
bgCtx, cancel := context.WithTimeout(context.Background(), annReadyTimeout)
defer cancel()
if err := r.waitANNReady(bgCtx, tn); err != nil {
logger.GetLogger(bgCtx).Warnf(
"[Doris] ANN index for %s not ready within %s: %v "+
"(queries may fall back to brute force temporarily)",
tn, annReadyTimeout, err)
return
}
logger.GetLogger(bgCtx).Infof("[Doris] ANN index for %s ready", tn)
}(tableName)
}
r.initializedTables.Store(dimension, true)
return nil
}
// tableExists 通过 information_schema 判断表是否存在。
//
// 不直接用 SHOW TABLES 是因为 Doris 4.1 对 SHOW TABLES LIKE 大小写敏感,
// 而 information_schema 与 MySQL 兼容性更好。
func (r *dorisRepository) tableExists(ctx context.Context, tableName string) (bool, error) {
const q = `SELECT COUNT(1) FROM information_schema.tables
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?`
var n int
if err := r.db.QueryRowContext(ctx, q, r.database, tableName).Scan(&n); err != nil {
return false, err
}
return n > 0, nil
}
// createTable 发出 CREATE TABLE DDL。Doris DDL 是同步的(除 ANN 索引构建外),
// 返回成功即代表表已可写。
func (r *dorisRepository) createTable(ctx context.Context, tableName string, dimension int, compatMode dorisCompatMode) error {
buckets := r.bucketsNum
if buckets <= 0 {
buckets = defaultBucketsNum
}
replication := r.replicationNum
if replication <= 0 {
replication = defaultReplicationNum
}
ddl := buildCreateTableDDL(tableName, dimension, buckets, replication, compatMode)
_, err := r.db.ExecContext(ctx, ddl)
if err != nil && compatMode == dorisCompatModeLegacy {
return fmt.Errorf(
"legacy Doris table creation failed: %w. If your Doris build rejects ANN indexes on UNIQUE KEY tables, set %s=%s before creating embedding tables. %s is not interchangeable after %s_* tables are created",
err,
envDorisCompatMode,
dorisCompatModeInnerProductDuplicate,
envDorisCompatMode,
r.tableBaseName,
)
}
return err
}
// buildCreateTableDDL 根据维度生成 CREATE TABLE DDL。
//
// 关键点:
// - DUPLICATE KEY(id):兼容当前 Doris/SelectDB 对 ANN 索引的表模型要求。
// WeKnora 在 Go 端用 delete + insert 保持按 id 替换的写入语义。
// - INVERTED 索引覆盖所有过滤字段 + 中文分词的 content 全文索引。
// - ANN 索引使用 HNSW + inner_product;Doris 写入/查询前会对向量单位化,
// 因此整体仍保持与其他向量库一致的 cosine 相似度语义。
//
// 注意:DDL 中 dimension / buckets / replication 三个数值字段是 Go 端格式化拼接的,
// 不存在 SQL 注入风险(来源都是受控的 IndexConfig int)。
func buildCreateTableDDL(tableName string, dimension, buckets, replication int, compatMode dorisCompatMode) string {
metricType := "inner_product"
keyMode := "DUPLICATE KEY(id)"
properties := fmt.Sprintf("\t\"replication_num\"=\"%d\"", replication)
if compatMode == dorisCompatModeLegacy {
metricType = "cosine_distance"
keyMode = "UNIQUE KEY(id)"
properties = fmt.Sprintf("\t\"replication_num\"=\"%d\",\n\t\"enable_unique_key_merge_on_write\"=\"true\"", replication)
}
const tpl = `CREATE TABLE IF NOT EXISTS ` + "`%s`" + ` (
id VARCHAR(64) NOT NULL,
chunk_id VARCHAR(64),
knowledge_id VARCHAR(64),
knowledge_base_id VARCHAR(64),
source_id VARCHAR(255),
source_type INT,
tag_id VARCHAR(64),
is_enabled BOOLEAN,
content TEXT,
embedding ARRAY NOT NULL,
INDEX idx_chunk (chunk_id) USING INVERTED,
INDEX idx_kb (knowledge_base_id) USING INVERTED,
INDEX idx_kid (knowledge_id) USING INVERTED,
INDEX idx_src (source_id) USING INVERTED,
INDEX idx_tag (tag_id) USING INVERTED,
INDEX idx_enabled (is_enabled) USING INVERTED,
INDEX idx_content (content) USING INVERTED PROPERTIES("parser"="chinese","support_phrase"="true"),
INDEX idx_emb (embedding) USING ANN PROPERTIES(
"index_type"="hnsw",
"metric_type"="%s",
"dim"="%d",
"max_degree"="32",
"ef_construction"="200"
)
) ENGINE=OLAP
%s
DISTRIBUTED BY HASH(id) BUCKETS %d
PROPERTIES(
%s
);`
return fmt.Sprintf(tpl, tableName, metricType, dimension, keyMode, buckets, properties)
}
// waitANNReady 轮询 SHOW INDEX,等待 ANN 索引进入 FINISHED 状态。
//
// Doris 的 ANN 索引在建表后会异步构建,期间查询会退化为 brute-force(结果对,速度慢)。
// 此处仅做"尽力而为"的等待:到点未就绪只记 warning,不阻塞写入。
func (r *dorisRepository) waitANNReady(ctx context.Context, tableName string) error {
deadline := time.Now().Add(annReadyTimeout)
for {
ready, err := r.annIndexReady(ctx, tableName)
if err != nil {
return err
}
if ready {
return nil
}
if time.Now().After(deadline) {
return fmt.Errorf("ann index not ready within %s", annReadyTimeout)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(annReadyPoll):
}
}
}
// annIndexReady 检查 ANN 索引的 State 是否为 FINISHED。
//
// SHOW INDEX FROM 在 Doris 上返回多列;不同小版本列序略有差异,
// 这里以列名匹配(来自 information_schema.statistics + 自定义 view 不可行,
// 直接用 SHOW INDEX 然后扫描即可)。
//
// 兼容策略:如果 SHOW INDEX 返回中找不到 idx_emb 行(极旧版本),视为已就绪,
// 避免因为不同 Doris 版本的输出差异把启动卡死。
func (r *dorisRepository) annIndexReady(ctx context.Context, tableName string) (bool, error) {
rows, err := r.db.QueryContext(ctx,
fmt.Sprintf("SHOW INDEX FROM `%s`", tableName))
if err != nil {
return false, err
}
defer rows.Close()
cols, err := rows.Columns()
if err != nil {
return false, err
}
keyNameIdx, stateIdx := -1, -1
for i, c := range cols {
switch strings.ToLower(c) {
case "key_name":
keyNameIdx = i
case "state", "index_state":
stateIdx = i
}
}
for rows.Next() {
// 使用 sql.RawBytes 接收以兼容不同列类型。
raw := make([]any, len(cols))
ptrs := make([]any, len(cols))
for i := range raw {
ptrs[i] = &raw[i]
}
if err := rows.Scan(ptrs...); err != nil {
return false, err
}
var keyName, state string
if keyNameIdx >= 0 {
keyName = bytesToString(raw[keyNameIdx])
}
if stateIdx >= 0 {
state = bytesToString(raw[stateIdx])
}
if keyName != "idx_emb" {
continue
}
if stateIdx < 0 {
// 旧版本不暴露 state 列,乐观认为已就绪。
return true, nil
}
if !strings.EqualFold(state, "FINISHED") &&
!strings.EqualFold(state, "NORMAL") {
return false, nil
}
}
if err := rows.Err(); err != nil {
return false, err
}
// 走到这里有两种情况:
// 1. 找到了 idx_emb 行,且 state 已是 FINISHED/NORMAL(或 stateIdx<0 的旧版本);
// 2. 没找到 idx_emb 行(极旧 Doris 不暴露该索引名);
// 都视为已就绪,不阻塞。未就绪的分支已在循环内提前 return false。
return true, nil
}
// listEmbeddingTables 返回当前 database 下所有 _% 命名的表,
// 用于关键词检索 / 跨维度 BatchUpdate。
func (r *dorisRepository) listEmbeddingTables(ctx context.Context) ([]string, error) {
const q = `SELECT TABLE_NAME FROM information_schema.tables
WHERE TABLE_SCHEMA = ? AND TABLE_NAME LIKE ?`
rows, err := r.db.QueryContext(ctx, q, r.database, r.tableBaseName+"\\_%")
if err != nil {
return nil, err
}
defer rows.Close()
var names []string
for rows.Next() {
var n string
if err := rows.Scan(&n); err != nil {
return nil, err
}
names = append(names, n)
}
return names, rows.Err()
}
// bytesToString 把 SHOW INDEX 返回的 raw any(通常是 []byte 或 string)
// 安全转成字符串。
func bytesToString(v any) string {
switch s := v.(type) {
case []byte:
return string(s)
case string:
return s
case nil:
return ""
default:
return fmt.Sprintf("%v", s)
}
}