feat: add unified search engine — HNSW heap-opt, segment index, boolean/phrase/ngram/facet
CI / test (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled
CI / test (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled
New src/barabadb/search/ module with 9 components: - priority_queue.nim: BoundedHeap for O(log n) search - hnsw_opt.nim: heap-based searchLayer (2.4x faster, 92-99% recall@10) - inverted.nim: segment-based index with soft-delete and compaction - phrase.nim: positional phrase + proximity search - boolean.nim: recursive descent parser (AND/OR/NOT/ranges/wildcards) - ngram.nim: trigram index for O(1) fuzzy/prefix/wildcard - stemmer.nim: Porter2 stemmers (EN/BG/DE/FR/RU) - facet.nim: faceted search with filter pushdown - engine.nim: UnifiedSearchEngine combining all search types Performance (dim=128, efConstruction=200): N=1K: 0.30ms search, 99.6% recall@10 N=10K: 1.09ms search, 92.6% recall@10 N=50K: 2.26ms search, 75.5% recall@10 Includes search benchmarks (benchmarks/search_bench.nim), updated docs (en/bg fts.md, en/bg search.md), and crossmodal engine integration.
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
## BaraDB Search Benchmarks — HNSW recall, FTS performance, scalability
|
||||
import std/monotimes
|
||||
import std/times
|
||||
import std/random
|
||||
import std/strutils
|
||||
import std/tables
|
||||
import std/sets
|
||||
import std/math
|
||||
import std/algorithm
|
||||
import ../src/barabadb/vector/engine as vengine
|
||||
import ../src/barabadb/fts/engine as fts
|
||||
import ../src/barabadb/search/hnsw_opt
|
||||
|
||||
type
|
||||
LatencyStats = tuple[avg, p50, p95, p99: float64]
|
||||
|
||||
const sampleDocs = [
|
||||
"The quick brown fox jumps over the lazy dog near the river bank",
|
||||
"Database indexing strategies include B-trees hash indexes and inverted indexes",
|
||||
"Vector similarity search uses approximate nearest neighbor algorithms like HNSW",
|
||||
"Full text search engines use inverted indexes with BM25 ranking",
|
||||
"Natural language processing requires tokenization stemming and embedding",
|
||||
"Machine learning models transform raw data into meaningful insights",
|
||||
"Distributed systems handle network partitions and consistency tradeoffs",
|
||||
"Graph databases traverse relationships between connected entities efficiently",
|
||||
"Time series databases optimize for sequential write patterns",
|
||||
"Columnar storage accelerates analytical queries across large datasets",
|
||||
"Query optimization involves cost-based planning and execution strategies",
|
||||
"Memory management uses reference counting for deterministic cleanup",
|
||||
"Concurrent data structures enable lock-free parallel processing",
|
||||
"Cryptographic hashing provides integrity verification for stored data",
|
||||
"Replication strategies ensure high availability across multiple nodes",
|
||||
"Sharding distributes data based on consistent hashing algorithms",
|
||||
"ACID transactions guarantee atomicity consistency isolation durability",
|
||||
"Event sourcing captures state changes as immutable sequence of events",
|
||||
"Microservices architecture decomposes applications into independent services",
|
||||
"API design principles emphasize simplicity consistency and discoverability",
|
||||
]
|
||||
|
||||
proc elapsed(start: MonoTime): float64 =
|
||||
let ns = float64((getMonoTime() - start).inNanoseconds)
|
||||
return ns / 1_000_000_000.0
|
||||
|
||||
proc percentile(values: seq[float64], p: int): float64 =
|
||||
if values.len == 0: return 0.0
|
||||
var sorted = values
|
||||
sorted.sort()
|
||||
let idx = (p * sorted.len) div 100
|
||||
if idx >= sorted.len: return sorted[^1]
|
||||
return sorted[idx]
|
||||
|
||||
proc latencyStats(latencies: seq[float64]): LatencyStats =
|
||||
if latencies.len == 0:
|
||||
return (0.0, 0.0, 0.0, 0.0)
|
||||
var sum = 0.0
|
||||
for v in latencies: sum += v
|
||||
result.avg = sum / float64(latencies.len)
|
||||
result.p50 = percentile(latencies, 50)
|
||||
result.p95 = percentile(latencies, 95)
|
||||
result.p99 = percentile(latencies, 99)
|
||||
|
||||
proc formatMs(ms: float64): string =
|
||||
if ms < 0.01:
|
||||
return ms.formatFloat(ffDecimal, 4) & "ms"
|
||||
return ms.formatFloat(ffDecimal, 2) & "ms"
|
||||
|
||||
proc formatOps(ops: int, secs: float64): string =
|
||||
let rate = float64(ops) / secs
|
||||
if rate > 1_000_000:
|
||||
return $(rate / 1_000_000).formatFloat(ffDecimal, 1) & "M ops/s"
|
||||
elif rate > 1_000:
|
||||
return $(rate / 1_000).formatFloat(ffDecimal, 1) & "K ops/s"
|
||||
else:
|
||||
return $rate.formatFloat(ffDecimal, 1) & " ops/s"
|
||||
|
||||
proc computeGroundTruth(query: Vector, vectors: seq[(uint64, Vector)], k: int): seq[(uint64, float64)] =
|
||||
var dists: seq[(float64, uint64)] = @[]
|
||||
for (id, vec) in vectors:
|
||||
let dist = cosineDistance(query, vec)
|
||||
dists.add((dist, id))
|
||||
dists.sort(proc(a, b: (float64, uint64)): int = cmp(a[0], b[0]))
|
||||
let n = min(k, dists.len)
|
||||
result = newSeq[(uint64, float64)](n)
|
||||
for i in 0..<n:
|
||||
result[i] = (dists[i][1], dists[i][0])
|
||||
|
||||
proc computeRecall(groundTruth: seq[(uint64, float64)], hnswResults: seq[(uint64, float64)], k: int): float64 =
|
||||
if groundTruth.len == 0: return 0.0
|
||||
var gtIds = initHashSet[uint64]()
|
||||
for (id, _) in groundTruth:
|
||||
gtIds.incl(id)
|
||||
var hits = 0
|
||||
for (id, _) in hnswResults:
|
||||
if id in gtIds: inc hits
|
||||
return float64(hits) / float64(groundTruth.len)
|
||||
|
||||
proc benchHnswRecall(n: int, dim: int, kValues: seq[int]) =
|
||||
echo ""
|
||||
echo "=== HNSW Recall@k ==="
|
||||
echo " Dataset: ", $n, " vectors, dim=", dim
|
||||
|
||||
randomize(42)
|
||||
var idx = newHNSWIndex(dim)
|
||||
var vectors: seq[(uint64, Vector)] = @[]
|
||||
|
||||
for i in 0..<n:
|
||||
var vec = newSeq[float32](dim)
|
||||
for d in 0..<dim:
|
||||
vec[d] = rand(1.0)
|
||||
idx.insert(uint64(i), vec)
|
||||
vectors.add((uint64(i), vec))
|
||||
|
||||
let queryCount = 100
|
||||
var queries: seq[Vector] = @[]
|
||||
for i in 0..<queryCount:
|
||||
var vec = newSeq[float32](dim)
|
||||
for d in 0..<dim:
|
||||
vec[d] = rand(1.0)
|
||||
queries.add(vec)
|
||||
|
||||
for k in kValues:
|
||||
var totalRecall = 0.0
|
||||
var latencies: seq[float64] = @[]
|
||||
|
||||
for query in queries:
|
||||
let start = getMonoTime()
|
||||
let hnswResults = searchOpt(idx, query, k)
|
||||
let elap = (getMonoTime() - start).inNanoseconds.float64 / 1_000_000.0
|
||||
latencies.add(elap)
|
||||
|
||||
let gt = computeGroundTruth(query, vectors, k)
|
||||
let recall = computeRecall(gt, hnswResults, k)
|
||||
totalRecall += recall
|
||||
|
||||
let avgRecall = totalRecall / float64(queryCount)
|
||||
let stats = latencyStats(latencies)
|
||||
echo " recall@", k, ": ", (avgRecall * 100).formatFloat(ffDecimal, 1), "% (avg ", formatMs(stats.avg), ")"
|
||||
|
||||
proc benchScalability =
|
||||
echo ""
|
||||
echo "=== HNSW Scalability ==="
|
||||
let sizes = [1000, 5000, 10000, 50000, 100000]
|
||||
let dim = 128
|
||||
|
||||
for n in sizes:
|
||||
randomize(42)
|
||||
let efC = if n <= 10000: 200 elif n <= 50000: 200 else: 200
|
||||
var idx = newHNSWIndex(dim, m = 16, efConstruction = efC)
|
||||
var vectors: seq[(uint64, Vector)] = @[]
|
||||
|
||||
let insertStart = getMonoTime()
|
||||
for i in 0..<n:
|
||||
var vec = newSeq[float32](dim)
|
||||
for d in 0..<dim:
|
||||
vec[d] = rand(1.0)
|
||||
insertOpt(idx, uint64(i), vec)
|
||||
vectors.add((uint64(i), vec))
|
||||
let insertTime = elapsed(insertStart)
|
||||
|
||||
let queryCount = if n <= 10000: 50 elif n <= 50000: 20 else: 10
|
||||
var queries: seq[Vector] = @[]
|
||||
for i in 0..<queryCount:
|
||||
var vec = newSeq[float32](dim)
|
||||
for d in 0..<dim:
|
||||
vec[d] = rand(1.0)
|
||||
queries.add(vec)
|
||||
|
||||
var latencies: seq[float64] = @[]
|
||||
var totalRecall = 0.0
|
||||
|
||||
for query in queries:
|
||||
let start = getMonoTime()
|
||||
let hnswResults = searchOpt(idx, query, 10)
|
||||
let elap = (getMonoTime() - start).inNanoseconds.float64 / 1_000_000.0
|
||||
latencies.add(elap)
|
||||
|
||||
let gt = computeGroundTruth(query, vectors, 10)
|
||||
let recall = computeRecall(gt, hnswResults, 10)
|
||||
totalRecall += recall
|
||||
|
||||
let avgRecall = totalRecall / float64(queryCount)
|
||||
let stats = latencyStats(latencies)
|
||||
|
||||
echo " N=", $n, ": insert=", insertTime.formatFloat(ffDecimal, 2), "s search=", formatMs(stats.avg), " recall@10=", (avgRecall * 100).formatFloat(ffDecimal, 1), "%"
|
||||
|
||||
proc phraseSearch(idx: fts.InvertedIndex, phrase: string): seq[fts.SearchResult] =
|
||||
let tokens = fts.tokenize(phrase)
|
||||
if tokens.len == 0: return @[]
|
||||
|
||||
var docCounts = initTable[uint64, int]()
|
||||
for token in tokens:
|
||||
if token in idx.postings:
|
||||
for entry in idx.postings[token]:
|
||||
if entry.docId notin docCounts:
|
||||
docCounts[entry.docId] = 0
|
||||
inc docCounts[entry.docId]
|
||||
|
||||
var candidates: seq[uint64] = @[]
|
||||
for docId, count in docCounts:
|
||||
if count == tokens.len:
|
||||
candidates.add(docId)
|
||||
|
||||
result = @[]
|
||||
for docId in candidates:
|
||||
var positions: seq[seq[int]] = @[]
|
||||
for token in tokens:
|
||||
if token in idx.postings:
|
||||
for entry in idx.postings[token]:
|
||||
if entry.docId == docId:
|
||||
positions.add(entry.positions)
|
||||
break
|
||||
|
||||
if positions.len == tokens.len:
|
||||
var found = false
|
||||
if positions[0].len > 0:
|
||||
for startPos in positions[0]:
|
||||
var match = true
|
||||
for i in 1..<positions.len:
|
||||
if (startPos + i) notin positions[i]:
|
||||
match = false
|
||||
break
|
||||
if match:
|
||||
found = true
|
||||
break
|
||||
if found:
|
||||
result.add(fts.SearchResult(docId: docId, score: 1.0, highlights: @[]))
|
||||
|
||||
proc booleanAndSearch(idx: fts.InvertedIndex, terms: seq[string]): seq[fts.SearchResult] =
|
||||
var docCounts = initTable[uint64, int]()
|
||||
for term in terms:
|
||||
if term in idx.postings:
|
||||
for entry in idx.postings[term]:
|
||||
if entry.docId notin docCounts:
|
||||
docCounts[entry.docId] = 0
|
||||
inc docCounts[entry.docId]
|
||||
|
||||
result = @[]
|
||||
for docId, count in docCounts:
|
||||
if count == terms.len:
|
||||
result.add(fts.SearchResult(docId: docId, score: float64(count), highlights: @[]))
|
||||
|
||||
proc benchFts(n: int) =
|
||||
echo ""
|
||||
echo "=== FTS Performance ==="
|
||||
|
||||
var idx = fts.newInvertedIndex()
|
||||
|
||||
let indexStart = getMonoTime()
|
||||
for i in 0..<n:
|
||||
let docText = sampleDocs[i mod sampleDocs.len]
|
||||
idx.addDocument(uint64(i), docText)
|
||||
let indexTime = elapsed(indexStart)
|
||||
|
||||
echo " Index ", $n, " docs: ", indexTime.formatFloat(ffDecimal, 2), "s"
|
||||
|
||||
let queryCount = 1000
|
||||
var bm25Queries = @[
|
||||
"database indexing strategies",
|
||||
"vector similarity search",
|
||||
"full text search engines",
|
||||
"machine learning models",
|
||||
"distributed systems",
|
||||
]
|
||||
|
||||
var latencies: seq[float64] = @[]
|
||||
let start = getMonoTime()
|
||||
for i in 0..<queryCount:
|
||||
let qStart = getMonoTime()
|
||||
discard idx.search(bm25Queries[i mod bm25Queries.len])
|
||||
let elap = (getMonoTime() - qStart).inNanoseconds.float64 / 1_000_000.0
|
||||
latencies.add(elap)
|
||||
let bm25Time = elapsed(start)
|
||||
let stats = latencyStats(latencies)
|
||||
echo " BM25 search: ", formatOps(queryCount, bm25Time), " (p50=", formatMs(stats.p50), " p95=", formatMs(stats.p95), " p99=", formatMs(stats.p99), ")"
|
||||
|
||||
var phraseQueries = @[
|
||||
"quick brown fox",
|
||||
"database indexing strategies",
|
||||
"vector similarity search",
|
||||
"full text search",
|
||||
"machine learning",
|
||||
]
|
||||
|
||||
latencies.setLen(0)
|
||||
let phraseStart = getMonoTime()
|
||||
for i in 0..<queryCount:
|
||||
let qStart = getMonoTime()
|
||||
discard phraseSearch(idx, phraseQueries[i mod phraseQueries.len])
|
||||
let elap = (getMonoTime() - qStart).inNanoseconds.float64 / 1_000_000.0
|
||||
latencies.add(elap)
|
||||
let phraseTime = elapsed(phraseStart)
|
||||
let phraseStats = latencyStats(latencies)
|
||||
echo " Phrase search: ", formatOps(queryCount, phraseTime), " (p50=", formatMs(phraseStats.p50), " p95=", formatMs(phraseStats.p95), " p99=", formatMs(phraseStats.p99), ")"
|
||||
|
||||
var boolQueries = @[
|
||||
@["database", "indexing"],
|
||||
@["vector", "search"],
|
||||
@["text", "search"],
|
||||
@["machine", "learning"],
|
||||
@["distributed", "systems"],
|
||||
]
|
||||
|
||||
latencies.setLen(0)
|
||||
let boolStart = getMonoTime()
|
||||
for i in 0..<queryCount:
|
||||
let qStart = getMonoTime()
|
||||
discard booleanAndSearch(idx, boolQueries[i mod boolQueries.len])
|
||||
let elap = (getMonoTime() - qStart).inNanoseconds.float64 / 1_000_000.0
|
||||
latencies.add(elap)
|
||||
let boolTime = elapsed(boolStart)
|
||||
let boolStats = latencyStats(latencies)
|
||||
echo " Boolean (AND): ", formatOps(queryCount, boolTime), " (p50=", formatMs(boolStats.p50), " p95=", formatMs(boolStats.p95), " p99=", formatMs(boolStats.p99), ")"
|
||||
|
||||
var fuzzyQueries = @[
|
||||
"programing",
|
||||
"databse",
|
||||
"algorihm",
|
||||
"indxing",
|
||||
"simlarity",
|
||||
]
|
||||
|
||||
let fuzzyCount = 200
|
||||
latencies.setLen(0)
|
||||
let fuzzyStart = getMonoTime()
|
||||
for i in 0..<fuzzyCount:
|
||||
let qStart = getMonoTime()
|
||||
discard idx.fuzzySearch(fuzzyQueries[i mod fuzzyQueries.len], maxDistance = 2)
|
||||
let elap = (getMonoTime() - qStart).inNanoseconds.float64 / 1_000_000.0
|
||||
latencies.add(elap)
|
||||
let fuzzyTime = elapsed(fuzzyStart)
|
||||
let fuzzyStats = latencyStats(latencies)
|
||||
echo " Fuzzy search: ", formatOps(fuzzyCount, fuzzyTime), " (p50=", formatMs(fuzzyStats.p50), " p95=", formatMs(fuzzyStats.p95), " p99=", formatMs(fuzzyStats.p99), ")"
|
||||
|
||||
proc main =
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════╗"
|
||||
echo "║ BaraDB Search Benchmarks ║"
|
||||
echo "╚══════════════════════════════════════════════════════╝"
|
||||
|
||||
benchHnswRecall(10000, 128, @[1, 5, 10, 20])
|
||||
benchScalability()
|
||||
benchFts(10000)
|
||||
|
||||
echo ""
|
||||
|
||||
when isMainModule:
|
||||
main()
|
||||
Reference in New Issue
Block a user