mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-04 23:00:30 +08:00
## What Adds [**SereneDB**](https://serenedb.com) as a selectable doc-store engine on **both** RAGFlow paths: - the **Go** `DocEngine` (`internal/engine/serenedb`), alongside Elasticsearch and Infinity; - the **Python** `DocStoreConnection` (`rag/utils/serenedb_conn.py`) + `DOC_ENGINE=serenedb` registration. SereneDB is a PostgreSQL-wire engine (DuckDB execution) whose single inverted index carries **both** a scored text column (`@@`, BM25) and an IVF vector column (`<#>`, inner product), so hybrid search is one SQL statement. The Go engine connects with `database/sql` + `lib/pq` (already a dependency, no new module); the Python connector uses psycopg2 (already a dependency). ## Storage model One table per tenant with `kb_id` as a filter column - the **Elasticsearch / OceanBase** model, not Infinity's per-dataset tables. This keeps BM25 statistics (IDF, avgdl) computed over the whole tenant corpus (global IDF). Both connectors use this identical layout, so they are storage- and retrieval-compatible: `hybrid` proxy routing and Python↔Go switching are safe. On the Python side the connector is wired as OceanBase's plain-SQL sibling (chunk_data JSON metadata, inline chunk vectors, verbatim ES field names); the ES tokenizer path is unchanged. Metadata stays one table per tenant (`ragflow_doc_meta_<tenant>`). The query shapes mirror the Python connector, including the five empirically-found landmines: the scored dictionary needs `frequency + norm` (else `BM25()` silently returns 0.0), the `@@` query is the tokenized query, the scored lexical branch matches one column, vectors use an L2-normalized shadow column with `ip`/`sq8`, and the similarity threshold goes directly in the ANN scan's `WHERE`. **Minimum engine version: SereneDB 26.07.4.** --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
273 lines
8.7 KiB
Go
273 lines
8.7 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 serenedb
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"ragflow/internal/engine/types"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// metadataTableDDL creates the per-tenant metadata table and its lookup
|
|
// indexes. Pure for tests.
|
|
func metadataTableDDL(tableName string) []string {
|
|
cols := make([]string, 0, len(docMetaColumnOrder))
|
|
for _, c := range docMetaColumnOrder {
|
|
cols = append(cols, fmt.Sprintf("%s %s", c, docMetaDDL[c]))
|
|
}
|
|
return []string{
|
|
fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s (%s)", tableName, strings.Join(cols, ", ")),
|
|
fmt.Sprintf("CREATE INDEX IF NOT EXISTS idx_%s_kb_id ON %s (kb_id)", tableName, tableName),
|
|
}
|
|
}
|
|
|
|
// CreateMetadataStore creates the tenant's document metadata table.
|
|
func (e *serenedbEngine) CreateMetadataStore(ctx context.Context, tenantID string) error {
|
|
tableName := buildMetadataTableName(tenantID)
|
|
for _, stmt := range metadataTableDDL(tableName) {
|
|
if err := e.exec(ctx, stmt); err != nil {
|
|
return fmt.Errorf("serenedb: create metadata store %s: %w", tableName, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DropMetadataStore drops the tenant metadata table.
|
|
func (e *serenedbEngine) DropMetadataStore(ctx context.Context, tenantID string) error {
|
|
return e.exec(ctx, fmt.Sprintf("DROP TABLE IF EXISTS %s", buildMetadataTableName(tenantID)))
|
|
}
|
|
|
|
// MetadataStoreExists reports whether the tenant metadata table exists.
|
|
func (e *serenedbEngine) MetadataStoreExists(ctx context.Context, tenantID string) (bool, error) {
|
|
return e.tableExists(ctx, buildMetadataTableName(tenantID))
|
|
}
|
|
|
|
func metaFieldsJSON(v interface{}) string {
|
|
if s, ok := v.(string); ok {
|
|
return s
|
|
}
|
|
if v == nil {
|
|
return "{}"
|
|
}
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return "{}"
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// InsertMetadata upserts metadata records by id. meta_fields is stored as a
|
|
// JSON string.
|
|
func (e *serenedbEngine) InsertMetadata(ctx context.Context, metadata []map[string]interface{}, tenantID string) ([]string, error) {
|
|
if len(metadata) == 0 {
|
|
return []string{}, nil
|
|
}
|
|
tableName := buildMetadataTableName(tenantID)
|
|
exists, err := e.tableExists(ctx, tableName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !exists {
|
|
if err := e.CreateMetadataStore(ctx, tenantID); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
query := fmt.Sprintf("INSERT INTO %s (id, kb_id, meta_fields) VALUES ($1, $2, $3) "+
|
|
"ON CONFLICT (id) DO UPDATE SET kb_id = EXCLUDED.kb_id, meta_fields = EXCLUDED.meta_fields", tableName)
|
|
for _, rec := range metadata {
|
|
if err := e.exec(ctx, query, rec["id"], rec["kb_id"], metaFieldsJSON(rec["meta_fields"])); err != nil {
|
|
return nil, fmt.Errorf("serenedb: insert metadata into %s: %w", tableName, err)
|
|
}
|
|
}
|
|
return []string{}, nil
|
|
}
|
|
|
|
// UpdateMetadata merges metaFields into the stored record, preserving keys the
|
|
// caller did not send. Missing rows are inserted.
|
|
func (e *serenedbEngine) UpdateMetadata(ctx context.Context, docID, datasetID string, metaFields map[string]interface{}, tenantID string) error {
|
|
tableName := buildMetadataTableName(tenantID)
|
|
exists, err := e.tableExists(ctx, tableName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !exists {
|
|
if err := e.CreateMetadataStore(ctx, tenantID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
existing, err := e.loadMetaFields(ctx, tableName, docID, datasetID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if existing == nil {
|
|
return e.exec(ctx,
|
|
fmt.Sprintf("INSERT INTO %s (id, kb_id, meta_fields) VALUES ($1, $2, $3)", tableName),
|
|
docID, datasetID, metaFieldsJSON(metaFields))
|
|
}
|
|
for k, v := range metaFields {
|
|
existing[k] = v
|
|
}
|
|
return e.exec(ctx,
|
|
fmt.Sprintf("UPDATE %s SET meta_fields = $1 WHERE id = $2 AND kb_id = $3", tableName),
|
|
metaFieldsJSON(existing), docID, datasetID)
|
|
}
|
|
|
|
// DeleteMetadataKeys removes specific keys from a record's meta_fields, dropping
|
|
// the whole row if none remain.
|
|
func (e *serenedbEngine) DeleteMetadataKeys(ctx context.Context, docID, datasetID string, keys []string, tenantID string) error {
|
|
tableName := buildMetadataTableName(tenantID)
|
|
existing, err := e.loadMetaFields(ctx, tableName, docID, datasetID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if existing == nil {
|
|
return fmt.Errorf("serenedb: metadata document not found: %s", docID)
|
|
}
|
|
changed := false
|
|
for _, k := range keys {
|
|
if _, ok := existing[k]; ok {
|
|
delete(existing, k)
|
|
changed = true
|
|
}
|
|
}
|
|
if !changed {
|
|
return nil
|
|
}
|
|
if len(existing) == 0 {
|
|
return e.exec(ctx,
|
|
fmt.Sprintf("DELETE FROM %s WHERE id = $1 AND kb_id = $2", tableName), docID, datasetID)
|
|
}
|
|
return e.exec(ctx,
|
|
fmt.Sprintf("UPDATE %s SET meta_fields = $1 WHERE id = $2 AND kb_id = $3", tableName),
|
|
metaFieldsJSON(existing), docID, datasetID)
|
|
}
|
|
|
|
// DeleteMetadata removes records matching condition. A missing table is not an
|
|
// error.
|
|
func (e *serenedbEngine) DeleteMetadata(ctx context.Context, condition map[string]interface{}, tenantID string) (int64, error) {
|
|
tableName := buildMetadataTableName(tenantID)
|
|
exists, err := e.tableExists(ctx, tableName)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if !exists {
|
|
return 0, nil
|
|
}
|
|
if unknown := unrecognizedFilterKeys(condition); len(unknown) > 0 {
|
|
return 0, fmt.Errorf("serenedb: refusing to delete metadata from %s with unrecognized filter keys %v", tableName, unknown)
|
|
}
|
|
filters := buildFilters(condition)
|
|
if len(filters) == 0 {
|
|
return 0, nil
|
|
}
|
|
res, err := e.db.ExecContext(ctx,
|
|
fmt.Sprintf("DELETE FROM %s WHERE %s", tableName, strings.Join(filters, " AND ")))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
return n, nil
|
|
}
|
|
|
|
// SearchMetadata returns metadata records matching the request. A missing table
|
|
// yields a non-nil empty result so callers do not fall back to in-memory scans.
|
|
func (e *serenedbEngine) SearchMetadata(ctx context.Context, req *types.SearchMetadataRequest) (*types.SearchMetadataResult, error) {
|
|
if req.TenantID == "" {
|
|
return nil, fmt.Errorf("serenedb: SearchMetadata requires a tenant id")
|
|
}
|
|
tableName := buildMetadataTableName(req.TenantID)
|
|
empty := &types.SearchMetadataResult{MetadataRecords: []map[string]interface{}{}, Total: 0}
|
|
exists, err := e.tableExists(ctx, tableName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !exists {
|
|
return empty, nil
|
|
}
|
|
// SelectFields is interpolated into the projection, so keep only real
|
|
// metadata columns.
|
|
fields := "*"
|
|
if len(req.SelectFields) > 0 {
|
|
valid := make([]string, 0, len(req.SelectFields))
|
|
for _, f := range req.SelectFields {
|
|
if _, ok := docMetaDDL[f]; ok {
|
|
valid = append(valid, f)
|
|
}
|
|
}
|
|
if len(valid) > 0 {
|
|
fields = strings.Join(valid, ", ")
|
|
}
|
|
}
|
|
filters := buildFilters(req.Filter)
|
|
where := filtersExpr(filters)
|
|
limit := req.Limit
|
|
if limit <= 0 {
|
|
limit = defaultPageSize
|
|
}
|
|
offset := req.Offset
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
query := buildFilterSQL(tableName, fields, where, req.OrderBy, limit, offset)
|
|
records, err := e.queryMaps(ctx, query)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("serenedb: search metadata %s: %w", tableName, err)
|
|
}
|
|
total, err := e.countRows(ctx, tableName, where)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if records == nil {
|
|
records = []map[string]interface{}{}
|
|
}
|
|
return &types.SearchMetadataResult{MetadataRecords: records, Total: total}, nil
|
|
}
|
|
|
|
// FilterDocIdsByMetaPushdown returns nil, which tells the caller to filter
|
|
// document metadata in memory. Pushing metadata predicates into SQL is a
|
|
// deferred optimization for this engine; nil is the interface's defined
|
|
// fall-back path and is always correct.
|
|
func (e *serenedbEngine) FilterDocIdsByMetaPushdown(ctx context.Context, sqlDB *gorm.DB, kbIDs []string, conditions []map[string]interface{}, logic string) []string {
|
|
return nil
|
|
}
|
|
|
|
// loadMetaFields returns the parsed meta_fields map for a record, or nil when
|
|
// the record does not exist.
|
|
func (e *serenedbEngine) loadMetaFields(ctx context.Context, tableName, docID, datasetID string) (map[string]interface{}, error) {
|
|
rows, err := e.queryMaps(ctx,
|
|
fmt.Sprintf("SELECT meta_fields FROM %s WHERE id = $1 AND kb_id = $2", tableName), docID, datasetID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(rows) == 0 {
|
|
return nil, nil
|
|
}
|
|
switch mf := rows[0]["meta_fields"].(type) {
|
|
case map[string]interface{}:
|
|
return mf, nil
|
|
case nil:
|
|
return map[string]interface{}{}, nil
|
|
default:
|
|
return map[string]interface{}{}, nil
|
|
}
|
|
}
|