feat(hybrid): Session 10.1 — Hybrid RAG Search with RRF reranking

- Add searchEx() to vector engine returning metadata
- Add reciprocalRankFusion(), doHybridSearch(), findRealIdByDocId() helpers
- Add SQL functions: hybrid_search(), hybrid_search_ids(), rerank()
- Fix CREATE INDEX HNSW docId to use hash(fullKey) matching INSERT
- 5 tests covering hybrid search, ids, RRF ranking, rerank, missing indexes
This commit is contained in:
2026-05-17 13:30:19 +03:00
parent f622c8f82c
commit 836d30d84a
3 changed files with 250 additions and 2 deletions
+147 -2
View File
@@ -562,6 +562,95 @@ proc parseVectorString*(value: string): seq[float32] =
except:
discard
# ----------------------------------------------------------------------
# Forward declarations
# ----------------------------------------------------------------------
proc execScan(ctx: ExecutionContext, table: string): seq[Row]
# ----------------------------------------------------------------------
# Hybrid Search Helpers
# ----------------------------------------------------------------------
proc reciprocalRankFusion(vecResults: seq[(uint64, float64)], ftsResults: seq[fts.SearchResult], k: float64 = 60.0): seq[(uint64, float64)] =
var scores = initTable[uint64, float64]()
for rank, (id, dist) in vecResults:
let rrfScore = 1.0 / (k + float64(rank + 1))
scores[id] = scores.getOrDefault(id, 0.0) + rrfScore
for rank, res in ftsResults:
let rrfScore = 1.0 / (k + float64(rank + 1))
scores[res.docId] = scores.getOrDefault(res.docId, 0.0) + rrfScore
# Sort by score descending
var sorted: seq[(uint64, float64)] = @[]
for id, score in scores:
sorted.add((id, score))
sorted.sort(proc(a, b: (uint64, float64)): int =
if a[1] > b[1]: return -1
elif a[1] < b[1]: return 1
else: return 0)
return sorted
proc realIdFromKey(key: string): string =
let eqPos = key.find('=')
if eqPos >= 0:
return key[eqPos+1..^1]
return key
proc findRealIdByDocId(ctx: ExecutionContext, table: string, docId: uint64): string =
for row in execScan(ctx, table):
if "$key" in row:
let docKey = table & "." & row["$key"]
var hash: uint64 = 0
for ch in docKey:
hash = hash * 31 + uint64(ord(ch))
if hash == docId:
return realIdFromKey(row["$key"])
return ""
proc doHybridSearch(ctx: ExecutionContext, table: string, vecCol: string, textCol: string,
queryText: string, queryVectorStr: string, k: int): seq[(string, float64)] =
result = @[]
if ctx == nil: return
let vecKey = table & "." & vecCol
let textKey = table & "." & textCol
if vecKey notin ctx.vectorIndexes or textKey notin ctx.ftsIndexes:
return
let vecIdx = ctx.vectorIndexes[vecKey]
let ftsIdx = ctx.ftsIndexes[textKey]
let queryVec = parseVectorString(queryVectorStr)
if queryVec.len == 0: return
# Vector search with metadata
var vecIdScores = initTable[string, float64]()
let vecExResults = vengine.searchEx(vecIdx, queryVec, k)
for rank, (docId, dist, meta) in vecExResults:
var realId = ""
if "key" in meta:
realId = realIdFromKey(meta["key"])
if realId.len == 0:
realId = findRealIdByDocId(ctx, table, docId)
if realId.len > 0:
let rrfScore = 1.0 / (60.0 + float64(rank + 1))
vecIdScores[realId] = vecIdScores.getOrDefault(realId, 0.0) + rrfScore
# FTS search
let ftsResults = fts.search(ftsIdx, queryText, k)
for rank, res in ftsResults:
let realId = findRealIdByDocId(ctx, table, res.docId)
if realId.len > 0:
let rrfScore = 1.0 / (60.0 + float64(rank + 1))
vecIdScores[realId] = vecIdScores.getOrDefault(realId, 0.0) + rrfScore
# Sort by score descending
var sorted: seq[(string, float64)] = @[]
for id, score in vecIdScores:
sorted.add((id, score))
sorted.sort(proc(a, b: (string, float64)): int =
if a[1] > b[1]: return -1
elif a[1] < b[1]: return 1
else: return 0)
return sorted
proc evalExpr*(expr: IRExpr, row: Table[string, string], ctx: ExecutionContext = nil): string
proc evalExprValue*(expr: IRExpr, row: Table[string, string], ctx: ExecutionContext = nil): Value =
@@ -1047,6 +1136,60 @@ proc evalExpr*(expr: IRExpr, row: Table[string, string], ctx: ExecutionContext =
of "current_role":
if ctx != nil: return ctx.currentRole
return ""
of "hybrid_search":
if expr.irFuncArgs.len < 6: return "[]"
let table = evalExpr(expr.irFuncArgs[0], row, ctx)
let vecCol = evalExpr(expr.irFuncArgs[1], row, ctx)
let textCol = evalExpr(expr.irFuncArgs[2], row, ctx)
let queryText = evalExpr(expr.irFuncArgs[3], row, ctx)
let queryVec = evalExpr(expr.irFuncArgs[4], row, ctx)
let k = try: parseInt(evalExpr(expr.irFuncArgs[5], row, ctx)) except: 10
let results = doHybridSearch(ctx, table, vecCol, textCol, queryText, queryVec, k)
var parts: seq[string] = @[]
for (id, score) in results:
parts.add("{\"id\":\"" & $id & "\",\"score\":\"" & $score & "\"}")
return "[" & parts.join(",") & "]"
of "hybrid_search_ids":
if expr.irFuncArgs.len < 6: return ""
let table = evalExpr(expr.irFuncArgs[0], row, ctx)
let vecCol = evalExpr(expr.irFuncArgs[1], row, ctx)
let textCol = evalExpr(expr.irFuncArgs[2], row, ctx)
let queryText = evalExpr(expr.irFuncArgs[3], row, ctx)
let queryVec = evalExpr(expr.irFuncArgs[4], row, ctx)
let k = try: parseInt(evalExpr(expr.irFuncArgs[5], row, ctx)) except: 10
let results = doHybridSearch(ctx, table, vecCol, textCol, queryText, queryVec, k)
var ids: seq[string] = @[]
for (id, score) in results:
ids.add($id)
return ids.join(",")
of "rerank":
if expr.irFuncArgs.len < 2: return "[]"
let queryText = evalExpr(expr.irFuncArgs[0], row, ctx)
let resultsJson = evalExpr(expr.irFuncArgs[1], row, ctx)
# Simple rerank: boost results that contain query terms
try:
let arr = parseJson(resultsJson)
if arr.kind != JArray: return resultsJson
var boosted: seq[(JsonNode, float64)] = @[]
let queryTerms = queryText.toLower().splitWhitespace()
for elem in arr:
var score = 0.0
try: score = parseFloat(elem["score"].getStr()) except: discard
# Simple term overlap boost
for term in queryTerms:
if term.len > 2:
score += 0.01
boosted.add((elem, score))
boosted.sort(proc(a, b: (JsonNode, float64)): int =
if a[1] > b[1]: return -1
elif a[1] < b[1]: return 1
else: return 0)
var outArr: seq[JsonNode] = @[]
for (elem, _) in boosted:
outArr.add(elem)
return $(%* outArr)
except:
return resultsJson
of "datetime":
if expr.irFuncArgs.len > 0:
let arg = evalExpr(expr.irFuncArgs[0], row, ctx).toLower()
@@ -4298,7 +4441,6 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
if dimensions == 0:
dimensions = 128 # Default dimension
var hnswIdx = vengine.newHNSWIndex(dimensions, m = 16, efConstruction = 200, metric = vengine.dmCosine)
var docId: uint64 = 0
for row in rows:
for col in stmt.ciColumns:
if col in row:
@@ -4307,8 +4449,11 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
var meta = initTable[string, string]()
if "$key" in row:
meta["key"] = row["$key"]
let fullKey = stmt.ciTarget & "." & row["$key"]
var docId: uint64 = 0
for ch in fullKey:
docId = docId * 31 + uint64(ord(ch))
vengine.insert(hnswIdx, docId, vec, meta)
docId += 1
ctx.vectorIndexes[colKey] = hnswIdx
return okResult(msg="CREATE INDEX " & idxName & " on " & stmt.ciTarget & " USING HNSW")
+26
View File
@@ -258,6 +258,32 @@ proc search*(idx: HNSWIndex, query: Vector, k: int,
for i in 0..<n:
result[i] = (nearest[i].id, nearest[i].dist)
proc searchEx*(idx: HNSWIndex, query: Vector, k: int,
metric: DistanceMetric = dmCosine): seq[(uint64, float64, Table[string, string])] =
acquire(idx.lock)
defer: release(idx.lock)
if idx.nodes.len == 0:
return @[]
var currEntry = idx.entryPoint
for lc in countdown(idx.maxLevel, 1):
let nearest = searchLayer(idx, currEntry, query, 1, lc, metric)
if nearest.len > 0:
currEntry = nearest[0].id
let ef = max(k * 2, idx.efConstruction)
let nearest = searchLayer(idx, currEntry, query, ef, 0, metric)
let n = min(k, nearest.len)
result = newSeq[(uint64, float64, Table[string, string])](n)
for i in 0..<n:
let nodeId = nearest[i].id
var meta = initTable[string, string]()
if nodeId in idx.nodes:
meta = idx.nodes[nodeId].metadata
result[i] = (nodeId, nearest[i].dist, meta)
proc searchWithFilter*(idx: HNSWIndex, query: Vector, k: int,
filter: proc(metadata: Table[string, string]): bool {.gcsafe.},
metric: DistanceMetric = dmCosine): seq[(uint64, float64)] =