Files
ragflow/internal/engine/oceanbase/schema.go
wangyunlai 73d006fa0e Add OceanBase and SeekDB Go document engine (#17780)
## What changed

- add an OceanBase/SeekDB Go document engine using `database/sql` and
the existing MySQL driver
- preserve the Python connector's configuration, physical table names,
schema, index names, and ARRAY/JSON/VECTOR encodings
- implement chunk, memory, document metadata, skill, SQL, full-text,
vector, and fusion search paths
- support `DBMS_HYBRID_SEARCH.SEARCH` behind the existing feature flag,
with SQL fallback only when the package is unavailable
- wire the engine into retrieval, memory, metadata, vector hydration,
and SQL chat flows
- add Python/Go compatibility contracts, SQL mock tests, and an
integration-tagged round-trip test

---------

Co-authored-by: Jin Hai <haijin.chn@gmail.com>
2026-08-10 15:06:32 +08:00

532 lines
17 KiB
Go

//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// 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.
//
package oceanbase
import (
"context"
"crypto/md5" // #nosec G501 -- MD5 provides a deterministic identifier checksum, not security.
"database/sql"
"fmt"
"os"
"regexp"
"strconv"
"strings"
"sync"
"time"
"ragflow/internal/engine/redis"
)
const (
maxIndexNameLength = 64
indexNameHashLength = 4
indexNameTruncationSpace = 8
)
type columnDefinition struct {
name string
typeSQL string
}
var (
identifierPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
metadataKeyPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
ddlLocks sync.Map
)
var chunkColumns = []columnDefinition{
{"id", "VARCHAR(256) NOT NULL PRIMARY KEY"},
{"kb_id", "VARCHAR(256) NOT NULL"},
{"doc_id", "VARCHAR(256) NULL"},
{"docnm_kwd", "VARCHAR(256) NULL"},
{"doc_type_kwd", "VARCHAR(256) NULL"},
{"title_tks", "VARCHAR(256) NULL"},
{"title_sm_tks", "VARCHAR(256) NULL"},
{"content_with_weight", "LONGTEXT NULL"},
{"content_ltks", "LONGTEXT NULL"},
{"content_sm_ltks", "LONGTEXT NULL"},
{"pagerank_fea", "INTEGER NULL"},
{"important_kwd", "ARRAY(VARCHAR(256)) NULL"},
{"important_tks", "TEXT NULL"},
{"question_kwd", "ARRAY(VARCHAR(1024)) NULL"},
{"question_tks", "TEXT NULL"},
{"tag_kwd", "ARRAY(VARCHAR(256)) NULL"},
{"tag_feas", "JSON NULL"},
{"available_int", "INTEGER NOT NULL DEFAULT 1"},
{"create_time", "VARCHAR(19) NULL"},
{"create_timestamp_flt", "DOUBLE NULL"},
{"img_id", "VARCHAR(128) NULL"},
{"position_int", "ARRAY(ARRAY(INTEGER)) NULL"},
{"page_num_int", "ARRAY(INTEGER) NULL"},
{"top_int", "ARRAY(INTEGER) NULL"},
{"knowledge_graph_kwd", "VARCHAR(256) NULL"},
{"source_id", "ARRAY(VARCHAR(256)) NULL"},
{"entity_kwd", "VARCHAR(256) NULL"},
{"entity_type_kwd", "VARCHAR(256) NULL"},
{"from_entity_kwd", "VARCHAR(256) NULL"},
{"to_entity_kwd", "VARCHAR(256) NULL"},
{"weight_int", "INTEGER NULL"},
{"weight_flt", "DOUBLE NULL"},
{"entities_kwd", "ARRAY(VARCHAR(256)) NULL"},
{"rank_flt", "DOUBLE NULL"},
{"n_hop_with_weight", "LONGTEXT NULL"},
{"removed_kwd", "VARCHAR(256) NULL DEFAULT 'N'"},
{"raptor_kwd", "VARCHAR(256) NULL"},
{"raptor_layer_int", "INTEGER NULL"},
{"chunk_data", "JSON NULL"},
{"metadata", "JSON NULL"},
{"extra", "JSON NULL"},
{"_order_id", "INTEGER NULL"},
{"group_id", "VARCHAR(256) NULL"},
{"mom_id", "VARCHAR(256) NULL"},
}
var chunkExtraColumns = selectColumnDefinitions(chunkColumns,
"_order_id",
"group_id",
"mom_id",
"chunk_data",
"raptor_kwd",
"raptor_layer_int",
"n_hop_with_weight",
)
var memoryColumns = []columnDefinition{
{"id", "VARCHAR(256) NOT NULL PRIMARY KEY"},
{"message_id", "VARCHAR(256) NOT NULL"},
{"message_type_kwd", "VARCHAR(64) NULL"},
{"source_id", "VARCHAR(256) NULL"},
{"memory_id", "VARCHAR(256) NOT NULL"},
{"user_id", "VARCHAR(256) NULL"},
{"agent_id", "VARCHAR(256) NULL"},
{"session_id", "VARCHAR(256) NULL"},
{"zone_id", "INTEGER NULL DEFAULT 0"},
{"valid_at", "VARCHAR(64) NULL"},
{"invalid_at", "VARCHAR(64) NULL"},
{"forget_at", "VARCHAR(64) NULL"},
{"status_int", "INTEGER NOT NULL DEFAULT 1"},
{"content_ltks", "LONGTEXT NULL"},
{"tokenized_content_ltks", "LONGTEXT NULL"},
}
var metadataColumns = []columnDefinition{
{"id", "VARCHAR(256) NOT NULL PRIMARY KEY"},
{"kb_id", "VARCHAR(256) NOT NULL"},
{"meta_fields", "JSON NULL"},
}
var skillColumns = []columnDefinition{
{"skill_id", "VARCHAR(256) NOT NULL PRIMARY KEY"},
{"space_id", "VARCHAR(256) NULL"},
{"folder_id", "VARCHAR(256) NULL"},
{"name", "LONGTEXT NULL"},
{"name_tks", "LONGTEXT NULL"},
{"tags", "LONGTEXT NULL"},
{"tags_tks", "LONGTEXT NULL"},
{"description", "LONGTEXT NULL"},
{"description_tks", "LONGTEXT NULL"},
{"content", "LONGTEXT NULL"},
{"content_tks", "LONGTEXT NULL"},
{"version", "VARCHAR(64) NULL"},
{"status", "VARCHAR(64) NULL"},
{"create_time", "BIGINT NULL DEFAULT 0"},
{"update_time", "BIGINT NULL DEFAULT 0"},
}
var chunkIndexColumns = []string{
"kb_id", "doc_id", "available_int", "knowledge_graph_kwd", "entity_type_kwd", "removed_kwd",
}
var memoryIndexColumns = []string{"message_id", "memory_id", "status_int"}
var originalFullTextFields = []string{"docnm_kwd", "content_with_weight", "important_tks", "question_tks"}
var tokenizedFullTextFields = []string{"title_tks", "title_sm_tks", "important_tks", "question_tks", "content_ltks", "content_sm_ltks"}
// CreateChunkStore creates or upgrades the legacy shared tenant table. The
// dataset ID is a row-level discriminator for chunk and memory tables.
func (e *Engine) CreateChunkStore(ctx context.Context, baseName, datasetID string, vectorSize int, parserID string) error {
if err := validateIdentifier(baseName); err != nil {
return err
}
switch {
case strings.HasPrefix(baseName, "skill_") || datasetID == "skill":
if err := e.ensureTable(ctx, baseName, skillColumns, "ob_"); err != nil {
return err
}
for _, field := range []string{"name_tks", "tags_tks", "description_tks", "content_tks"} {
if err := e.ensureFullTextIndex(ctx, baseName, field, "ob_"); err != nil {
return err
}
}
case strings.HasPrefix(baseName, "memory_"):
if err := e.ensureTable(ctx, baseName, memoryColumns, "ob_memory_"); err != nil {
return err
}
for _, field := range memoryIndexColumns {
if err := e.ensureRegularIndex(ctx, baseName, field, "ob_memory_"); err != nil {
return err
}
}
if e.flags.enableFullTextSearch {
for _, field := range []string{"content_ltks", "tokenized_content_ltks"} {
if err := e.ensureFullTextIndex(ctx, baseName, field, "ob_memory_"); err != nil {
return err
}
}
}
default:
if err := e.ensureTable(ctx, baseName, chunkColumns, "ob_"); err != nil {
return err
}
for _, column := range chunkExtraColumns {
if err := e.ensureColumn(ctx, baseName, column, "ob_"); err != nil {
return err
}
}
for _, field := range chunkIndexColumns {
if err := e.ensureRegularIndex(ctx, baseName, field, "ob_"); err != nil {
return err
}
}
if e.flags.enableFullTextSearch {
fields := tokenizedFullTextFields
if e.flags.searchOriginalContent {
fields = originalFullTextFields
}
for _, field := range fields {
if err := e.ensureFullTextIndex(ctx, baseName, field, "ob_"); err != nil {
return err
}
}
}
}
return e.ensureVectorColumnAndIndex(ctx, baseName, vectorSize, lockPrefix(baseName))
}
func (e *Engine) ensureTable(ctx context.Context, tableName string, columns []columnDefinition, prefix string) error {
return e.ensureTableWithLock(ctx, tableName, columns, prefix+"create_table_"+tableName)
}
func (e *Engine) ensureTableWithLock(ctx context.Context, tableName string, columns []columnDefinition, lockName string) error {
return e.withDDLLock(ctx, lockName, func() (bool, error) {
return e.tableExists(ctx, tableName)
}, func() error {
definitions := make([]string, 0, len(columns))
for _, column := range columns {
definitions = append(definitions, quoteIdentifier(column.name)+" "+column.typeSQL)
}
query := fmt.Sprintf("CREATE TABLE %s (%s) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ORGANIZATION=heap",
quoteIdentifier(tableName), strings.Join(definitions, ", "))
_, err := e.db.ExecContext(ctx, query)
return err
})
}
func (e *Engine) ensureColumn(ctx context.Context, tableName string, column columnDefinition, prefix string) error {
return e.withDDLLock(ctx, prefix+"add_"+column.name+"_"+tableName, func() (bool, error) {
return e.columnExists(ctx, tableName, column.name)
}, func() error {
_, err := e.db.ExecContext(ctx, fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s",
quoteIdentifier(tableName), quoteIdentifier(column.name), column.typeSQL))
return err
})
}
func (e *Engine) ensureRegularIndex(ctx context.Context, tableName, columnName, prefix string) error {
indexName := regularIndexName(tableName, columnName)
return e.withDDLLock(ctx, prefix+"add_idx_"+tableName+"_"+columnName, func() (bool, error) {
return e.indexExists(ctx, tableName, indexName)
}, func() error {
_, err := e.db.ExecContext(ctx, fmt.Sprintf("CREATE INDEX %s ON %s (%s)",
quoteIdentifier(indexName), quoteIdentifier(tableName), quoteIdentifier(columnName)))
return err
})
}
func regularIndexName(tableName, columnName string) string {
indexName := fmt.Sprintf("ix_%s_%s", tableName, columnName)
if len(indexName) <= maxIndexNameLength {
return indexName
}
digest := fmt.Sprintf("%x", md5.Sum([]byte(indexName))) // #nosec G401 -- This is a non-security identifier checksum.
suffix := "_" + digest[len(digest)-indexNameHashLength:]
return indexName[:maxIndexNameLength-indexNameTruncationSpace] + suffix
}
func (e *Engine) ensureFullTextIndex(ctx context.Context, tableName, columnName, prefix string) error {
indexName := "fts_idx_" + columnName
return e.withDDLLock(ctx, prefix+"add_fulltext_idx_"+tableName+"_"+columnName, func() (bool, error) {
return e.indexExists(ctx, tableName, indexName)
}, func() error {
_, err := e.db.ExecContext(ctx, fmt.Sprintf("CREATE FULLTEXT INDEX %s ON %s (%s) WITH PARSER IK",
quoteIdentifier(indexName), quoteIdentifier(tableName), quoteIdentifier(columnName)))
return err
})
}
func (e *Engine) ensureVectorColumnAndIndex(ctx context.Context, tableName string, vectorSize int, prefix string) error {
if vectorSize <= 0 {
return nil
}
columnName := fmt.Sprintf("q_%d_vec", vectorSize)
if err := e.withDDLLock(ctx, prefix+"add_vector_column_"+tableName+"_"+columnName, func() (bool, error) {
return e.columnExists(ctx, tableName, columnName)
}, func() error {
_, err := e.db.ExecContext(ctx, fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s VECTOR(%d) NULL",
quoteIdentifier(tableName), quoteIdentifier(columnName), vectorSize))
return err
}); err != nil {
return err
}
indexName := columnName + "_idx"
return e.withDDLLock(ctx, prefix+"add_vector_idx_"+tableName+"_"+columnName, func() (bool, error) {
return e.indexExists(ctx, tableName, indexName)
}, func() error {
_, err := e.db.ExecContext(ctx, fmt.Sprintf("CREATE VECTOR INDEX %s ON %s (%s) WITH (distance=cosine, type=hnsw, lib=vsag)",
quoteIdentifier(indexName), quoteIdentifier(tableName), quoteIdentifier(columnName)))
return err
})
}
func (e *Engine) withDDLLock(ctx context.Context, lockName string, check func() (bool, error), action func() error) error {
value, _ := ddlLocks.LoadOrStore(lockName, &sync.Mutex{})
lock := value.(*sync.Mutex)
lock.Lock()
defer lock.Unlock()
exists, err := check()
if err != nil {
return err
}
if exists {
return nil
}
timeoutSeconds := int64(60)
if raw := strings.TrimSpace(os.Getenv("OB_DDL_TIMEOUT")); raw != "" {
if parsed, parseErr := strconv.ParseInt(raw, 10, 64); parseErr == nil && parsed > 0 {
timeoutSeconds = parsed
}
}
timeout := time.Duration(timeoutSeconds) * time.Second
distributed := redis.NewDistributedLock(lockName, "", timeout, timeout)
if distributed != nil && !distributed.Acquire(ctx) {
deadline := time.NewTimer(timeout)
defer deadline.Stop()
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
waitForLock:
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-deadline.C:
return fmt.Errorf("timeout waiting for DDL %s", lockName)
case <-ticker.C:
exists, err = check()
if err != nil {
return err
}
if exists {
return nil
}
if distributed.Acquire(ctx) {
break waitForLock
}
}
}
}
if distributed != nil {
defer distributed.Release(ctx)
exists, err = check()
if err != nil {
return err
}
if exists {
return nil
}
}
if err := action(); err != nil && !isDuplicateDDLError(err) {
return fmt.Errorf("DDL %s: %w", lockName, err)
}
exists, err = check()
if err != nil {
return err
}
if !exists {
return fmt.Errorf("DDL %s completed without creating the requested object", lockName)
}
return nil
}
func (e *Engine) tableExists(ctx context.Context, tableName string) (bool, error) {
var count int
err := e.db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?",
e.dbName, tableName).Scan(&count)
return count > 0, err
}
func (e *Engine) columnExists(ctx context.Context, tableName, columnName string) (bool, error) {
var count int
err := e.db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ?",
e.dbName, tableName, columnName).Scan(&count)
return count > 0, err
}
func (e *Engine) indexExists(ctx context.Context, tableName, indexName string) (bool, error) {
var count int
err := e.db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND INDEX_NAME = ?",
e.dbName, tableName, indexName).Scan(&count)
return count > 0, err
}
func (e *Engine) findVectorColumn(ctx context.Context, tableName, expectedColumn string) (string, error) {
query := "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME REGEXP '^q_[0-9]+_vec$'"
args := []interface{}{e.dbName, tableName}
if expectedColumn != "" {
if !vectorColumnPattern.MatchString(expectedColumn) {
return "", fmt.Errorf("invalid vector column: %s", expectedColumn)
}
query += " AND COLUMN_NAME = ?"
args = append(args, expectedColumn)
}
query += " ORDER BY COLUMN_NAME LIMIT 1"
var columnName string
err := e.db.QueryRowContext(ctx, query, args...).Scan(&columnName)
if err == sql.ErrNoRows {
return "", nil
}
return columnName, err
}
// ChunkStoreExists checks the shared physical table. CreateChunkStore performs
// the additive compatibility upgrade when a caller needs a new vector size.
func (e *Engine) ChunkStoreExists(ctx context.Context, baseName, datasetID string) (bool, error) {
if err := validateIdentifier(baseName); err != nil {
return false, err
}
exists, err := e.tableExists(ctx, baseName)
if err != nil || !exists {
return exists, err
}
kind := tableKind(baseName, datasetID)
var indexColumns, fullTextColumns []string
switch kind {
case "memory":
indexColumns = memoryIndexColumns
if e.flags.enableFullTextSearch {
fullTextColumns = []string{"content_ltks", "tokenized_content_ltks"}
}
case "skill":
fullTextColumns = []string{"name_tks", "tags_tks", "description_tks", "content_tks"}
default:
indexColumns = chunkIndexColumns
if e.flags.enableFullTextSearch {
fullTextColumns = tokenizedFullTextFields
if e.flags.searchOriginalContent {
fullTextColumns = originalFullTextFields
}
}
for _, column := range chunkExtraColumns {
exists, err = e.columnExists(ctx, baseName, column.name)
if err != nil || !exists {
return exists, err
}
}
}
for _, column := range indexColumns {
exists, err = e.indexExists(ctx, baseName, regularIndexName(baseName, column))
if err != nil || !exists {
return exists, err
}
}
for _, column := range fullTextColumns {
exists, err = e.indexExists(ctx, baseName, "fts_idx_"+column)
if err != nil || !exists {
return exists, err
}
}
return true, nil
}
// DropChunkStore keeps shared chunk and memory tables alive when only one
// dataset is removed. Skill tables and explicitly unscoped calls are dropped.
func (e *Engine) DropChunkStore(ctx context.Context, baseName, datasetID string) error {
if err := validateIdentifier(baseName); err != nil {
return err
}
if datasetID != "" && datasetID != "skill" {
exists, err := e.tableExists(ctx, baseName)
if err != nil {
return err
}
if !exists {
return nil
}
field := "kb_id"
if strings.HasPrefix(baseName, "memory_") {
field = "memory_id"
}
_, err = e.db.ExecContext(ctx, fmt.Sprintf("DELETE FROM %s WHERE %s = ?",
quoteIdentifier(baseName), quoteIdentifier(field)), datasetID)
return err
}
_, err := e.db.ExecContext(ctx, "DROP TABLE IF EXISTS "+quoteIdentifier(baseName))
return err
}
func lockPrefix(tableName string) string {
if strings.HasPrefix(tableName, "memory_") {
return "ob_memory_"
}
return "ob_"
}
func selectColumnDefinitions(columns []columnDefinition, names ...string) []columnDefinition {
byName := make(map[string]columnDefinition, len(columns))
for _, column := range columns {
byName[column.name] = column
}
selected := make([]columnDefinition, 0, len(names))
for _, name := range names {
column, ok := byName[name]
if !ok {
panic("missing column definition: " + name)
}
selected = append(selected, column)
}
return selected
}
func quoteIdentifier(identifier string) string { return "`" + identifier + "`" }
func validateIdentifier(identifier string) error {
if identifier == "" || !identifierPattern.MatchString(identifier) {
return fmt.Errorf("invalid SQL identifier: %q", identifier)
}
return nil
}
func isDuplicateDDLError(err error) bool {
message := strings.ToLower(err.Error())
return strings.Contains(message, "duplicate") || strings.Contains(message, "already exists")
}