Files
ragflow/internal/engine/serenedb/search.go
deadtrickster 197b142cef feat(serenedb): add SereneDB doc-store engine (Go + Python connectors) (#17375)
## 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>
2026-08-04 14:16:39 +08:00

588 lines
16 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"
"fmt"
"math"
"regexp"
"sort"
"strconv"
"strings"
"ragflow/internal/engine/types"
)
const (
defaultPageSize = 30
defaultBranchTopN = 200
)
// pagerankExpr folds the dataset pagerank into a scored search, matching the ES
// fusion math (score + pagerank_fea/100).
const pagerankExpr = "COALESCE(" + pagerankField + ", 0) / 100.0"
type parsedMatch struct {
textQuery string
textTopN int
vectorData []float64
vectorTopN int
vecThreshold float64
vectorWeight float64
hasText bool
hasVector bool
}
// Search runs the fulltext, vector, hybrid-fusion, or filter-only query implied
// by the request's match expressions against the tenant table(s), scoping to
// req.KbIDs with a kb_id filter. Because all datasets share one table, BM25 is
// scored over the whole tenant corpus.
func (e *serenedbEngine) Search(ctx context.Context, req *types.SearchRequest) (*types.SearchResult, error) {
types.LogSearchRequest("serenedb", req)
pm := parseMatchExprs(req.MatchExprs)
outputFields := resolveOutputFields(req.SelectFields)
fieldsExpr := strings.Join(outputFields, ", ")
filters := searchFilters(req.Filter, req.KbIDs, pm.hasText || pm.hasVector)
where := filtersExpr(filters)
offset := req.Offset
if offset < 0 {
offset = 0
}
limit := req.Limit
if limit <= 0 {
limit = defaultPageSize
}
scored := pm.hasText || pm.hasVector
result := &types.SearchResult{Chunks: []map[string]interface{}{}}
for _, tableName := range req.IndexNames {
exists, err := e.tableExists(ctx, tableName)
if err != nil {
return nil, err
}
if !exists {
continue
}
var query string
switch {
case pm.hasText && pm.hasVector:
query = buildFusionSQL(tableName, fieldsExpr, outputFields, where, pm, offset, limit)
case pm.hasText:
query = buildFulltextSQL(tableName, fieldsExpr, where, pm.textQuery, branchLimit(pm.textTopN, limit), offset)
case pm.hasVector:
query = buildVectorSQL(tableName, fieldsExpr, where, pm, branchLimit(pm.vectorTopN, limit), offset)
default:
total, err := e.countRows(ctx, tableName, where)
if err != nil {
return nil, err
}
result.Total += total
query = buildFilterSQL(tableName, fieldsExpr, where, req.OrderBy, limit, offset)
}
rows, err := e.queryMaps(ctx, query)
if err != nil {
return nil, fmt.Errorf("serenedb: search %s: %w", tableName, err)
}
result.Chunks = append(result.Chunks, rows...)
}
if scored && len(result.Chunks) > 1 {
sortByScore(result.Chunks)
}
if limit > 0 && len(result.Chunks) > limit {
result.Chunks = result.Chunks[:limit]
}
if result.Total == 0 {
result.Total = int64(len(result.Chunks))
}
return result, nil
}
// resolveOutputFields keeps id and pagerank in the projection and drops the
// synthetic _score and any unknown fields.
func resolveOutputFields(selectFields []string) []string {
var fields []string
seen := map[string]struct{}{}
add := func(f string) {
if _, ok := seen[f]; ok {
return
}
seen[f] = struct{}{}
fields = append(fields, f)
}
add("id")
src := selectFields
useAll := len(src) == 0
for _, f := range src {
if f == "*" {
useAll = true
}
}
if useAll {
for _, c := range columnOrder {
add(c)
}
} else {
for _, f := range src {
if f == "_score" || f == "*" {
continue
}
if isKnownColumn(f) {
add(f)
}
}
}
add(pagerankField)
return fields
}
// searchFilters builds the SQL predicates for a search. kb_id scopes the query
// to the requested datasets within the shared tenant table, and scored queries
// default to available_int=1 when the caller did not set it.
func searchFilters(filter map[string]interface{}, kbIDs []string, scored bool) []string {
cond := map[string]interface{}{}
for k, v := range filter {
cond[k] = v
}
if kbs := stringSlice(kbIDs); len(kbs) > 0 {
cond["kb_id"] = kbs
}
if scored {
_, hasAvail := cond["available_int"]
_, hasStatus := cond["status"]
if !hasAvail && !hasStatus {
cond["available_int"] = 1
}
}
return buildFilters(cond)
}
// branchLimit is the row cap for a single-mode query.
func branchLimit(topN, limit int) int {
if limit > 0 {
return limit
}
if topN > 0 {
return topN
}
return defaultPageSize
}
// buildFulltextSQL scores the single lexical column with BM25 plus pagerank.
func buildFulltextSQL(tableName, fieldsExpr, where, textQuery string, limit, offset int) string {
idx := indexRelation(tableName)
match := fmt.Sprintf("%s @@ %s", lexScoredCol, escapeLiteral(textQuery))
return fmt.Sprintf(
"SELECT %s, BM25(%s.tableoid) + %s AS _score FROM %s WHERE %s AND (%s) "+
"ORDER BY _score DESC LIMIT %d OFFSET %d",
fieldsExpr, idx, pagerankExpr, idx, where, match, limit, offset)
}
// buildVectorSQL runs the ANN scan on the normalized shadow column. The
// similarity threshold goes straight in the WHERE (relies on SereneDB 26.07.4).
func buildVectorSQL(tableName, fieldsExpr, where string, pm parsedMatch, limit, offset int) string {
idx := indexRelation(tableName)
vecN := normColumn(len(pm.vectorData))
qv := vectorLiteral(pm.vectorData)
sim := fmt.Sprintf("-(%s <#> %s)", vecN, qv)
return fmt.Sprintf(
"SELECT %s, %s + %s AS _score FROM %s WHERE %s AND %s >= %s "+
"ORDER BY %s <#> %s LIMIT %d OFFSET %d",
fieldsExpr, sim, pagerankExpr, idx, where, sim, formatFloat(pm.vecThreshold),
vecN, qv, limit, offset)
}
// buildFusionSQL is the one-statement hybrid over a single tenant table: the
// BM25 branch normalized against the whole-table max with a window function,
// FULL OUTER JOINed with the ANN branch, weighted-summed with pagerank. Because
// the table holds the whole tenant corpus, the BM25 normalization is global.
func buildFusionSQL(tableName, fieldsExpr string, outputFields []string, where string, pm parsedMatch, offset, limit int) string {
idx := indexRelation(tableName)
vecN := normColumn(len(pm.vectorData))
qv := vectorLiteral(pm.vectorData)
match := fmt.Sprintf("%s @@ %s", lexScoredCol, escapeLiteral(pm.textQuery))
lexN := pm.textTopN
if lexN <= 0 {
lexN = defaultBranchTopN
}
vN := pm.vectorTopN
if vN <= 0 {
vN = defaultBranchTopN
}
n := limit
if n <= 0 {
n = lexN + vN
}
prefixed := make([]string, len(outputFields))
for i, f := range outputFields {
prefixed[i] = "t." + f
}
vw := pm.vectorWeight
return fmt.Sprintf(`WITH lex AS (
SELECT id, BM25(%s.tableoid) AS s
FROM %s WHERE %s AND (%s)
ORDER BY s DESC LIMIT %d),
lexn AS (SELECT id, s / NULLIF(MAX(s) OVER (), 0) AS sn FROM lex),
vec AS (
SELECT id, -(%s <#> %s) AS sim
FROM %s WHERE %s AND -(%s <#> %s) >= %s
ORDER BY %s <#> %s LIMIT %d),
fused AS (
SELECT COALESCE(l.id, v.id) AS id,
COALESCE(l.sn, 0) * %s + COALESCE(v.sim, 0) * %s AS fs
FROM lexn l FULL OUTER JOIN vec v ON l.id = v.id)
SELECT %s, f.fs + COALESCE(t.%s, 0) / 100.0 AS _score
FROM fused f JOIN %s t ON t.id = f.id
ORDER BY _score DESC LIMIT %d OFFSET %d`,
idx, idx, where, match, lexN,
vecN, qv, idx, where, vecN, qv, formatFloat(pm.vecThreshold), vecN, qv, vN,
formatWeight(1.0-vw), formatWeight(vw),
strings.Join(prefixed, ", "), pagerankField, tableName, n, offset)
}
// buildFilterSQL is the metadata/browse path: no scoring, optional ordering.
func buildFilterSQL(tableName, fieldsExpr, where string, orderBy *types.OrderByExpr, limit, offset int) string {
var order string
if orderBy != nil && len(orderBy.Fields) > 0 {
var parts []string
for _, f := range orderBy.Fields {
if _, known := columnDDL[f.Field]; !known {
continue
}
dir := "ASC"
if f.Type == types.SortDesc {
dir = "DESC"
}
parts = append(parts, fmt.Sprintf("%s %s", f.Field, dir))
}
if len(parts) > 0 {
order = " ORDER BY " + strings.Join(parts, ", ")
}
}
return fmt.Sprintf("SELECT %s FROM %s WHERE %s%s LIMIT %d OFFSET %d",
fieldsExpr, tableName, where, order, limit, offset)
}
func (e *serenedbEngine) countRows(ctx context.Context, tableName, where string) (int64, error) {
rows, err := e.queryMaps(ctx, fmt.Sprintf("SELECT count(*) AS c FROM %s WHERE %s", tableName, where))
if err != nil {
return 0, err
}
if len(rows) == 0 {
return 0, nil
}
return toInt64(rows[0]["c"]), nil
}
// parseMatchExprs extracts the text query, dense vector, and fusion weight from
// the ordered match expressions.
func parseMatchExprs(exprs []interface{}) parsedMatch {
pm := parsedMatch{vectorWeight: 0.5}
for _, m := range exprs {
switch expr := m.(type) {
case string:
if expr != "" {
pm.textQuery = stripESQuery(expr)
pm.hasText = true
}
case *types.MatchTextExpr:
raw := expr.MatchingText
if raw == "" && expr.ExtraOptions != nil {
if oq, ok := expr.ExtraOptions["original_query"].(string); ok {
raw = oq
}
}
if raw != "" {
pm.textQuery = stripESQuery(raw)
pm.textTopN = expr.TopN
pm.hasText = true
}
case *types.MatchDenseExpr:
if len(expr.EmbeddingData) > 0 {
pm.vectorData = expr.EmbeddingData
pm.vectorTopN = expr.TopN
pm.vecThreshold = denseThreshold(expr.ExtraOptions)
pm.hasVector = true
}
case *types.FusionExpr:
if w, ok := fusionVectorWeight(expr.FusionParams); ok {
pm.vectorWeight = w
}
}
}
return pm
}
func denseThreshold(opts map[string]interface{}) float64 {
if opts == nil {
return 0.0
}
switch v := opts["similarity"].(type) {
case float64:
return v
case string:
f, _ := strconv.ParseFloat(v, 64)
return f
}
if s, ok := opts["threshold"].(string); ok {
f, _ := strconv.ParseFloat(s, 64)
return f
}
return 0.0
}
// fusionVectorWeight reads the vector weight (second element of the weights
// pair) from the fusion params.
func fusionVectorWeight(params map[string]interface{}) (float64, bool) {
if params == nil {
return 0, false
}
w, ok := params["weights"].(string)
if !ok {
return 0, false
}
parts := strings.Split(w, ",")
if len(parts) < 2 {
return 0, false
}
f, err := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
if err != nil {
return 0, false
}
return f, true
}
func formatFloat(f float64) string {
return strconv.FormatFloat(f, 'f', -1, 64)
}
// formatWeight renders a fusion weight without float-subtraction noise
// (e.g. 1.0 - 0.95 renders as 0.05, not 0.050000000000000044). Weights are
// low-precision by nature, so rounding to 1e-6 is exact enough.
func formatWeight(f float64) string {
return strconv.FormatFloat(math.Round(f*1e6)/1e6, 'f', -1, 64)
}
func toInt64(v interface{}) int64 {
switch n := v.(type) {
case int64:
return n
case int:
return int64(n)
case float64:
return int64(n)
case string:
i, _ := strconv.ParseInt(n, 10, 64)
return i
}
return 0
}
func toFloat64(v interface{}) float64 {
switch n := v.(type) {
case float64:
return n
case int64:
return float64(n)
case int:
return float64(n)
case string:
f, _ := strconv.ParseFloat(n, 64)
return f
}
return 0
}
func sortByScore(chunks []map[string]interface{}) {
sort.SliceStable(chunks, func(i, j int) bool {
return toFloat64(chunks[i]["_score"]) > toFloat64(chunks[j]["_score"])
})
}
// GetChunkIDs returns the ids of the given chunks in order.
func (e *serenedbEngine) GetChunkIDs(chunks []map[string]interface{}) []string {
ids := make([]string, 0, len(chunks))
for _, c := range chunks {
if id, ok := c["id"].(string); ok {
ids = append(ids, id)
}
}
return ids
}
// GetScores maps chunk id to its recovered score, reading the structure
// KNNScores produces.
func (e *serenedbEngine) GetScores(searchResult map[string]interface{}) map[string]float64 {
scores := map[string]float64{}
hits, ok := searchResult["hits"].(map[string]interface{})
if !ok {
return scores
}
hitList, ok := hits["hits"].([]interface{})
if !ok {
return scores
}
for _, h := range hitList {
hit, ok := h.(map[string]interface{})
if !ok {
continue
}
id, ok := hit["_id"].(string)
if !ok {
continue
}
scores[id] = toFloat64(hit["_score"])
}
return scores
}
// KNNScores repackages the per-chunk _score into the hits structure GetScores
// consumes.
func (e *serenedbEngine) KNNScores(ctx context.Context, chunks []map[string]interface{}, queryVector []float64, topK int) (map[string]interface{}, error) {
if len(chunks) == 0 {
return nil, nil
}
hits := make([]interface{}, 0, len(chunks))
for _, c := range chunks {
id, _ := c["id"].(string)
hits = append(hits, map[string]interface{}{"_id": id, "_score": toFloat64(c["_score"])})
}
return map[string]interface{}{"hits": map[string]interface{}{"hits": hits}}, nil
}
// GetFields returns the requested fields per chunk id, omitting nil values.
func (e *serenedbEngine) GetFields(chunks []map[string]interface{}, fields []string) map[string]map[string]interface{} {
out := map[string]map[string]interface{}{}
if len(chunks) == 0 || len(fields) == 0 {
return out
}
for _, c := range chunks {
id, ok := c["id"].(string)
if !ok {
continue
}
row := map[string]interface{}{}
for _, f := range fields {
if v, ok := c[f]; ok && v != nil {
row[f] = v
}
}
out[id] = row
}
return out
}
// GetAggregation counts distinct values of a field across chunks, ordered by
// count descending.
func (e *serenedbEngine) GetAggregation(chunks []map[string]interface{}, fieldName string) []map[string]interface{} {
counts := map[string]int{}
for _, c := range chunks {
// Aggregation-style chunks carry an explicit value/count.
if val, ok := c["value"]; ok {
if s, ok := val.(string); ok {
counts[s] += toInt(c["count"])
continue
}
}
v, ok := c[fieldName]
if !ok {
continue
}
for _, item := range asList(v) {
if s, ok := item.(string); ok {
if strings.TrimSpace(s) != "" {
counts[s]++
}
}
}
}
out := make([]map[string]interface{}, 0, len(counts))
for k, n := range counts {
out = append(out, map[string]interface{}{"key": k, "count": n})
}
sort.SliceStable(out, func(i, j int) bool {
return out[i]["count"].(int) > out[j]["count"].(int)
})
return out
}
var nonWordBoundary = regexp.MustCompile(`</em>\s*<em>`)
// GetHighlight emphasizes keyword hits in the stored text, client-side.
func (e *serenedbEngine) GetHighlight(chunks []map[string]interface{}, keywords []string, fieldName string) map[string]string {
ans := map[string]string{}
if len(chunks) == 0 || len(keywords) == 0 {
return ans
}
var pats []*regexp.Regexp
for _, k := range keywords {
if k == "" {
continue
}
pats = append(pats, regexp.MustCompile(`(?i)(^|\W)(`+regexp.QuoteMeta(k)+`)(\W|$)`))
}
for _, c := range chunks {
id, ok := c["id"].(string)
if !ok {
continue
}
txt, ok := c[fieldName].(string)
if !ok || txt == "" {
continue
}
marked := txt
for _, p := range pats {
marked = p.ReplaceAllString(marked, "$1<em>$2</em>$3")
}
if strings.Contains(marked, "<em>") {
ans[id] = nonWordBoundary.ReplaceAllString(marked, " ")
}
}
return ans
}
func asList(v interface{}) []interface{} {
switch val := v.(type) {
case []interface{}:
return val
case []string:
out := make([]interface{}, len(val))
for i, s := range val {
out[i] = s
}
return out
default:
return []interface{}{v}
}
}
func toInt(v interface{}) int {
switch n := v.(type) {
case int:
return n
case int64:
return int(n)
case float64:
return int(n)
}
return 0
}