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>
477 lines
14 KiB
Go
477 lines
14 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"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"ragflow/internal/common"
|
|
|
|
"github.com/lib/pq"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
const defaultVectorSize = 1024
|
|
|
|
// chunkTableDDL returns the statements that create a chunk table, its scored
|
|
// dictionary, and the hybrid inverted index (text columns + normalized vector
|
|
// shadow). Pure so it can be asserted in tests.
|
|
func chunkTableDDL(tableName string, vectorSize int) []string {
|
|
if vectorSize <= 0 {
|
|
vectorSize = defaultVectorSize
|
|
}
|
|
cols := make([]string, 0, len(columnOrder)+2)
|
|
for _, c := range columnOrder {
|
|
cols = append(cols, fmt.Sprintf("%s %s", c, columnDDL[c]))
|
|
}
|
|
vec, vecN := rawVectorColumn(vectorSize), normColumn(vectorSize)
|
|
cols = append(cols,
|
|
fmt.Sprintf("%s FLOAT[%d]", vec, vectorSize),
|
|
fmt.Sprintf("%s FLOAT[%d]", vecN, vectorSize))
|
|
|
|
fts := make([]string, 0, len(ftsColumns))
|
|
for _, c := range ftsColumns {
|
|
fts = append(fts, fmt.Sprintf("%s %s", c, dictionaryName))
|
|
}
|
|
return []string{
|
|
fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s (%s)", tableName, strings.Join(cols, ", ")),
|
|
dictionaryDDL,
|
|
fmt.Sprintf("CREATE INDEX IF NOT EXISTS %s ON %s "+
|
|
"USING inverted (id, %s, %s ivf (metric = 'ip', quant = 'sq8')) "+
|
|
"WITH (optimize_top_k = 'bm25(1.2, 0.75)')",
|
|
indexRelation(tableName), tableName, strings.Join(fts, ", "), vecN),
|
|
}
|
|
}
|
|
|
|
// CreateChunkStore ensures the tenant chunk table and its indexes exist. All
|
|
// datasets in the tenant share this table, so it is created once and is a
|
|
// no-op for later datasets.
|
|
func (e *serenedbEngine) CreateChunkStore(ctx context.Context, baseName, datasetID string, vectorSize int, parserID string) error {
|
|
tableName := chunkTableName(baseName)
|
|
for _, stmt := range chunkTableDDL(tableName, vectorSize) {
|
|
if err := e.exec(ctx, stmt); err != nil {
|
|
return fmt.Errorf("serenedb: create chunk store %s: %w", tableName, err)
|
|
}
|
|
}
|
|
common.Info("SereneDB created chunk store", zap.String("table", tableName))
|
|
return nil
|
|
}
|
|
|
|
// DropChunkStore removes a dataset from the tenant table. Because the table is
|
|
// shared, a dataset drop deletes that dataset's rows; only a whole-tenant drop
|
|
// (empty datasetID) drops the table itself.
|
|
func (e *serenedbEngine) DropChunkStore(ctx context.Context, baseName, datasetID string) error {
|
|
tableName := chunkTableName(baseName)
|
|
exists, err := e.tableExists(ctx, tableName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !exists {
|
|
return nil
|
|
}
|
|
if datasetID != "" {
|
|
return e.exec(ctx, fmt.Sprintf("DELETE FROM %s WHERE kb_id = $1", tableName), datasetID)
|
|
}
|
|
if err := e.exec(ctx, fmt.Sprintf("DROP INDEX IF EXISTS %s", indexRelation(tableName))); err != nil {
|
|
return err
|
|
}
|
|
return e.exec(ctx, fmt.Sprintf("DROP TABLE IF EXISTS %s", tableName))
|
|
}
|
|
|
|
// ChunkStoreExists reports whether the tenant table exists.
|
|
func (e *serenedbEngine) ChunkStoreExists(ctx context.Context, baseName, datasetID string) (bool, error) {
|
|
return e.tableExists(ctx, chunkTableName(baseName))
|
|
}
|
|
|
|
// toFloatSlice coerces a chunk vector value to []float64.
|
|
func toFloatSlice(v interface{}) ([]float64, bool) {
|
|
switch val := v.(type) {
|
|
case []float64:
|
|
return val, true
|
|
case []float32:
|
|
out := make([]float64, len(val))
|
|
for i, f := range val {
|
|
out[i] = float64(f)
|
|
}
|
|
return out, true
|
|
case []interface{}:
|
|
out := make([]float64, 0, len(val))
|
|
for _, x := range val {
|
|
switch n := x.(type) {
|
|
case float64:
|
|
out = append(out, n)
|
|
case float32:
|
|
out = append(out, float64(n))
|
|
case int:
|
|
out = append(out, float64(n))
|
|
case json.Number:
|
|
f, _ := n.Float64()
|
|
out = append(out, f)
|
|
default:
|
|
return nil, false
|
|
}
|
|
}
|
|
return out, true
|
|
default:
|
|
return nil, false
|
|
}
|
|
}
|
|
|
|
// prepareChunkRow flattens a chunk into ordered (columns, values) for insert.
|
|
// Vector columns get their L2-normalized shadow; unknown fields collapse into
|
|
// the `extra` JSON column; JSON columns are serialized. ES field names are
|
|
// preserved verbatim.
|
|
func prepareChunkRow(chunk map[string]interface{}, defaultKbID string) ([]string, []interface{}) {
|
|
d := map[string]interface{}{}
|
|
extra := map[string]interface{}{}
|
|
vecCols := map[string][]float64{}
|
|
|
|
for k, v := range chunk {
|
|
if m := vectorColumnPattern.FindStringSubmatch(k); m != nil {
|
|
if vec, ok := toFloatSlice(v); ok {
|
|
vecCols[k] = vec
|
|
}
|
|
continue
|
|
}
|
|
if _, known := columnDDL[k]; !known {
|
|
extra[k] = v
|
|
continue
|
|
}
|
|
if k == "kb_id" {
|
|
if list, ok := v.([]interface{}); ok && len(list) > 0 {
|
|
v = list[0]
|
|
} else if list, ok := v.([]string); ok && len(list) > 0 {
|
|
v = list[0]
|
|
}
|
|
}
|
|
if isJSONColumn(k) {
|
|
if _, ok := v.(string); !ok {
|
|
b, _ := json.Marshal(v)
|
|
v = string(b)
|
|
}
|
|
}
|
|
d[k] = v
|
|
}
|
|
|
|
if len(extra) > 0 {
|
|
merged := map[string]interface{}{}
|
|
if s, ok := d["extra"].(string); ok && s != "" {
|
|
_ = json.Unmarshal([]byte(s), &merged)
|
|
}
|
|
for k, v := range extra {
|
|
merged[k] = v
|
|
}
|
|
b, _ := json.Marshal(merged)
|
|
d["extra"] = string(b)
|
|
}
|
|
// The table is shared across datasets, so kb_id identifies the row's
|
|
// dataset. Honour an explicit kb_id, otherwise stamp the target dataset.
|
|
if defaultKbID != "" {
|
|
if _, ok := d["kb_id"]; !ok {
|
|
d["kb_id"] = defaultKbID
|
|
}
|
|
}
|
|
for k, dv := range columnDefaults {
|
|
if _, ok := d[k]; !ok {
|
|
d[k] = dv
|
|
}
|
|
}
|
|
|
|
cols := make([]string, 0, len(d)+2*len(vecCols))
|
|
for c := range d {
|
|
cols = append(cols, c)
|
|
}
|
|
sort.Strings(cols)
|
|
vals := make([]interface{}, 0, len(cols)+2*len(vecCols))
|
|
for _, c := range cols {
|
|
if isArrayColumn(c) {
|
|
// VARCHAR[] columns must be bound through pq.Array; database/sql
|
|
// cannot convert a bare Go slice.
|
|
vals = append(vals, pq.Array(toStringArray(d[c])))
|
|
} else {
|
|
vals = append(vals, d[c])
|
|
}
|
|
}
|
|
vnames := make([]string, 0, len(vecCols))
|
|
for vc := range vecCols {
|
|
vnames = append(vnames, vc)
|
|
}
|
|
sort.Strings(vnames)
|
|
for _, vc := range vnames {
|
|
size, _ := strconv.Atoi(vectorColumnPattern.FindStringSubmatch(vc)[1])
|
|
cols = append(cols, vc, normColumn(size))
|
|
vals = append(vals, pq.Array(vecCols[vc]), pq.Array(l2Normalize(vecCols[vc])))
|
|
}
|
|
return cols, vals
|
|
}
|
|
|
|
// InsertChunks upserts chunks by id. Rows are grouped by identical column set
|
|
// and inserted in one multi-row statement per group. Returns an empty slice on
|
|
// success (ids are the caller's own).
|
|
func (e *serenedbEngine) InsertChunks(ctx context.Context, chunks []map[string]interface{}, baseName, datasetID string) ([]string, error) {
|
|
if len(chunks) == 0 {
|
|
return []string{}, nil
|
|
}
|
|
tableName := chunkTableName(baseName)
|
|
exists, err := e.tableExists(ctx, tableName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !exists {
|
|
size := 0
|
|
for k := range chunks[0] {
|
|
if m := vectorColumnPattern.FindStringSubmatch(k); m != nil {
|
|
size, _ = strconv.Atoi(m[1])
|
|
}
|
|
}
|
|
if err := e.CreateChunkStore(ctx, baseName, datasetID, size, ""); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
type group struct {
|
|
cols []string
|
|
rows [][]interface{}
|
|
}
|
|
groups := map[string]*group{}
|
|
for _, chunk := range chunks {
|
|
cols, vals := prepareChunkRow(chunk, datasetID)
|
|
key := strings.Join(cols, ",")
|
|
g := groups[key]
|
|
if g == nil {
|
|
g = &group{cols: cols}
|
|
groups[key] = g
|
|
}
|
|
g.rows = append(g.rows, vals)
|
|
}
|
|
|
|
for _, g := range groups {
|
|
query, args := buildUpsert(tableName, g.cols, g.rows)
|
|
if err := e.exec(ctx, query, args...); err != nil {
|
|
return nil, fmt.Errorf("serenedb: insert into %s: %w", tableName, err)
|
|
}
|
|
}
|
|
return []string{}, nil
|
|
}
|
|
|
|
// buildUpsert renders a multi-row INSERT ... ON CONFLICT (id) DO UPDATE for one
|
|
// column set. Pure so it can be asserted in tests.
|
|
func buildUpsert(tableName string, cols []string, rows [][]interface{}) (string, []interface{}) {
|
|
var args []interface{}
|
|
valueGroups := make([]string, 0, len(rows))
|
|
ph := 1
|
|
for _, row := range rows {
|
|
placeholders := make([]string, len(cols))
|
|
for i := range cols {
|
|
placeholders[i] = "$" + strconv.Itoa(ph)
|
|
ph++
|
|
args = append(args, row[i])
|
|
}
|
|
valueGroups = append(valueGroups, "("+strings.Join(placeholders, ", ")+")")
|
|
}
|
|
updates := make([]string, 0, len(cols))
|
|
for _, c := range cols {
|
|
if c == "id" {
|
|
continue
|
|
}
|
|
updates = append(updates, fmt.Sprintf("%s = EXCLUDED.%s", c, c))
|
|
}
|
|
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES %s ON CONFLICT (id) DO UPDATE SET %s",
|
|
tableName, strings.Join(cols, ", "), strings.Join(valueGroups, ", "), strings.Join(updates, ", "))
|
|
return query, args
|
|
}
|
|
|
|
// UpdateChunks applies field updates to rows matching condition. add/remove on
|
|
// array columns map to list_append/array_remove; other fields are set directly.
|
|
func (e *serenedbEngine) UpdateChunks(ctx context.Context, condition, newValue map[string]interface{}, baseName, datasetID string) error {
|
|
tableName := chunkTableName(baseName)
|
|
exists, err := e.tableExists(ctx, tableName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !exists {
|
|
return fmt.Errorf("serenedb: table %s does not exist", tableName)
|
|
}
|
|
cond := copyCondition(condition)
|
|
cond["kb_id"] = datasetID
|
|
if unknown := unrecognizedFilterKeys(cond); len(unknown) > 0 {
|
|
return fmt.Errorf("serenedb: refusing to update %s with unrecognized filter keys %v", tableName, unknown)
|
|
}
|
|
filters := buildFilters(cond)
|
|
if len(filters) == 0 {
|
|
return fmt.Errorf("serenedb: refusing to update %s without a filter", tableName)
|
|
}
|
|
sets := buildUpdateSets(newValue)
|
|
if len(sets) == 0 {
|
|
return nil
|
|
}
|
|
query := fmt.Sprintf("UPDATE %s SET %s WHERE %s", tableName, strings.Join(sets, ", "), strings.Join(filters, " AND "))
|
|
return e.exec(ctx, query)
|
|
}
|
|
|
|
// buildUpdateSets renders the SET clause fragments. Pure for tests.
|
|
func buildUpdateSets(newValue map[string]interface{}) []string {
|
|
var sets []string
|
|
for k, v := range newValue {
|
|
switch k {
|
|
case "remove":
|
|
items := map[string]interface{}{}
|
|
if s, ok := v.(string); ok {
|
|
items[s] = nil
|
|
} else if m, ok := v.(map[string]interface{}); ok {
|
|
items = m
|
|
}
|
|
for kk, vv := range items {
|
|
if _, known := columnDDL[kk]; !known {
|
|
continue
|
|
}
|
|
if vv == nil {
|
|
sets = append(sets, fmt.Sprintf("%s = NULL", kk))
|
|
} else if isArrayColumn(kk) {
|
|
sets = append(sets, fmt.Sprintf("%s = array_remove(%s, %s)", kk, kk, escapeLiteral(vv)))
|
|
}
|
|
}
|
|
case "add":
|
|
if m, ok := v.(map[string]interface{}); ok {
|
|
for kk, vv := range m {
|
|
if isArrayColumn(kk) {
|
|
sets = append(sets, fmt.Sprintf("%s = list_append(%s, %s)", kk, kk, escapeLiteral(vv)))
|
|
}
|
|
}
|
|
}
|
|
default:
|
|
if isJSONColumn(k) {
|
|
if s, ok := v.(string); ok {
|
|
sets = append(sets, fmt.Sprintf("%s = %s", k, escapeLiteral(s)))
|
|
} else {
|
|
b, _ := json.Marshal(v)
|
|
sets = append(sets, fmt.Sprintf("%s = %s", k, escapeLiteral(string(b))))
|
|
}
|
|
} else if _, known := columnDDL[k]; known {
|
|
sets = append(sets, fmt.Sprintf("%s = %s", k, escapeLiteral(v)))
|
|
}
|
|
}
|
|
}
|
|
sort.Strings(sets)
|
|
return sets
|
|
}
|
|
|
|
// DeleteChunks removes rows matching condition and returns the count. A missing
|
|
// table is not an error.
|
|
func (e *serenedbEngine) DeleteChunks(ctx context.Context, condition map[string]interface{}, baseName, datasetID string) (int64, error) {
|
|
tableName := chunkTableName(baseName)
|
|
exists, err := e.tableExists(ctx, tableName)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if !exists {
|
|
return 0, nil
|
|
}
|
|
cond := copyCondition(condition)
|
|
cond["kb_id"] = datasetID
|
|
if unknown := unrecognizedFilterKeys(cond); len(unknown) > 0 {
|
|
return 0, fmt.Errorf("serenedb: refusing to delete from %s with unrecognized filter keys %v", tableName, unknown)
|
|
}
|
|
filters := buildFilters(cond)
|
|
if len(filters) == 0 {
|
|
return 0, nil
|
|
}
|
|
where := strings.Join(filters, " AND ")
|
|
res, err := e.db.ExecContext(ctx, fmt.Sprintf("DELETE FROM %s WHERE %s", tableName, where))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
return n, nil
|
|
}
|
|
|
|
// GetChunk looks a chunk up by id in the tenant table, optionally scoped to
|
|
// the caller's datasets.
|
|
func (e *serenedbEngine) GetChunk(ctx context.Context, baseName, chunkID string, datasetIDs []string) (interface{}, error) {
|
|
tableName := chunkTableName(baseName)
|
|
exists, err := e.tableExists(ctx, tableName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !exists {
|
|
return nil, nil
|
|
}
|
|
query := fmt.Sprintf("SELECT * FROM %s WHERE id = $1", tableName)
|
|
if kbs := stringSlice(datasetIDs); len(kbs) > 0 {
|
|
if kb := buildFilters(map[string]interface{}{"kb_id": kbs}); len(kb) > 0 {
|
|
query += " AND " + kb[0]
|
|
}
|
|
}
|
|
rows, err := e.queryMaps(ctx, query, chunkID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(rows) == 0 {
|
|
return nil, nil
|
|
}
|
|
return rows[0], nil
|
|
}
|
|
|
|
// stringSlice drops empty ids so an all-blank dataset list yields no kb filter.
|
|
func stringSlice(ids []string) []string {
|
|
out := make([]string, 0, len(ids))
|
|
for _, id := range ids {
|
|
if id != "" {
|
|
out = append(out, id)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// toStringArray coerces an array-column value into the []string that pq.Array
|
|
// binds as a VARCHAR[].
|
|
func toStringArray(v interface{}) []string {
|
|
switch val := v.(type) {
|
|
case []string:
|
|
return val
|
|
case []interface{}:
|
|
out := make([]string, 0, len(val))
|
|
for _, x := range val {
|
|
if s, ok := x.(string); ok {
|
|
out = append(out, s)
|
|
} else {
|
|
out = append(out, fmt.Sprintf("%v", x))
|
|
}
|
|
}
|
|
return out
|
|
case nil:
|
|
return nil
|
|
case string:
|
|
return []string{val}
|
|
default:
|
|
return []string{fmt.Sprintf("%v", val)}
|
|
}
|
|
}
|
|
|
|
func copyCondition(condition map[string]interface{}) map[string]interface{} {
|
|
out := make(map[string]interface{}, len(condition)+1)
|
|
for k, v := range condition {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|