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>
345 lines
13 KiB
Go
345 lines
13 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 (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
|
|
"ragflow/internal/engine/types"
|
|
)
|
|
|
|
func TestPythonChunkSchemaContract(t *testing.T) {
|
|
expected := []string{
|
|
"id", "kb_id", "doc_id", "docnm_kwd", "doc_type_kwd", "title_tks", "title_sm_tks",
|
|
"content_with_weight", "content_ltks", "content_sm_ltks", "pagerank_fea", "important_kwd",
|
|
"important_tks", "question_kwd", "question_tks", "tag_kwd", "tag_feas", "available_int",
|
|
"create_time", "create_timestamp_flt", "img_id", "position_int", "page_num_int", "top_int",
|
|
"knowledge_graph_kwd", "source_id", "entity_kwd", "entity_type_kwd", "from_entity_kwd",
|
|
"to_entity_kwd", "weight_int", "weight_flt", "entities_kwd", "rank_flt", "n_hop_with_weight",
|
|
"removed_kwd", "raptor_kwd", "raptor_layer_int", "chunk_data", "metadata", "extra", "_order_id",
|
|
"group_id", "mom_id",
|
|
}
|
|
if got := columnNames(chunkColumns); !reflect.DeepEqual(got, expected) {
|
|
t.Fatalf("Go chunk schema columns changed:\n got: %v\nwant: %v", got, expected)
|
|
}
|
|
|
|
python := readRepoFile(t, "rag", "utils", "ob_conn.py")
|
|
for _, column := range expected {
|
|
if !strings.Contains(python, "Column(\""+column+"\"") {
|
|
t.Errorf("Python chunk schema no longer declares %q", column)
|
|
}
|
|
}
|
|
for _, snippet := range []string{
|
|
`Column("important_kwd", ARRAY(String(256))`,
|
|
`Column("question_kwd", ARRAY(String(1024))`,
|
|
`Column("position_int", ARRAY(ARRAY(Integer))`,
|
|
`Column("metadata", JSON`,
|
|
`Column("extra", JSON`,
|
|
`server_default="1"`,
|
|
`server_default="'N'"`,
|
|
} {
|
|
if !strings.Contains(python, snippet) {
|
|
t.Errorf("Python chunk schema contract is missing %q", snippet)
|
|
}
|
|
}
|
|
|
|
wantTypes := map[string]string{
|
|
"important_kwd": "ARRAY(VARCHAR(256)) NULL",
|
|
"question_kwd": "ARRAY(VARCHAR(1024)) NULL",
|
|
"position_int": "ARRAY(ARRAY(INTEGER)) NULL",
|
|
"metadata": "JSON NULL",
|
|
"extra": "JSON NULL",
|
|
"available_int": "INTEGER NOT NULL DEFAULT 1",
|
|
"removed_kwd": "VARCHAR(256) NULL DEFAULT 'N'",
|
|
}
|
|
for name, want := range wantTypes {
|
|
if got := columnType(chunkColumns, name); got != want {
|
|
t.Errorf("column %s type = %q, want %q", name, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPythonMemoryAndMetadataSchemaContract(t *testing.T) {
|
|
memoryExpected := []string{
|
|
"id", "message_id", "message_type_kwd", "source_id", "memory_id", "user_id", "agent_id",
|
|
"session_id", "zone_id", "valid_at", "invalid_at", "forget_at", "status_int", "content_ltks",
|
|
"tokenized_content_ltks",
|
|
}
|
|
if got := columnNames(memoryColumns); !reflect.DeepEqual(got, memoryExpected) {
|
|
t.Fatalf("Go memory schema columns changed:\n got: %v\nwant: %v", got, memoryExpected)
|
|
}
|
|
memoryPython := readRepoFile(t, "memory", "utils", "ob_conn.py")
|
|
for _, column := range memoryExpected {
|
|
if !strings.Contains(memoryPython, "Column(\""+column+"\"") {
|
|
t.Errorf("Python memory schema no longer declares %q", column)
|
|
}
|
|
}
|
|
|
|
metadataExpected := []string{"id", "kb_id", "meta_fields"}
|
|
if got := columnNames(metadataColumns); !reflect.DeepEqual(got, metadataExpected) {
|
|
t.Fatalf("Go metadata schema columns = %v, want %v", got, metadataExpected)
|
|
}
|
|
basePython := readRepoFile(t, "common", "doc_store", "ob_conn_base.py")
|
|
for _, column := range metadataExpected {
|
|
if !strings.Contains(basePython, "Column(\""+column+"\"") {
|
|
t.Errorf("Python metadata schema no longer declares %q", column)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPythonPhysicalTableNameContract(t *testing.T) {
|
|
if got := metadataTableName("tenant-1"); got != "ragflow_doc_meta_tenant-1" {
|
|
t.Fatalf("metadata table = %q", got)
|
|
}
|
|
if tableKind("ragflow_tenant-1") != "chunk" || tableKind("memory_tenant-1") != "memory" || tableKind("custom_table", "skill") != "skill" {
|
|
t.Fatal("physical table kinds are not recognized")
|
|
}
|
|
|
|
contracts := map[string][]string{
|
|
filepath.Join("rag", "nlp", "search.py"): {`return f"ragflow_{uid}"`},
|
|
filepath.Join("memory", "services", "messages.py"): {`f"memory_{uid}"`},
|
|
filepath.Join("api", "db", "services", "doc_metadata_service.py"): {`f"ragflow_doc_meta_{tenant_id}"`},
|
|
}
|
|
for path, snippets := range contracts {
|
|
content := readRepoFile(t, strings.Split(path, string(filepath.Separator))...)
|
|
for _, snippet := range snippets {
|
|
if !strings.Contains(content, snippet) {
|
|
t.Errorf("%s no longer contains table-name contract %q", path, snippet)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLegacyChunkEncodingContract(t *testing.T) {
|
|
document := map[string]interface{}{
|
|
"id": "chunk-1", "kb_id": []string{"kb-1"}, "doc_id": "doc-1",
|
|
"available_int": nil, "removed_kwd": nil, "chunk_order_int": 7,
|
|
"metadata": map[string]interface{}{"_group_id": "group-1", "_title": "renamed"},
|
|
"important_kwd": []string{" alpha\t", "beta\n"},
|
|
"q_3_vec": []float64{0.1, 0.2, 0.3},
|
|
"custom_field": "preserved",
|
|
}
|
|
got, err := normalizeChunk(document)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got["kb_id"] != "kb-1" || got["available_int"] != 1 || got["removed_kwd"] != "N" || got["_order_id"] != 7 {
|
|
t.Fatalf("legacy scalar/default encoding changed: %#v", got)
|
|
}
|
|
if got["group_id"] != "group-1" || got["docnm_kwd"] != "renamed" {
|
|
t.Fatalf("metadata denormalization changed: %#v", got)
|
|
}
|
|
if got["q_3_vec"] != "[0.1,0.2,0.3]" {
|
|
t.Fatalf("vector encoding = %q", got["q_3_vec"])
|
|
}
|
|
var extra map[string]interface{}
|
|
if err := json.Unmarshal([]byte(got["extra"].(string)), &extra); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if extra["custom_field"] != "preserved" {
|
|
t.Fatalf("unknown field was not preserved in extra: %#v", extra)
|
|
}
|
|
}
|
|
|
|
func TestLegacyMemoryAliasesAndVectorEncoding(t *testing.T) {
|
|
got, err := normalizeMemory(map[string]interface{}{
|
|
"id": "memory-1_1", "message_id": "1", "memory_id": "memory-1",
|
|
"message_type": "raw", "status": false, "content": "hello world",
|
|
"content_embed": []float64{0.25, 0.5},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got["message_type_kwd"] != "raw" || got["status_int"] != 0 || got["content_ltks"] != "hello world" {
|
|
t.Fatalf("memory aliases changed: %#v", got)
|
|
}
|
|
if got["q_2_vec"] != "[0.25,0.5]" {
|
|
t.Fatalf("memory vector encoding = %q", got["q_2_vec"])
|
|
}
|
|
decoded := decodeLogicalRow(map[string]interface{}{
|
|
"message_type_kwd": "raw", "status_int": int64(0), "content_ltks": "hello",
|
|
"q_2_vec": "[0.25,0.5]",
|
|
}, "memory")
|
|
if decoded["message_type"] != "raw" || decoded["status"] != false || !reflect.DeepEqual(decoded["content_embed"], []interface{}{0.25, 0.5}) {
|
|
t.Fatalf("memory read aliases changed: %#v", decoded)
|
|
}
|
|
}
|
|
|
|
func TestSkillRowsKeepStringStatusAndSkillRowID(t *testing.T) {
|
|
decoded := decodeLogicalRow(map[string]interface{}{
|
|
"skill_id": "skill-1",
|
|
"status": "draft",
|
|
}, "skill")
|
|
if decoded["status"] != "draft" {
|
|
t.Fatalf("skill status = %#v, want draft", decoded["status"])
|
|
}
|
|
|
|
expression, alias, err := selectExpression("row_id()", "skill")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if expression != "`skill_id` AS `row_id`" || alias != "row_id" {
|
|
t.Fatalf("skill row ID projection = (%q, %q)", expression, alias)
|
|
}
|
|
}
|
|
|
|
func TestDBMSHybridBodyMatchesPythonSemantics(t *testing.T) {
|
|
plan := searchPlan{
|
|
text: &types.MatchTextExpr{MatchingText: "hello", TopN: 10, ExtraOptions: map[string]interface{}{"minimum_should_match": 0.3}},
|
|
dense: &types.MatchDenseExpr{VectorColumnName: "q_2_vec", EmbeddingData: []float64{0.1, 0.2}, EmbeddingDataType: "float", TopN: 8, ExtraOptions: map[string]interface{}{"similarity": 0.42}},
|
|
fusion: &types.FusionExpr{Method: "weighted_sum", FusionParams: map[string]interface{}{"weights": "0.25,0.75"}},
|
|
}
|
|
body, ok := buildDBMSBody("chunk", map[string]interface{}{"kb_id": []string{"kb-1"}, "available_int": 0}, &types.SearchRequest{
|
|
Offset: 2, Limit: 5, RankFeature: map[string]float64{"pagerank_fea": 0.1},
|
|
}, plan)
|
|
if !ok {
|
|
t.Fatal("hybrid body unexpectedly required SQL fallback")
|
|
}
|
|
root, ok := body["query"].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("hybrid body query leg = %#v", body["query"])
|
|
}
|
|
query, ok := root["bool"].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("hybrid body bool leg = %#v", root)
|
|
}
|
|
mustClauses, ok := query["must"].([]interface{})
|
|
if !ok || len(mustClauses) == 0 {
|
|
t.Fatalf("hybrid body must leg = %#v", query["must"])
|
|
}
|
|
firstClause, ok := mustClauses[0].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("hybrid body must clause = %#v", mustClauses[0])
|
|
}
|
|
must, ok := firstClause["query_string"].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("hybrid body query_string leg = %#v", firstClause)
|
|
}
|
|
if must["minimum_should_match"] != "30%" || query["boost"] != 0.25 {
|
|
t.Fatalf("hybrid text leg = %#v", query)
|
|
}
|
|
knn, ok := body["knn"].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("hybrid knn leg = %#v", body["knn"])
|
|
}
|
|
if knn["k"] != 8 || knn["num_candidates"] != 16 || knn["similarity"] != 0.42 {
|
|
t.Fatalf("hybrid vector leg = %#v", knn)
|
|
}
|
|
}
|
|
|
|
func TestMetadataJSONPushdownOperators(t *testing.T) {
|
|
predicate, args, err := buildMetaPushdownPredicate([]map[string]interface{}{
|
|
{"key": "author", "op": "contains", "value": "Alice"},
|
|
{"key": "year", "op": "≥", "value": "2024"},
|
|
{"key": "tags", "op": "in", "value": "rag, database"},
|
|
}, "and")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, fragment := range []string{"JSON_UNQUOTE", "DECIMAL(65,20)", "JSON_CONTAINS", " AND "} {
|
|
if !strings.Contains(predicate, fragment) {
|
|
t.Errorf("metadata predicate %q is missing %q", predicate, fragment)
|
|
}
|
|
}
|
|
wantArgs := []interface{}{"$.author", "Alice", "$.year", int64(2024), "$.tags", `"rag"`, "$.tags", `"database"`}
|
|
if !reflect.DeepEqual(args, wantArgs) {
|
|
t.Fatalf("metadata args = %#v, want %#v", args, wantArgs)
|
|
}
|
|
if _, _, err := buildMetaPushdownPredicate([]map[string]interface{}{{"key": "bad-key", "op": "=", "value": "x"}}, "and"); err == nil {
|
|
t.Fatal("invalid JSON metadata key must reject push-down")
|
|
}
|
|
}
|
|
|
|
func TestMetadataJSONPushdownRejectsUnsafeNegativeOperators(t *testing.T) {
|
|
for _, operator := range []string{"≠", "not in"} {
|
|
_, _, err := buildMetaPushdownPredicate([]map[string]interface{}{
|
|
{"key": "tags", "op": operator, "value": []string{"a"}},
|
|
}, "and")
|
|
if err == nil {
|
|
t.Errorf("operator %q must reject metadata push-down", operator)
|
|
}
|
|
}
|
|
|
|
for _, operator := range []string{"=", "in"} {
|
|
if _, _, err := buildMetaPushdownPredicate([]map[string]interface{}{
|
|
{"key": "tags", "op": operator, "value": []string{"a"}},
|
|
}, "and"); err != nil {
|
|
t.Errorf("operator %q unexpectedly rejected metadata push-down: %v", operator, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHybridFallbackOnlyForUnavailablePackage(t *testing.T) {
|
|
if !isHybridUnavailableError(assertError("ERROR 1305: FUNCTION DBMS_HYBRID_SEARCH.SEARCH does not exist")) {
|
|
t.Fatal("missing DBMS package must trigger SQL fallback")
|
|
}
|
|
if isHybridUnavailableError(assertError("DBMS_HYBRID_SEARCH.SEARCH syntax error in query_string")) {
|
|
t.Fatal("query errors must be returned instead of silently falling back")
|
|
}
|
|
if isHybridUnavailableError(assertError("feature not supported")) {
|
|
t.Fatal("unrelated unsupported errors must not trigger SQL fallback")
|
|
}
|
|
}
|
|
|
|
func TestCompareVersions(t *testing.T) {
|
|
if compareVersions("OceanBase_CE 4.3.5.1", "4.3.5.1") != 0 || compareVersions("4.4.1.0", "4.3.5.1") <= 0 || compareVersions("4.3.4.0", "4.3.5.1") >= 0 {
|
|
t.Fatal("OceanBase version comparison changed")
|
|
}
|
|
}
|
|
|
|
type assertError string
|
|
|
|
func (e assertError) Error() string { return string(e) }
|
|
|
|
func columnNames(columns []columnDefinition) []string {
|
|
result := make([]string, len(columns))
|
|
for i, column := range columns {
|
|
result[i] = column.name
|
|
}
|
|
return result
|
|
}
|
|
|
|
func columnType(columns []columnDefinition, name string) string {
|
|
for _, column := range columns {
|
|
if column.name == name {
|
|
return column.typeSQL
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func readRepoFile(t *testing.T, parts ...string) string {
|
|
t.Helper()
|
|
_, currentFile, _, ok := runtime.Caller(0)
|
|
if !ok {
|
|
t.Fatal("cannot locate compatibility test source")
|
|
}
|
|
repoRoot := filepath.Clean(filepath.Join(filepath.Dir(currentFile), "..", "..", ".."))
|
|
path := filepath.Join(append([]string{repoRoot}, parts...)...)
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", path, err)
|
|
}
|
|
return string(content)
|
|
}
|