mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-11 01:41:26 +08:00
## 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>
230 lines
8.7 KiB
Go
230 lines
8.7 KiB
Go
//go:build integration
|
|
|
|
//
|
|
// 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"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"reflect"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"ragflow/internal/engine/types"
|
|
"ragflow/internal/server/config"
|
|
)
|
|
|
|
func TestLegacyStorageRoundTrip(t *testing.T) {
|
|
host := os.Getenv("RAGFLOW_TEST_OCEANBASE_HOST")
|
|
if host == "" {
|
|
t.Skip("RAGFLOW_TEST_OCEANBASE_HOST is not set")
|
|
}
|
|
port, err := strconv.Atoi(envOr("RAGFLOW_TEST_OCEANBASE_PORT", "2881"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
engine, err := NewEngine(envOr("RAGFLOW_TEST_OCEANBASE_ENGINE_TYPE", "oceanbase"), config.OceanBaseConnectionConfig{
|
|
DBName: envOr("RAGFLOW_TEST_OCEANBASE_DBNAME", "test"),
|
|
User: envOr("RAGFLOW_TEST_OCEANBASE_USER", "root@test"), Password: os.Getenv("RAGFLOW_TEST_OCEANBASE_PASSWORD"),
|
|
Host: host, Port: port, MaxConnections: 4,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer engine.Close()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
defer cancel()
|
|
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
|
|
tableName := "ragflow_go_compat_" + suffix
|
|
datasetID := "kb-" + suffix
|
|
defer cleanupChunkStore(t, engine, tableName, "")
|
|
|
|
if err := engine.CreateChunkStore(ctx, tableName, datasetID, 2, "naive"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := engine.InsertChunks(ctx, []map[string]interface{}{{
|
|
"id": "chunk-1", "kb_id": datasetID, "doc_id": "doc-1", "content_with_weight": "hello oceanbase",
|
|
"content_ltks": "hello oceanbase", "important_kwd": []string{"hello"},
|
|
"metadata": map[string]interface{}{"_group_id": "group-1", "custom": "json-value"},
|
|
"custom_field": "kept-in-extra", "q_2_vec": []float64{0.25, 0.5},
|
|
}}, tableName, datasetID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
row, err := engine.GetChunk(ctx, tableName, "chunk-1", []string{datasetID})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
chunk := row.(map[string]interface{})
|
|
if chunk["group_id"] != "group-1" {
|
|
t.Fatalf("legacy metadata denormalization failed: %#v", chunk)
|
|
}
|
|
|
|
result, err := engine.Search(ctx, &types.SearchRequest{
|
|
IndexNames: []string{tableName}, KbIDs: []string{datasetID}, Limit: 10,
|
|
SelectFields: []string{"id", "metadata", "extra", "q_2_vec"},
|
|
MatchExprs: []interface{}{&types.MatchDenseExpr{
|
|
VectorColumnName: "q_2_vec", EmbeddingData: []float64{0.25, 0.5},
|
|
EmbeddingDataType: "float", TopN: 10, ExtraOptions: map[string]interface{}{"similarity": 0.1},
|
|
}},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(result.Chunks) != 1 || result.Chunks[0]["id"] != "chunk-1" {
|
|
t.Fatalf("vector round trip returned %#v", result)
|
|
}
|
|
|
|
pythonChunkID := "python-chunk-1"
|
|
_, err = engine.db.ExecContext(ctx, fmt.Sprintf(
|
|
"REPLACE INTO %s (id, kb_id, doc_id, content_with_weight, content_ltks, important_kwd, metadata, extra, q_2_vec) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
quoteIdentifier(tableName),
|
|
), pythonChunkID, datasetID, "python-doc-1", "python formatted chunk", "python formatted chunk",
|
|
`["python","legacy"]`, `{"_group_id":"python-group","custom":"python-json"}`,
|
|
`{"python_extra":"preserved"}`, `[0.75,0.25]`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pythonRow, err := engine.GetChunk(ctx, tableName, pythonChunkID, []string{datasetID})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pythonChunk := pythonRow.(map[string]interface{})
|
|
if !reflect.DeepEqual(pythonChunk["important_kwd"], []interface{}{"python", "legacy"}) {
|
|
t.Fatalf("Python ARRAY decoding failed: %#v", pythonChunk["important_kwd"])
|
|
}
|
|
if metadata, ok := pythonChunk["metadata"].(map[string]interface{}); !ok || metadata["custom"] != "python-json" {
|
|
t.Fatalf("Python JSON decoding failed: %#v", pythonChunk["metadata"])
|
|
}
|
|
if vector, ok := floatSlice(pythonChunk["q_2_vec"]); !ok || !reflect.DeepEqual(vector, []float64{0.75, 0.25}) {
|
|
t.Fatalf("Python VECTOR decoding failed: %#v", pythonChunk["q_2_vec"])
|
|
}
|
|
|
|
assertStoredJSON(ctx, t, engine, tableName, "important_kwd", "chunk-1", []interface{}{"hello"})
|
|
assertStoredJSON(ctx, t, engine, tableName, "metadata", "chunk-1", map[string]interface{}{"_group_id": "group-1", "custom": "json-value"})
|
|
assertStoredJSON(ctx, t, engine, tableName, "extra", "chunk-1", map[string]interface{}{"custom_field": "kept-in-extra"})
|
|
assertStoredJSON(ctx, t, engine, tableName, "q_2_vec", "chunk-1", []interface{}{0.25, 0.5})
|
|
|
|
memoryTable := "memory_go_compat_" + suffix
|
|
memoryA := "memory-a-" + suffix
|
|
memoryB := "memory-b-" + suffix
|
|
defer cleanupChunkStore(t, engine, memoryTable, "")
|
|
if err := engine.CreateChunkStore(ctx, memoryTable, memoryA, 2, ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := engine.InsertChunks(ctx, []map[string]interface{}{
|
|
{"id": memoryA + "_1", "message_id": "1", "memory_id": memoryA, "content": "first", "content_embed": []float64{0.1, 0.2}},
|
|
{"id": memoryB + "_1", "message_id": "1", "memory_id": memoryB, "content": "second", "content_embed": []float64{0.3, 0.4}},
|
|
}, memoryTable, memoryA); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := engine.DropChunkStore(ctx, memoryTable, memoryA); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := engine.GetChunk(ctx, memoryTable, memoryA+"_1", []string{memoryA}); err == nil {
|
|
t.Fatal("deleted memory rows are still readable")
|
|
}
|
|
if _, err := engine.GetChunk(ctx, memoryTable, memoryB+"_1", []string{memoryB}); err != nil {
|
|
t.Fatalf("another memory's rows were removed: %v", err)
|
|
}
|
|
|
|
tenantID := "go_compat_" + suffix
|
|
metadataTable := metadataTableName(tenantID)
|
|
defer cleanupMetadataStore(t, engine, tenantID)
|
|
if err := engine.CreateMetadataStore(ctx, tenantID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := engine.db.ExecContext(ctx,
|
|
"REPLACE INTO "+quoteIdentifier(metadataTable)+" (id, kb_id, meta_fields) VALUES (?, ?, ?)",
|
|
"python-meta-1", datasetID, `{"tags":["a","b"],"source":"python"}`,
|
|
); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
metadataResult, err := engine.SearchMetadata(ctx, &types.SearchMetadataRequest{
|
|
TenantID: tenantID, Limit: 10, SelectFields: []string{"id", "kb_id", "meta_fields"},
|
|
Filter: map[string]interface{}{"id": "python-meta-1"},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(metadataResult.MetadataRecords) != 1 {
|
|
t.Fatalf("Python metadata row returned %#v", metadataResult.MetadataRecords)
|
|
}
|
|
metaFields, ok := metadataResult.MetadataRecords[0]["meta_fields"].(map[string]interface{})
|
|
if !ok || metaFields["source"] != "python" {
|
|
t.Fatalf("Python metadata JSON decoding failed: %#v", metadataResult.MetadataRecords[0])
|
|
}
|
|
if err := engine.UpdateMetadata(ctx, "go-meta-1", datasetID, map[string]interface{}{"source": "go", "tags": []string{"c"}}, tenantID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertStoredJSON(ctx, t, engine, metadataTable, "meta_fields", "go-meta-1", map[string]interface{}{"source": "go", "tags": []interface{}{"c"}})
|
|
}
|
|
|
|
func assertStoredJSON(ctx context.Context, t *testing.T, engine *Engine, tableName, columnName, rowID string, want interface{}) {
|
|
t.Helper()
|
|
var raw interface{}
|
|
query := fmt.Sprintf("SELECT %s FROM %s WHERE id = ?", quoteIdentifier(columnName), quoteIdentifier(tableName))
|
|
if err := engine.db.QueryRowContext(ctx, query, rowID).Scan(&raw); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var text string
|
|
switch value := raw.(type) {
|
|
case []byte:
|
|
text = string(value)
|
|
case string:
|
|
text = value
|
|
default:
|
|
t.Fatalf("stored %s value has type %T", columnName, raw)
|
|
}
|
|
var got interface{}
|
|
if err := json.Unmarshal([]byte(text), &got); err != nil {
|
|
t.Fatalf("stored %s value %q is not JSON compatible: %v", columnName, text, err)
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("stored %s value = %#v, want %#v", columnName, got, want)
|
|
}
|
|
}
|
|
|
|
func envOr(name, fallback string) string {
|
|
if value := os.Getenv(name); value != "" {
|
|
return value
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func cleanupChunkStore(t *testing.T, engine *Engine, tableName, datasetID string) {
|
|
t.Helper()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
if err := engine.DropChunkStore(ctx, tableName, datasetID); err != nil {
|
|
t.Errorf("clean up chunk store %s: %v", tableName, err)
|
|
}
|
|
}
|
|
|
|
func cleanupMetadataStore(t *testing.T, engine *Engine, tenantID string) {
|
|
t.Helper()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
if err := engine.DropMetadataStore(ctx, tenantID); err != nil {
|
|
t.Errorf("clean up metadata store for tenant %s: %v", tenantID, err)
|
|
}
|
|
}
|