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

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:
2026-05-30 13:42:08 +03:00
parent 965ed2f675
commit ef264d7d69
18 changed files with 3978 additions and 7 deletions
+14
View File
@@ -6,6 +6,7 @@ import ../storage/lsm
import ../vector/engine as vengine
import ../graph/engine as gengine
import ../fts/engine as fts
import ../search/hnsw_opt
type
QueryMode* = enum
@@ -88,6 +89,19 @@ proc searchVectorFiltered*(engine: CrossModalEngine, query: seq[float32], k: int
filter: proc(meta: Table[string, string]): bool {.gcsafe.}): seq[(uint64, float64)] =
vengine.searchWithFilter(engine.vectorIdx, query, k, filter)
proc searchVectorOpt*(engine: CrossModalEngine, query: seq[float32], k: int = 10,
metric: vengine.DistanceMetric = vengine.dmCosine): seq[(uint64, float64)] =
hnsw_opt.searchOpt(engine.vectorIdx, query, k, metric)
proc searchVectorFilteredOpt*(engine: CrossModalEngine, query: seq[float32], k: int,
filter: proc(meta: Table[string, string]): bool {.gcsafe.}): seq[(uint64, float64)] =
hnsw_opt.searchWithFilterOpt(engine.vectorIdx, query, k, filter)
proc insertVectorOpt*(engine: CrossModalEngine, id: uint64, vector: seq[float32],
meta: Table[string, string] = initTable[string, string]()) =
hnsw_opt.insertOpt(engine.vectorIdx, id, vector, meta)
engine.metadata[id] = meta
# Graph operations
proc addNode*(engine: CrossModalEngine, label: string,
props: Table[string, string] = initTable[string, string]()): uint64 =
+548
View File
@@ -0,0 +1,548 @@
import std/tables
import std/strutils
import std/math
import std/algorithm
import std/sets
type
PostingEntry* = object
docId*: uint64
termFreq*: int
positions*: seq[int]
BoolOp* = enum
boAnd = "AND"
boOr = "OR"
boNot = "NOT"
QueryNodeKind* = enum
qnkTerm, qnkPhrase, qnkBool, qnkWildcard, qnkFuzzy, qnkRange
QueryNode* = ref object
case kind*: QueryNodeKind
of qnkTerm:
term*: string
field*: string
boost*: float64
of qnkPhrase:
phraseTerms*: seq[string]
slop*: int
of qnkBool:
op*: BoolOp
children*: seq[QueryNode]
of qnkWildcard:
pattern*: string
of qnkFuzzy:
fuzzyTerm*: string
maxDistance*: int
of qnkRange:
rangeField*: string
rangeMin*: float64
rangeMax*: float64
includeMin*: bool
includeMax*: bool
SearchResult* = object
docId*: uint64
score*: float64
highlights*: seq[(int, int)]
# --- Tokenizer ---
type
TokenKind = enum
tkWord, tkQuoted, tkNumber,
tkAnd, tkOr, tkNot,
tkLParen, tkRParen,
tkLBracket, tkRBracket,
tkColon, tkTilde, tkStar,
tkPlus, tkMinus, tkTo,
tkEOF
Token = object
kind: TokenKind
value: string
proc tokenizeQuery(input: string): seq[Token] =
result = @[]
var i = 0
while i < input.len:
case input[i]
of ' ', '\t', '\n', '\r':
inc i
of '(':
result.add(Token(kind: tkLParen, value: "("))
inc i
of ')':
result.add(Token(kind: tkRParen, value: ")"))
inc i
of '[':
result.add(Token(kind: tkLBracket, value: "["))
inc i
of ']':
result.add(Token(kind: tkRBracket, value: "]"))
inc i
of ':':
result.add(Token(kind: tkColon, value: ":"))
inc i
of '~':
result.add(Token(kind: tkTilde, value: "~"))
inc i
of '*':
result.add(Token(kind: tkStar, value: "*"))
inc i
of '+':
result.add(Token(kind: tkPlus, value: "+"))
inc i
of '-':
result.add(Token(kind: tkMinus, value: "-"))
inc i
of '"':
inc i
var phrase = ""
while i < input.len and input[i] != '"':
phrase.add(input[i])
inc i
if i < input.len:
inc i
result.add(Token(kind: tkQuoted, value: phrase))
else:
var word = ""
while i < input.len and
input[i] notin {' ', '\t', '\n', '\r', '(', ')', '[', ']',
':', '~', '*', '+', '-', '"'}:
word.add(input[i])
inc i
let upper = word.toUpperAscii()
if upper == "AND":
result.add(Token(kind: tkAnd, value: "AND"))
elif upper == "OR":
result.add(Token(kind: tkOr, value: "OR"))
elif upper == "NOT":
result.add(Token(kind: tkNot, value: "NOT"))
elif upper == "TO":
result.add(Token(kind: tkTo, value: "TO"))
else:
var isNum = true
var hasDot = false
for ci, c in word:
if c == '-' and ci == 0: continue
if c == '.' and not hasDot:
hasDot = true
continue
if not c.isDigit():
isNum = false
break
if isNum and word.len > 0 and word != "-":
result.add(Token(kind: tkNumber, value: word))
else:
result.add(Token(kind: tkWord, value: word))
result.add(Token(kind: tkEOF, value: ""))
# --- Parser ---
type
Parser = object
tokens: seq[Token]
pos: int
proc peek(p: var Parser): Token =
if p.pos < p.tokens.len:
p.tokens[p.pos]
else:
Token(kind: tkEOF, value: "")
proc advance(p: var Parser): Token =
result = p.peek()
if p.pos < p.tokens.len:
inc p.pos
proc parseExpr(p: var Parser): QueryNode
proc parsePrimary(p: var Parser): QueryNode
proc parseRange(p: var Parser, fieldName: string): QueryNode =
let minTok = p.advance()
var minVal: float64
if minTok.kind == tkNumber:
minVal = parseFloat(minTok.value)
elif minTok.kind == tkStar:
minVal = NegInf
else:
minVal = NegInf
discard p.advance() # TO
let maxTok = p.advance()
var maxVal: float64
if maxTok.kind == tkNumber:
maxVal = parseFloat(maxTok.value)
elif maxTok.kind == tkStar:
maxVal = Inf
else:
maxVal = Inf
if p.peek().kind == tkRBracket:
discard p.advance()
QueryNode(
kind: qnkRange,
rangeField: fieldName,
rangeMin: minVal,
rangeMax: maxVal,
includeMin: true,
includeMax: true,
)
proc parsePrimary(p: var Parser): QueryNode =
let tok = p.peek()
case tok.kind
of tkLParen:
discard p.advance()
let inner = parseExpr(p)
if p.peek().kind == tkRParen:
discard p.advance()
return inner
of tkQuoted:
discard p.advance()
let words = tok.value.splitWhitespace()
return QueryNode(kind: qnkPhrase, phraseTerms: words, slop: 0)
of tkWord:
discard p.advance()
var fieldName = ""
var termValue = tok.value
if p.peek().kind == tkColon:
discard p.advance()
fieldName = tok.value
let next = p.peek()
if next.kind == tkLBracket:
discard p.advance()
return parseRange(p, fieldName)
elif next.kind == tkQuoted:
let qt = p.advance()
let words = qt.value.splitWhitespace()
return QueryNode(kind: qnkPhrase, phraseTerms: words, slop: 0)
elif next.kind in {tkWord, tkNumber}:
termValue = p.advance().value
else:
termValue = ""
if p.peek().kind == tkTilde:
discard p.advance()
var dist = 2
if p.peek().kind == tkNumber:
dist = parseInt(p.advance().value)
return QueryNode(kind: qnkFuzzy, fuzzyTerm: termValue.toLowerAscii(),
maxDistance: dist)
if p.peek().kind == tkStar:
discard p.advance()
return QueryNode(kind: qnkWildcard, pattern: termValue.toLowerAscii() & "*")
return QueryNode(kind: qnkTerm, term: termValue.toLowerAscii(),
field: fieldName, boost: 1.0)
of tkPlus:
discard p.advance()
return parsePrimary(p)
of tkMinus:
discard p.advance()
let inner = parsePrimary(p)
return QueryNode(kind: qnkBool, op: boNot, children: @[inner])
of tkNumber:
discard p.advance()
return QueryNode(kind: qnkTerm, term: tok.value, field: "", boost: 1.0)
else:
discard p.advance()
return QueryNode(kind: qnkTerm, term: "", field: "", boost: 1.0)
proc parseNotExpr(p: var Parser): QueryNode =
if p.peek().kind == tkNot:
discard p.advance()
let inner = parseNotExpr(p)
return QueryNode(kind: qnkBool, op: boNot, children: @[inner])
return parsePrimary(p)
proc parseAndExpr(p: var Parser): QueryNode =
var children: seq[QueryNode] = @[]
children.add(parseNotExpr(p))
while true:
let tok = p.peek()
if tok.kind == tkAnd:
discard p.advance()
children.add(parseNotExpr(p))
elif tok.kind in {tkWord, tkQuoted, tkLParen, tkPlus, tkMinus,
tkNumber, tkNot}:
children.add(parseNotExpr(p))
else:
break
if children.len == 1:
return children[0]
return QueryNode(kind: qnkBool, op: boAnd, children: children)
proc parseOrExpr(p: var Parser): QueryNode =
var children: seq[QueryNode] = @[]
children.add(parseAndExpr(p))
while p.peek().kind == tkOr:
discard p.advance()
children.add(parseAndExpr(p))
if children.len == 1:
return children[0]
return QueryNode(kind: qnkBool, op: boOr, children: children)
proc parseExpr(p: var Parser): QueryNode =
parseOrExpr(p)
proc parseQuery*(input: string): QueryNode =
let tokens = tokenizeQuery(input)
var parser = Parser(tokens: tokens, pos: 0)
parseExpr(parser)
# --- Levenshtein distance ---
proc levenshtein(a, b: string): int =
let m = a.len
let n = b.len
var d = newSeq[seq[int]](m + 1)
for i in 0..m:
d[i] = newSeq[int](n + 1)
d[i][0] = i
for j in 0..n:
d[0][j] = j
for i in 1..m:
for j in 1..n:
let cost = if a[i-1] == b[j-1]: 0 else: 1
d[i][j] = min(d[i-1][j] + 1, min(d[i][j-1] + 1, d[i-1][j-1] + cost))
return d[m][n]
# --- Executor ---
proc executeNode(postings: Table[string, seq[PostingEntry]],
query: QueryNode,
docScores: var Table[uint64, float64],
allDocIds: HashSet[uint64]): HashSet[uint64] =
result = initHashSet[uint64]()
case query.kind
of qnkTerm:
let key = if query.field.len > 0: query.field & ":" & query.term
else: query.term
if key in postings:
for entry in postings[key]:
result.incl(entry.docId)
let s = float64(entry.termFreq) * query.boost
if entry.docId notin docScores:
docScores[entry.docId] = 0.0
docScores[entry.docId] += s
of qnkPhrase:
if query.phraseTerms.len == 0:
return
var candidates = initHashSet[uint64]()
var first = true
for pt in query.phraseTerms:
let ptLower = pt.toLowerAscii()
var docs = initHashSet[uint64]()
if ptLower in postings:
for entry in postings[ptLower]:
docs.incl(entry.docId)
if first:
candidates = docs
first = false
else:
candidates = candidates * docs
for docId in candidates:
var valid = true
var lastPos = -1
for i, pt in query.phraseTerms:
let ptLower = pt.toLowerAscii()
if ptLower notin postings:
valid = false
break
var found = false
for entry in postings[ptLower]:
if entry.docId == docId:
for pos in entry.positions:
if i == 0 or pos == lastPos + 1 + query.slop:
found = true
lastPos = pos
break
break
if not found:
valid = false
break
if valid:
result.incl(docId)
if docId notin docScores:
docScores[docId] = 0.0
docScores[docId] += 1.0
of qnkBool:
case query.op
of boAnd:
var first = true
for child in query.children:
let childDocs = executeNode(postings, child, docScores, allDocIds)
if first:
result = childDocs
first = false
else:
result = result * childDocs
if first:
return
of boOr:
for child in query.children:
let childDocs = executeNode(postings, child, docScores, allDocIds)
result = result + childDocs
of boNot:
if query.children.len > 0:
let childDocs = executeNode(postings, query.children[0], docScores, allDocIds)
result = allDocIds - childDocs
of qnkWildcard:
let prefix = query.pattern.strip(chars = {'*'})
for term in postings.keys:
if term.startsWith(prefix):
for entry in postings[term]:
result.incl(entry.docId)
if entry.docId notin docScores:
docScores[entry.docId] = 0.0
docScores[entry.docId] += float64(entry.termFreq)
of qnkFuzzy:
let target = query.fuzzyTerm.toLowerAscii()
for term in postings.keys:
if levenshtein(term, target) <= query.maxDistance:
for entry in postings[term]:
result.incl(entry.docId)
if entry.docId notin docScores:
docScores[entry.docId] = 0.0
docScores[entry.docId] += float64(entry.termFreq)
of qnkRange:
discard
proc executeBoolQuery*(postings: Table[string, seq[PostingEntry]],
query: QueryNode,
docScores: var Table[uint64, float64],
allDocIds: HashSet[uint64] = initHashSet[uint64]()): HashSet[uint64] =
executeNode(postings, query, docScores, allDocIds)
# --- BM25 helpers ---
proc expandTerms(postings: Table[string, seq[PostingEntry]],
node: QueryNode): seq[string] =
result = @[]
case node.kind
of qnkTerm:
let key = if node.field.len > 0: node.field & ":" & node.term
else: node.term
if key in postings:
result.add(key)
of qnkPhrase:
for pt in node.phraseTerms:
let t = pt.toLowerAscii()
if t in postings:
result.add(t)
of qnkBool:
for child in node.children:
result.add(expandTerms(postings, child))
of qnkWildcard:
let prefix = node.pattern.strip(chars = {'*'})
for term in postings.keys:
if term.startsWith(prefix):
result.add(term)
of qnkFuzzy:
let target = node.fuzzyTerm.toLowerAscii()
for term in postings.keys:
if levenshtein(term, target) <= node.maxDistance:
result.add(term)
of qnkRange:
discard
# --- High-level API ---
proc booleanSearch*(postings: Table[string, seq[PostingEntry]],
docLengths: Table[uint64, int],
docCount: int,
avgDocLen: float64,
queryStr: string,
limit: int = 10,
fieldValues: Table[string, Table[uint64, float64]] =
initTable[string, Table[uint64, float64]]()): seq[SearchResult] =
let query = parseQuery(queryStr)
var allDocIds = initHashSet[uint64]()
for docId in docLengths.keys:
allDocIds.incl(docId)
var rawScores = initTable[uint64, float64]()
let matchingDocs = executeBoolQuery(postings, query, rawScores, allDocIds)
if matchingDocs.len == 0:
return @[]
let terms = expandTerms(postings, query)
var finalScores = initTable[uint64, float64]()
const k1 = 1.2
const b = 0.75
let n = float64(docCount)
for term in terms:
if term notin postings:
continue
let df = float64(postings[term].len)
if df == 0.0:
continue
let idf = ln((n - df + 0.5) / (df + 0.5) + 1.0)
for entry in postings[term]:
if entry.docId notin matchingDocs:
continue
let docLen = float64(docLengths.getOrDefault(entry.docId, 0))
if docLen == 0.0 or avgDocLen == 0.0:
continue
let tfNorm = (float64(entry.termFreq) * (k1 + 1.0)) /
(float64(entry.termFreq) + k1 * (1.0 - b + b * docLen / avgDocLen))
if entry.docId notin finalScores:
finalScores[entry.docId] = 0.0
finalScores[entry.docId] += idf * tfNorm
# Apply range filters post-execution
proc applyRangeFilters(node: QueryNode, docs: var HashSet[uint64]) =
case node.kind
of qnkRange:
if node.rangeField in fieldValues:
let fv = fieldValues[node.rangeField]
var toRemove: seq[uint64] = @[]
for docId in docs:
if docId notin fv:
toRemove.add(docId)
continue
let v = fv[docId]
let belowMin = if node.includeMin: v < node.rangeMin
else: v <= node.rangeMin
let aboveMax = if node.includeMax: v > node.rangeMax
else: v >= node.rangeMax
if belowMin or aboveMax:
toRemove.add(docId)
for docId in toRemove:
docs.excl(docId)
of qnkBool:
for child in node.children:
applyRangeFilters(child, docs)
else:
discard
var resultDocs = matchingDocs
applyRangeFilters(query, resultDocs)
var results: seq[SearchResult] = @[]
for docId in resultDocs:
let score = finalScores.getOrDefault(docId, rawScores.getOrDefault(docId, 0.0))
results.add(SearchResult(docId: docId, score: score, highlights: @[]))
results.sort(proc(a, b: SearchResult): int = cmp(b.score, a.score))
if results.len > limit:
results = results[0..<limit]
return results
+245
View File
@@ -0,0 +1,245 @@
import std/tables
import std/sets
import std/locks
import std/math
import std/algorithm
import inverted
import phrase
import boolean as boolmod
import ngram
import stemmer
import facet
import hnsw_opt
import ../vector/engine as vengine
import ../fts/multilang
import ../fts/engine as ftsengine
type
SearchConfig* = object
language*: Language
maxSegmentSize*: int
fieldBoosts*: Table[string, float64]
ngramSize*: int
enableFacets*: bool
SearchResult* = object
docId*: uint64
score*: float64
highlights*: seq[(int, int)]
UnifiedSearchEngine* = ref object
fts*: SegmentIndex
ngrams*: NGramIndex
facets*: FacetIndex
vectorIdx*: vengine.HNSWIndex
config*: SearchConfig
stemmerFn*: Stemmer2
lock*: Lock
proc defaultSearchConfig*(): SearchConfig =
SearchConfig(
language: langEnglish,
maxSegmentSize: 50_000,
fieldBoosts: initTable[string, float64](),
ngramSize: 3,
enableFacets: true,
)
proc newUnifiedSearchEngine*(config: SearchConfig = defaultSearchConfig()): UnifiedSearchEngine =
let segIdx = newSegmentIndex(config.maxSegmentSize)
segIdx.langConfig = getLanguageConfig(config.language)
segIdx.fieldBoosts = config.fieldBoosts
result = UnifiedSearchEngine(
fts: segIdx,
ngrams: newNGramIndex(config.ngramSize),
facets: newFacetIndex(),
vectorIdx: vengine.newHNSWIndex(128),
config: config,
stemmerFn: getStemmer2(config.language),
)
initLock(result.lock)
proc toNgramPosting(seg: Segment): Table[string, seq[ngram.PostingEntry]] =
result = initTable[string, seq[ngram.PostingEntry]]()
for term, entries in seg.postings:
var converted: seq[ngram.PostingEntry] = @[]
for entry in entries:
converted.add(ngram.PostingEntry(
docId: entry.docId,
termFreq: entry.termFreq,
positions: entry.positions,
))
result[term] = converted
proc toBoolPosting(idx: SegmentIndex): Table[string, seq[boolmod.PostingEntry]] =
result = initTable[string, seq[boolmod.PostingEntry]]()
for seg in idx.segments:
for term, entries in seg.postings:
if term notin result:
result[term] = @[]
for entry in entries:
if entry.docId notin seg.deleted:
result[term].add(boolmod.PostingEntry(
docId: entry.docId,
termFreq: entry.termFreq,
positions: entry.positions,
))
proc indexDocument*(engine: UnifiedSearchEngine, docId: uint64, text: string,
fields: Table[string, string] = initTable[string, string](),
facets: Table[string, seq[string]] = initTable[string, seq[string]]()) =
engine.fts.addDocument(docId, text, fields)
if engine.config.enableFacets and facets.len > 0:
engine.facets.addDocument(docId, facets)
let seg = engine.fts.segments[^1]
let nPostings = toNgramPosting(seg)
engine.ngrams.buildFromSegment(nPostings)
proc removeDocument*(engine: UnifiedSearchEngine, docId: uint64) =
engine.fts.removeDocument(docId)
if engine.config.enableFacets:
engine.facets.removeDocument(docId)
proc indexVector*(engine: UnifiedSearchEngine, id: uint64, vector: vengine.Vector,
metadata: Table[string, string] = initTable[string, string]()) =
hnsw_opt.insertOpt(engine.vectorIdx, id, vector, metadata)
proc search*(engine: UnifiedSearchEngine, query: string,
limit: int = 10): seq[SearchResult] =
let res = engine.fts.search(query, limit)
result = newSeq[SearchResult](res.len)
for i, r in res:
result[i] = SearchResult(docId: r.docId, score: r.score, highlights: r.highlights)
proc searchPhrase*(engine: UnifiedSearchEngine, terms: seq[string],
slop: int = 0, limit: int = 10): seq[SearchResult] =
let pq = phrase.PhraseQuery(terms: terms, slop: slop)
let res = phrase.phraseSearch(engine.fts, pq, limit)
result = newSeq[SearchResult](res.len)
for i, r in res:
result[i] = SearchResult(docId: r.docId, score: r.score, highlights: r.highlights)
proc searchProximity*(engine: UnifiedSearchEngine, terms: seq[string],
maxDistance: int = 5, limit: int = 10): seq[SearchResult] =
let res = phrase.proximitySearch(engine.fts, terms, maxDistance, limit)
result = newSeq[SearchResult](res.len)
for i, r in res:
result[i] = SearchResult(docId: r.docId, score: r.score, highlights: r.highlights)
proc searchBoolean*(engine: UnifiedSearchEngine, queryStr: string,
limit: int = 10): seq[SearchResult] =
let postings = toBoolPosting(engine.fts)
var allDocLengths = initTable[uint64, int]()
var totalDocCount = 0
var totalTerms = 0
for seg in engine.fts.segments:
for docId, docLen in seg.docLengths:
if docId notin seg.deleted:
allDocLengths[docId] = docLen
inc totalDocCount
totalTerms += docLen
let avgDocLen = if totalDocCount > 0: float64(totalTerms) / float64(totalDocCount) else: 0.0
let res = boolmod.booleanSearch(postings, allDocLengths, totalDocCount, avgDocLen, queryStr, limit)
result = newSeq[SearchResult](res.len)
for i, r in res:
result[i] = SearchResult(docId: r.docId, score: r.score, highlights: r.highlights)
proc searchFuzzy*(engine: UnifiedSearchEngine, query: string,
maxDistance: int = 2, limit: int = 10): seq[SearchResult] =
var allPostings = initTable[string, seq[ngram.PostingEntry]]()
for seg in engine.fts.segments:
let segPostings = toNgramPosting(seg)
for term, entries in segPostings:
if term notin allPostings:
allPostings[term] = @[]
for entry in entries:
if entry.docId notin seg.deleted:
allPostings[term].add(entry)
let res = ngram.fuzzySearchFast(engine.ngrams, allPostings, query, maxDistance, limit)
result = newSeq[SearchResult](res.len)
for i, r in res:
result[i] = SearchResult(docId: r.docId, score: r.score, highlights: r.highlights)
proc searchPrefix*(engine: UnifiedSearchEngine, prefix: string,
limit: int = 10): seq[FuzzyCandidate] =
engine.ngrams.prefixSearch(prefix, limit)
proc searchWildcard*(engine: UnifiedSearchEngine, pattern: string,
limit: int = 10): seq[FuzzyCandidate] =
engine.ngrams.wildcardSearch(pattern, limit)
proc searchVector*(engine: UnifiedSearchEngine, query: vengine.Vector, k: int = 10,
metric: vengine.DistanceMetric = vengine.dmCosine): seq[(uint64, float64)] =
hnsw_opt.searchOpt(engine.vectorIdx, query, k, metric)
proc searchVectorFiltered*(engine: UnifiedSearchEngine, query: vengine.Vector, k: int,
filter: proc(meta: Table[string, string]): bool {.gcsafe.},
metric: vengine.DistanceMetric = vengine.dmCosine): seq[(uint64, float64)] =
hnsw_opt.searchWithFilterOpt(engine.vectorIdx, query, k, filter, metric)
proc hybridSearch*(engine: UnifiedSearchEngine, queryText: string, queryVec: vengine.Vector,
k: int = 10, textWeight: float64 = 1.0,
vecWeight: float64 = 1.0): seq[(uint64, float64)] =
const rrfK = 60.0
let ftsResults = engine.search(queryText, k * 2)
let vecResults = if queryVec.len > 0: engine.searchVector(queryVec, k * 2) else: @[]
var rrfScores = initTable[uint64, float64]()
for rank, res in ftsResults:
let score = textWeight / (rrfK + float64(rank + 1))
rrfScores[res.docId] = rrfScores.getOrDefault(res.docId, 0.0) + score
for rank, (id, _) in vecResults:
let score = vecWeight / (rrfK + float64(rank + 1))
rrfScores[id] = rrfScores.getOrDefault(id, 0.0) + score
var results: seq[(uint64, float64)] = @[]
for docId, score in rrfScores:
results.add((docId, score))
results.sort(proc(a, b: (uint64, float64)): int = cmp(b[1], a[1]))
if results.len > k:
results = results[0..<k]
return results
proc getFacetCounts*(engine: UnifiedSearchEngine, field: string,
candidateDocs: HashSet[uint64] = initHashSet[uint64](),
limit: int = 10): seq[FacetCount] =
engine.facets.getFacetCounts(field, candidateDocs, limit)
proc filterByFacets*(engine: UnifiedSearchEngine, filters: seq[FacetFilter]): HashSet[uint64] =
engine.facets.filterByFacets(filters)
proc compact*(engine: UnifiedSearchEngine) =
engine.fts.compact()
for seg in engine.fts.segments:
let nPostings = toNgramPosting(seg)
engine.ngrams.buildFromSegment(nPostings)
proc setFieldBoost*(engine: UnifiedSearchEngine, field: string, boost: float64) =
engine.fts.fieldBoosts[field] = boost
engine.config.fieldBoosts[field] = boost
proc setLanguage*(engine: UnifiedSearchEngine, lang: Language) =
engine.config.language = lang
engine.fts.langConfig = getLanguageConfig(lang)
engine.stemmerFn = getStemmer2(lang)
proc documentCount*(engine: UnifiedSearchEngine): int =
var count = 0
for seg in engine.fts.segments:
count += seg.docCount - seg.deleted.len
return count
proc termCount*(engine: UnifiedSearchEngine): int =
var terms: HashSet[string]
for seg in engine.fts.segments:
for term in seg.postings.keys:
terms.incl(term)
return terms.len
+121
View File
@@ -0,0 +1,121 @@
import std/tables
import std/sets
import std/algorithm
import std/locks
type
FacetField* = object
name*: string
values*: Table[string, HashSet[uint64]]
FacetIndex* = ref object
fields*: Table[string, FacetField]
lock*: Lock
FacetCount* = object
value*: string
count*: int
FacetFilter* = object
field*: string
values*: seq[string]
exclude*: bool
proc newFacetIndex*(): FacetIndex =
result = FacetIndex(fields: initTable[string, FacetField]())
initLock(result.lock)
proc addDocument*(idx: FacetIndex, docId: uint64,
facets: Table[string, seq[string]]) =
acquire(idx.lock)
try:
for fieldName, vals in facets:
if fieldName notin idx.fields:
idx.fields[fieldName] = FacetField(
name: fieldName,
values: initTable[string, HashSet[uint64]](),
)
for v in vals:
if v notin idx.fields[fieldName].values:
idx.fields[fieldName].values[v] = initHashSet[uint64]()
idx.fields[fieldName].values[v].incl(docId)
finally:
release(idx.lock)
proc removeDocument*(idx: FacetIndex, docId: uint64) =
acquire(idx.lock)
try:
for fieldName, field in idx.fields.mpairs:
var emptyKeys: seq[string] = @[]
for val, docIds in field.values.mpairs:
docIds.excl(docId)
if docIds.len == 0:
emptyKeys.add(val)
for key in emptyKeys:
field.values.del(key)
finally:
release(idx.lock)
proc updateDocument*(idx: FacetIndex, docId: uint64,
facets: Table[string, seq[string]]) =
idx.removeDocument(docId)
idx.addDocument(docId, facets)
proc getFacetCounts*(idx: FacetIndex, field: string,
candidateDocs: HashSet[uint64] = initHashSet[uint64](),
limit: int = 10): seq[FacetCount] =
acquire(idx.lock)
try:
result = @[]
if field notin idx.fields:
return
let useFilter = candidateDocs.len > 0
for val, docIds in idx.fields[field].values:
var count = 0
if useFilter:
for docId in docIds:
if docId in candidateDocs:
inc count
else:
count = docIds.len
if count > 0:
result.add(FacetCount(value: val, count: count))
result.sort(proc(a, b: FacetCount): int = cmp(b.count, a.count))
if result.len > limit:
result = result[0..<limit]
finally:
release(idx.lock)
proc filterByFacets*(idx: FacetIndex, filters: seq[FacetFilter]): HashSet[uint64] =
acquire(idx.lock)
try:
result = initHashSet[uint64]()
if filters.len == 0:
return
var first = true
for filter in filters:
var filterDocs = initHashSet[uint64]()
if filter.field in idx.fields:
for val in filter.values:
if val in idx.fields[filter.field].values:
filterDocs = filterDocs + idx.fields[filter.field].values[val]
if filter.exclude:
var allFieldDocs = initHashSet[uint64]()
if filter.field in idx.fields:
for val, docIds in idx.fields[filter.field].values:
allFieldDocs = allFieldDocs + docIds
filterDocs = allFieldDocs - filterDocs
if first:
result = filterDocs
first = false
else:
result = result * filterDocs
finally:
release(idx.lock)
proc aggregate*(idx: FacetIndex, fields: seq[string],
candidateDocs: HashSet[uint64] = initHashSet[uint64](),
limit: int = 10): Table[string, seq[FacetCount]] =
result = initTable[string, seq[FacetCount]]()
for field in fields:
result[field] = idx.getFacetCounts(field, candidateDocs, limit)
+195
View File
@@ -0,0 +1,195 @@
import std/tables
import std/sets
import std/locks
import std/math
import std/random
import std/algorithm
import ../vector/engine
import priority_queue
proc randomLevelOpt(m: int): int =
var level = 0
let p = 1.0 / float64(m)
while rand(1.0) < p and level < 16:
inc level
return level
proc selectNeighborsOpt(candidates: seq[NodeDist], maxN: int): seq[uint64] =
var sorted = candidates
sorted.sort(proc(a, b: NodeDist): int = cmp(a.dist, b.dist))
let n = min(maxN, sorted.len)
result = newSeq[uint64](n)
for i in 0..<n:
result[i] = sorted[i].id
proc addBidirectionalLinkOpt(idx: HNSWIndex, nodeId, neighborId: uint64, level: int) =
let node = idx.nodes[nodeId]
let neighbor = idx.nodes[neighborId]
if level >= node.neighbors.len or level >= neighbor.neighbors.len:
return
if neighborId notin node.neighbors[level]:
node.neighbors[level].add(neighborId)
if nodeId notin neighbor.neighbors[level]:
neighbor.neighbors[level].add(nodeId)
if neighbor.neighbors[level].len > idx.maxM:
var dists: seq[(float64, uint64)] = @[]
for nid in neighbor.neighbors[level]:
dists.add((distance(neighbor.vector, idx.nodes[nid].vector, idx.metric), nid))
dists.sort(proc(a, b: (float64, uint64)): int = cmp(a[0], b[0]))
neighbor.neighbors[level].setLen(idx.maxM)
for i in 0..<idx.maxM:
neighbor.neighbors[level][i] = dists[i][1]
proc searchLayerOpt*(idx: HNSWIndex, entryId: uint64, query: Vector, ef: int,
level: int, metric: DistanceMetric): seq[NodeDist] =
var visited = initHashSet[uint64]()
let candidates = newBoundedHeap[float64, uint64](0,
proc(a, b: float64): bool = a < b)
let nearest = newBoundedHeap[float64, uint64](ef,
proc(a, b: float64): bool = a > b)
let entryDist = distance(query, idx.nodes[entryId].vector, metric)
candidates.push(entryDist, entryId)
nearest.push(entryDist, entryId)
visited.incl(entryId)
while not candidates.isEmpty:
let closest = candidates.pop()
if nearest.len >= ef and closest.key > nearest.peek().key:
break
let node = idx.nodes[closest.value]
if level < node.neighbors.len:
for neighborId in node.neighbors[level]:
if neighborId notin visited:
visited.incl(neighborId)
let dist = distance(query, idx.nodes[neighborId].vector, metric)
if nearest.len < ef or dist < nearest.peek().key:
candidates.push(dist, neighborId)
nearest.push(dist, neighborId)
result = newSeqOfCap[NodeDist](nearest.len)
for entry in nearest.items():
result.add((entry.key, entry.value))
result.sort(proc(a, b: NodeDist): int = cmp(a.dist, b.dist))
proc searchOpt*(idx: HNSWIndex, query: Vector, k: int,
metric: DistanceMetric = dmCosine): seq[(uint64, float64)] =
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 = searchLayerOpt(idx, currEntry, query, 1, lc, metric)
if nearest.len > 0:
currEntry = nearest[0].id
let ef = max(k * 2, idx.efConstruction)
let nearest = searchLayerOpt(idx, currEntry, query, ef, 0, metric)
let n = min(k, nearest.len)
result = newSeq[(uint64, float64)](n)
for i in 0..<n:
result[i] = (nearest[i].id, nearest[i].dist)
proc searchExOpt*(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 = searchLayerOpt(idx, currEntry, query, 1, lc, metric)
if nearest.len > 0:
currEntry = nearest[0].id
let ef = max(k * 2, idx.efConstruction)
let nearest = searchLayerOpt(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 searchWithFilterOpt*(idx: HNSWIndex, query: Vector, k: int,
filter: proc(metadata: Table[string, string]): bool {.gcsafe.},
metric: DistanceMetric = dmCosine): seq[(uint64, float64)] =
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 = searchLayerOpt(idx, currEntry, query, 1, lc, metric)
if nearest.len > 0:
currEntry = nearest[0].id
let maxEf = max(k * 64, idx.efConstruction * 4)
var ef = k
while ef <= maxEf:
let nearest = searchLayerOpt(idx, currEntry, query, ef, 0, metric)
var filtered: seq[(uint64, float64)] = @[]
for nd in nearest:
if nd.id in idx.nodes and filter(idx.nodes[nd.id].metadata):
filtered.add((nd.id, nd.dist))
if filtered.len >= k:
return filtered[0..<k]
if nearest.len > 0:
currEntry = nearest[0].id
ef = ef * 2
let nearest = searchLayerOpt(idx, currEntry, query, maxEf, 0, metric)
var filtered: seq[(uint64, float64)] = @[]
for nd in nearest:
if nd.id in idx.nodes and filter(idx.nodes[nd.id].metadata):
filtered.add((nd.id, nd.dist))
if filtered.len > k:
filtered.setLen(k)
return filtered
proc insertOpt*(idx: HNSWIndex, id: uint64, vector: Vector,
metadata: Table[string, string] = initTable[string, string]()) =
acquire(idx.lock)
defer: release(idx.lock)
let level = randomLevelOpt(idx.m)
let node = HNSWNode(id: id, vector: vector, metadata: metadata,
neighbors: newSeq[seq[uint64]](level + 1))
for i in 0..level:
node.neighbors[i] = @[]
idx.nodes[id] = node
if idx.entryPoint == 0:
idx.entryPoint = id
idx.maxLevel = level
return
var currEntry = idx.entryPoint
for lc in countdown(idx.maxLevel, level + 1):
let nearest = searchLayerOpt(idx, currEntry, vector, 1, lc, idx.metric)
if nearest.len > 0:
currEntry = nearest[0].id
let topLevel = min(level, idx.maxLevel)
for lc in countdown(topLevel, 0):
let nearest = searchLayerOpt(idx, currEntry, vector, idx.efConstruction, lc, idx.metric)
let neighbors = selectNeighborsOpt(nearest, idx.m)
for neighborId in neighbors:
addBidirectionalLinkOpt(idx, id, neighborId, lc)
if nearest.len > 0:
currEntry = nearest[0].id
if level > idx.maxLevel:
idx.entryPoint = id
idx.maxLevel = level
+242
View File
@@ -0,0 +1,242 @@
import std/tables
import std/sets
import std/math
import std/algorithm
import std/locks
from ../fts/engine import PostingEntry
import ../fts/multilang
type
SearchResult* = object
docId*: uint64
score*: float64
highlights*: seq[(int, int)]
FieldBoost* = object
fieldName*: string
boost*: float64
Segment* = ref object
id*: int
postings*: Table[string, seq[PostingEntry]]
docLengths*: Table[uint64, int]
docFields*: Table[uint64, Table[string, string]]
docFieldTerms*: Table[uint64, Table[string, HashSet[string]]]
docCount*: int
avgDocLen*: float64
totalTerms*: int
deleted*: HashSet[uint64]
SegmentIndex* = ref object
segments*: seq[Segment]
fieldBoosts*: Table[string, float64]
nextSegmentId*: int
maxSegmentSize*: int
langConfig*: LanguageConfig
lock*: Lock
proc newSegment*(id: int): Segment =
Segment(
id: id,
postings: initTable[string, seq[PostingEntry]](),
docLengths: initTable[uint64, int](),
docFields: initTable[uint64, Table[string, string]](),
docFieldTerms: initTable[uint64, Table[string, HashSet[string]]](),
docCount: 0,
avgDocLen: 0.0,
totalTerms: 0,
deleted: initHashSet[uint64](),
)
proc newSegmentIndex*(maxSegmentSize: int = 50_000): SegmentIndex =
result = SegmentIndex(
segments: @[newSegment(0)],
fieldBoosts: initTable[string, float64](),
nextSegmentId: 1,
maxSegmentSize: maxSegmentSize,
langConfig: getLanguageConfig(langEnglish),
)
initLock(result.lock)
proc addDocumentToSegment(seg: Segment, docId: uint64, tokens: seq[string],
fields: Table[string, string], langConfig: LanguageConfig) =
var termFreqs = initTable[string, int]()
var positions = initTable[string, seq[int]]()
for i, token in tokens:
if token notin termFreqs:
termFreqs[token] = 0
positions[token] = @[]
inc termFreqs[token]
positions[token].add(i)
for term, freq in termFreqs:
if term notin seg.postings:
seg.postings[term] = @[]
seg.postings[term].add(PostingEntry(
docId: docId,
termFreq: freq,
positions: positions[term],
))
seg.docLengths[docId] = tokens.len
inc seg.docCount
seg.totalTerms += tokens.len
if seg.docCount > 0:
seg.avgDocLen = float64(seg.totalTerms) / float64(seg.docCount)
if fields.len > 0:
seg.docFields[docId] = fields
var fieldTerms = initTable[string, HashSet[string]]()
for fieldName, fieldValue in fields:
let fieldTokens = tokenize(fieldValue, langConfig).toHashSet()
fieldTerms[fieldName] = fieldTokens
seg.docFieldTerms[docId] = fieldTerms
proc addDocument*(idx: SegmentIndex, docId: uint64, text: string,
fields: Table[string, string] = initTable[string, string]()) =
acquire(idx.lock)
try:
let tokens = tokenize(text, idx.langConfig)
var seg = idx.segments[^1]
addDocumentToSegment(seg, docId, tokens, fields, idx.langConfig)
if seg.docCount >= idx.maxSegmentSize:
let newSeg = newSegment(idx.nextSegmentId)
inc idx.nextSegmentId
idx.segments.add(newSeg)
finally:
release(idx.lock)
proc removeDocument*(idx: SegmentIndex, docId: uint64) =
acquire(idx.lock)
try:
for seg in idx.segments:
if docId in seg.docLengths:
seg.deleted.incl(docId)
return
finally:
release(idx.lock)
proc bm25SegScore(seg: Segment, term: string, entry: PostingEntry,
k1: float64 = 1.2, b: float64 = 0.75): float64 =
let df = seg.postings[term].len
let n = seg.docCount
if df == 0 or n == 0:
return 0.0
let idf = ln((float64(n) - float64(df) + 0.5) / (float64(df) + 0.5) + 1.0)
let docLen = float64(seg.docLengths.getOrDefault(entry.docId, 0))
let tfNorm = (float64(entry.termFreq) * (k1 + 1.0)) /
(float64(entry.termFreq) + k1 * (1.0 - b + b * docLen / seg.avgDocLen))
return idf * tfNorm
proc search*(idx: SegmentIndex, query: string, limit: int = 10): seq[SearchResult] =
acquire(idx.lock)
try:
let queryTokens = tokenize(query, idx.langConfig)
if queryTokens.len == 0:
return @[]
var docScores = initTable[uint64, float64]()
var docHighlights = initTable[uint64, seq[(int, int)]]()
for seg in idx.segments:
for token in queryTokens:
if token notin seg.postings:
continue
let postings = seg.postings[token]
for entry in postings:
if entry.docId in seg.deleted:
continue
var score = bm25SegScore(seg, token, entry)
if score == 0.0:
continue
var maxBoost = 1.0
if entry.docId in seg.docFieldTerms:
let fieldTerms = seg.docFieldTerms[entry.docId]
for fieldName, terms in fieldTerms:
if token in terms:
let boost = idx.fieldBoosts.getOrDefault(fieldName, 1.0)
if boost > maxBoost:
maxBoost = boost
score *= maxBoost
if entry.docId notin docScores:
docScores[entry.docId] = 0.0
docHighlights[entry.docId] = @[]
docScores[entry.docId] += score
if entry.positions.len > 0:
for pos in entry.positions:
docHighlights[entry.docId].add((pos, pos + token.len))
var results: seq[SearchResult] = @[]
for docId, score in docScores:
results.add(SearchResult(
docId: docId,
score: score,
highlights: docHighlights.getOrDefault(docId, @[]),
))
results.sort(proc(a, b: SearchResult): int = cmp(b.score, a.score))
if results.len > limit:
results = results[0..<limit]
return results
finally:
release(idx.lock)
proc compact*(idx: SegmentIndex) =
acquire(idx.lock)
try:
if idx.segments.len <= 1:
for seg in idx.segments:
if seg.deleted.len > 0:
for docId in seg.deleted:
seg.docLengths.del(docId)
seg.docFields.del(docId)
seg.docFieldTerms.del(docId)
for term, postings in seg.postings.mpairs:
var filtered: seq[PostingEntry] = @[]
for entry in postings:
if entry.docId != docId:
filtered.add(entry)
postings = filtered
seg.deleted = initHashSet[uint64]()
seg.docCount = seg.docLengths.len
seg.totalTerms = 0
for dl in seg.docLengths.values:
seg.totalTerms += dl
if seg.docCount > 0:
seg.avgDocLen = float64(seg.totalTerms) / float64(seg.docCount)
return
let merged = newSegment(idx.nextSegmentId)
inc idx.nextSegmentId
for seg in idx.segments:
for docId, docLen in seg.docLengths:
if docId in seg.deleted:
continue
merged.docLengths[docId] = docLen
inc merged.docCount
merged.totalTerms += docLen
if docId in seg.docFields:
merged.docFields[docId] = seg.docFields[docId]
if docId in seg.docFieldTerms:
merged.docFieldTerms[docId] = seg.docFieldTerms[docId]
for term, postings in seg.postings:
if term notin merged.postings:
merged.postings[term] = @[]
for entry in postings:
if entry.docId notin seg.deleted:
merged.postings[term].add(entry)
if merged.docCount > 0:
merged.avgDocLen = float64(merged.totalTerms) / float64(merged.docCount)
idx.segments = @[merged]
finally:
release(idx.lock)
+289
View File
@@ -0,0 +1,289 @@
import std/tables
import std/sets
import std/strutils
import std/algorithm
import std/locks
import std/math
type
PostingEntry* = object
docId*: uint64
termFreq*: int
positions*: seq[int]
SearchResult* = object
docId*: uint64
score*: float64
highlights*: seq[(int, int)]
NGramIndex* = ref object
n*: int
ngramToTerms*: Table[string, HashSet[string]]
termFreqs*: Table[string, int]
lock*: Lock
FuzzyCandidate* = object
term*: string
distance*: int
score*: float64
proc levenshtein(a, b: string): int =
let m = a.len
let n = b.len
if m == 0: return n
if n == 0: return m
var prev = newSeq[int](n + 1)
var curr = newSeq[int](n + 1)
for j in 0..n:
prev[j] = j
for i in 1..m:
curr[0] = i
for j in 1..n:
let cost = if a[i - 1] == b[j - 1]: 0 else: 1
curr[j] = min(prev[j] + 1, min(curr[j - 1] + 1, prev[j - 1] + cost))
swap(prev, curr)
result = prev[n]
proc generateNgrams(s: string, n: int): seq[string] =
result = @[]
if s.len < n:
result.add(s)
return
for i in 0..(s.len - n):
result.add(s[i..<(i + n)])
proc newNGramIndex*(n: int = 3): NGramIndex =
result = NGramIndex(
n: n,
ngramToTerms: initTable[string, HashSet[string]](),
termFreqs: initTable[string, int](),
)
initLock(result.lock)
proc addTerm*(idx: NGramIndex, term: string, freq: int = 1) =
acquire(idx.lock)
try:
if term in idx.termFreqs:
idx.termFreqs[term] += freq
else:
idx.termFreqs[term] = freq
let ngrams = generateNgrams(term, idx.n)
for ng in ngrams:
if ng notin idx.ngramToTerms:
idx.ngramToTerms[ng] = initHashSet[string]()
idx.ngramToTerms[ng].incl(term)
finally:
release(idx.lock)
proc removeTerm*(idx: NGramIndex, term: string) =
acquire(idx.lock)
try:
if term notin idx.termFreqs:
return
idx.termFreqs.del(term)
let ngrams = generateNgrams(term, idx.n)
for ng in ngrams:
if ng in idx.ngramToTerms:
idx.ngramToTerms[ng].excl(term)
if idx.ngramToTerms[ng].len == 0:
idx.ngramToTerms.del(ng)
finally:
release(idx.lock)
proc buildFromSegment*(idx: NGramIndex, postings: Table[string, seq[PostingEntry]]) =
acquire(idx.lock)
try:
idx.ngramToTerms.clear()
idx.termFreqs.clear()
for term, entries in postings:
var totalFreq = 0
for e in entries:
totalFreq += e.termFreq
idx.termFreqs[term] = totalFreq
let ngrams = generateNgrams(term, idx.n)
for ng in ngrams:
if ng notin idx.ngramToTerms:
idx.ngramToTerms[ng] = initHashSet[string]()
idx.ngramToTerms[ng].incl(term)
finally:
release(idx.lock)
proc fuzzyCandidates*(idx: NGramIndex, query: string, maxDistance: int = 2): seq[FuzzyCandidate] =
acquire(idx.lock)
try:
result = @[]
if query.len == 0:
return
let queryNgrams = generateNgrams(query, idx.n)
if queryNgrams.len == 0:
return
var candidateCounts = initTable[string, int]()
for ng in queryNgrams:
if ng in idx.ngramToTerms:
for term in idx.ngramToTerms[ng]:
if term notin candidateCounts:
candidateCounts[term] = 0
candidateCounts[term] += 1
let queryNgramCount = queryNgrams.len
var candidates: seq[FuzzyCandidate] = @[]
for term, overlap in candidateCounts:
let termNgramCount = max(term.len - idx.n + 1, 1)
let unionSize = queryNgramCount + termNgramCount - overlap
if unionSize == 0:
continue
let jaccard = float64(overlap) / float64(unionSize)
let lenDiff = abs(term.len - query.len)
if lenDiff > maxDistance:
continue
if jaccard < 0.1:
continue
let dist = levenshtein(query, term)
if dist <= maxDistance:
let simScore = 1.0 - float64(dist) / float64(max(query.len, term.len))
let freq = idx.termFreqs.getOrDefault(term, 1)
let score = simScore * ln(float64(freq) + 1.0)
candidates.add(FuzzyCandidate(term: term, distance: dist, score: score))
candidates.sort(proc(a, b: FuzzyCandidate): int =
if a.distance != b.distance:
return cmp(a.distance, b.distance)
return cmp(b.score, a.score)
)
result = candidates
finally:
release(idx.lock)
proc fuzzySearchFast*(idx: NGramIndex, docPostings: Table[string, seq[PostingEntry]],
query: string, maxDistance: int = 2, limit: int = 10): seq[SearchResult] =
let candidates = idx.fuzzyCandidates(query, maxDistance)
if candidates.len == 0:
return @[]
var docScores = initTable[uint64, float64]()
for cand in candidates:
if cand.term notin docPostings:
continue
for entry in docPostings[cand.term]:
if entry.docId notin docScores:
docScores[entry.docId] = 0.0
docScores[entry.docId] += cand.score * float64(entry.termFreq)
result = @[]
for docId, score in docScores:
result.add(SearchResult(docId: docId, score: score, highlights: @[]))
result.sort(proc(a, b: SearchResult): int = cmp(b.score, a.score))
if result.len > limit:
result = result[0..<limit]
proc prefixSearch*(idx: NGramIndex, prefix: string, limit: int = 10): seq[FuzzyCandidate] =
acquire(idx.lock)
try:
result = @[]
if prefix.len == 0:
return
var matched = initHashSet[string]()
if prefix.len >= idx.n:
let prefixNgrams = generateNgrams(prefix, idx.n)
if prefixNgrams.len > 0:
let firstNg = prefixNgrams[0]
if firstNg in idx.ngramToTerms:
for term in idx.ngramToTerms[firstNg]:
if term.startsWith(prefix):
matched.incl(term)
else:
for term in idx.termFreqs.keys:
if term.startsWith(prefix):
matched.incl(term)
var candidates: seq[FuzzyCandidate] = @[]
for term in matched:
let freq = idx.termFreqs.getOrDefault(term, 1)
let score = ln(float64(freq) + 1.0)
candidates.add(FuzzyCandidate(term: term, distance: 0, score: score))
candidates.sort(proc(a, b: FuzzyCandidate): int = cmp(b.score, a.score))
if candidates.len > limit:
candidates = candidates[0..<limit]
result = candidates
finally:
release(idx.lock)
proc wildcardMatch(term: string, pattern: string): bool =
let parts = pattern.split('*')
if parts.len == 1:
return term == pattern
var pos = 0
if parts[0].len > 0:
if not term.startsWith(parts[0]):
return false
pos = parts[0].len
for i in 1..<(parts.len - 1):
let part = parts[i]
if part.len == 0:
continue
let found = term.find(part, pos)
if found < 0:
return false
pos = found + part.len
let last = parts[^1]
if last.len > 0:
if not term.endsWith(last):
return false
let endStart = term.len - last.len
if endStart < pos:
return false
return true
proc wildcardSearch*(idx: NGramIndex, pattern: string, limit: int = 10): seq[FuzzyCandidate] =
acquire(idx.lock)
try:
result = @[]
if pattern.len == 0:
return
let parts = pattern.split('*')
var fixedPart = ""
for p in parts:
if p.len > fixedPart.len:
fixedPart = p
var candidates: seq[FuzzyCandidate] = @[]
if fixedPart.len >= idx.n:
let fixedNgrams = generateNgrams(fixedPart, idx.n)
var termCandidates = initHashSet[string]()
if fixedNgrams.len > 0:
let firstNg = fixedNgrams[0]
if firstNg in idx.ngramToTerms:
for term in idx.ngramToTerms[firstNg]:
termCandidates.incl(term)
for term in termCandidates:
if wildcardMatch(term, pattern):
let freq = idx.termFreqs.getOrDefault(term, 1)
let score = ln(float64(freq) + 1.0)
candidates.add(FuzzyCandidate(term: term, distance: 0, score: score))
else:
for term in idx.termFreqs.keys:
if wildcardMatch(term, pattern):
let freq = idx.termFreqs.getOrDefault(term, 1)
let score = ln(float64(freq) + 1.0)
candidates.add(FuzzyCandidate(term: term, distance: 0, score: score))
candidates.sort(proc(a, b: FuzzyCandidate): int = cmp(b.score, a.score))
if candidates.len > limit:
candidates = candidates[0..<limit]
result = candidates
finally:
release(idx.lock)
+252
View File
@@ -0,0 +1,252 @@
import std/tables
import std/sets
import std/algorithm
import std/math
import std/locks
from ../fts/engine import PostingEntry
import ../fts/multilang
import inverted
type
PhraseQuery* = object
terms*: seq[string]
slop*: int
proc gatherPostings(idx: SegmentIndex, term: string): Table[uint64, seq[int]] =
result = initTable[uint64, seq[int]]()
for seg in idx.segments:
if term notin seg.postings:
continue
for entry in seg.postings[term]:
if entry.docId in seg.deleted:
continue
if entry.docId notin result:
result[entry.docId] = @[]
result[entry.docId].add(entry.positions)
proc checkPhraseMatch(positions: seq[seq[int]], slop: int): bool =
if positions.len == 0:
return false
if positions.len == 1:
return positions[0].len > 0
for startPos in positions[0]:
var matched = true
var prevPos = startPos
for i in 1..<positions.len:
var found = false
for candidatePos in positions[i]:
let gap = candidatePos - prevPos
if gap >= 1 and gap <= 1 + slop:
prevPos = candidatePos
found = true
break
elif candidatePos > prevPos + 1 + slop:
break
if not found:
matched = false
break
if matched:
return true
return false
proc minProximityWindow(positions: seq[seq[int]]): int =
if positions.len == 0:
return int.high
for posList in positions:
if posList.len == 0:
return int.high
var pointers = newSeq[int](positions.len)
var bestWindow = int.high
while true:
var lo = int.high
var hi = int.low
for i in 0..<positions.len:
let p = positions[i][pointers[i]]
if p < lo: lo = p
if p > hi: hi = p
let window = hi - lo
if window < bestWindow:
bestWindow = window
var minIdx = 0
for i in 1..<positions.len:
if positions[i][pointers[i]] < positions[minIdx][pointers[minIdx]]:
minIdx = i
inc pointers[minIdx]
if pointers[minIdx] >= positions[minIdx].len:
break
return bestWindow
proc phraseSearch*(idx: SegmentIndex, query: PhraseQuery,
limit: int = 10): seq[SearchResult] =
acquire(idx.lock)
try:
if query.terms.len == 0:
return @[]
var queryTerms: seq[string] = @[]
for term in query.terms:
let tokenized = tokenize(term, idx.langConfig)
for t in tokenized:
queryTerms.add(t)
if queryTerms.len == 0:
return @[]
var perTermPostings: seq[Table[uint64, seq[int]]] = @[]
for term in queryTerms:
perTermPostings.add(gatherPostings(idx, term))
var candidateDocs = initHashSet[uint64]()
if perTermPostings.len > 0:
for docId in perTermPostings[0].keys:
candidateDocs.incl(docId)
for i in 1..<perTermPostings.len:
var intersection = initHashSet[uint64]()
for docId in candidateDocs:
if docId in perTermPostings[i]:
intersection.incl(docId)
candidateDocs = intersection
var results: seq[SearchResult] = @[]
let phraseBonus = 2.0
for docId in candidateDocs:
var positions: seq[seq[int]] = @[]
for i in 0..<perTermPostings.len:
var sorted = perTermPostings[i][docId]
sorted.sort()
positions.add(sorted)
if not checkPhraseMatch(positions, query.slop):
continue
var score = 0.0
for seg in idx.segments:
if docId in seg.deleted:
continue
for term in queryTerms:
if term notin seg.postings:
continue
for entry in seg.postings[term]:
if entry.docId == docId:
let df = seg.postings[term].len
let n = seg.docCount
if df > 0 and n > 0:
let idf = ln((float64(n) - float64(df) + 0.5) /
(float64(df) + 0.5) + 1.0)
let docLen = float64(seg.docLengths.getOrDefault(docId, 0))
let tfNorm = (float64(entry.termFreq) * (1.2 + 1.0)) /
(float64(entry.termFreq) +
1.2 * (1.0 - 0.75 + 0.75 * docLen / seg.avgDocLen))
score += idf * tfNorm
break
score *= phraseBonus
var highlights: seq[(int, int)] = @[]
if positions.len > 0 and positions[0].len > 0:
let start = positions[0][0]
let endPos = positions[^1][^1]
highlights.add((start, endPos + 1))
results.add(SearchResult(
docId: docId,
score: score,
highlights: highlights,
))
results.sort(proc(a, b: SearchResult): int = cmp(b.score, a.score))
if results.len > limit:
results = results[0..<limit]
return results
finally:
release(idx.lock)
proc proximitySearch*(idx: SegmentIndex, terms: seq[string], maxDistance: int,
limit: int = 10): seq[SearchResult] =
acquire(idx.lock)
try:
if terms.len == 0:
return @[]
var queryTerms: seq[string] = @[]
for term in terms:
let tokenized = tokenize(term, idx.langConfig)
for t in tokenized:
queryTerms.add(t)
if queryTerms.len == 0:
return @[]
var perTermPostings: seq[Table[uint64, seq[int]]] = @[]
for term in queryTerms:
perTermPostings.add(gatherPostings(idx, term))
var candidateDocs = initHashSet[uint64]()
if perTermPostings.len > 0:
for docId in perTermPostings[0].keys:
candidateDocs.incl(docId)
for i in 1..<perTermPostings.len:
var intersection = initHashSet[uint64]()
for docId in candidateDocs:
if docId in perTermPostings[i]:
intersection.incl(docId)
candidateDocs = intersection
var results: seq[SearchResult] = @[]
for docId in candidateDocs:
var positions: seq[seq[int]] = @[]
for i in 0..<perTermPostings.len:
var sorted = perTermPostings[i][docId]
sorted.sort()
positions.add(sorted)
let window = minProximityWindow(positions)
if window > maxDistance:
continue
var score = 0.0
for seg in idx.segments:
if docId in seg.deleted:
continue
for term in queryTerms:
if term notin seg.postings:
continue
for entry in seg.postings[term]:
if entry.docId == docId:
let df = seg.postings[term].len
let n = seg.docCount
if df > 0 and n > 0:
let idf = ln((float64(n) - float64(df) + 0.5) /
(float64(df) + 0.5) + 1.0)
let docLen = float64(seg.docLengths.getOrDefault(docId, 0))
let tfNorm = (float64(entry.termFreq) * (1.2 + 1.0)) /
(float64(entry.termFreq) +
1.2 * (1.0 - 0.75 + 0.75 * docLen / seg.avgDocLen))
score += idf * tfNorm
break
let proximityBonus = float64(maxDistance) / float64(max(window, 1))
score *= proximityBonus
results.add(SearchResult(
docId: docId,
score: score,
highlights: @[],
))
results.sort(proc(a, b: SearchResult): int = cmp(b.score, a.score))
if results.len > limit:
results = results[0..<limit]
return results
finally:
release(idx.lock)
+73
View File
@@ -0,0 +1,73 @@
type
HeapEntry*[K, V] = object
key*: K
value*: V
BoundedHeap*[K, V] = ref object
data: seq[HeapEntry[K, V]]
cap: int
less: proc(a, b: K): bool {.gcsafe.}
proc newBoundedHeap*[K, V](maxCapacity: int = 0,
less: proc(a, b: K): bool {.gcsafe.}): BoundedHeap[K, V] =
BoundedHeap[K, V](data: newSeqOfCap[HeapEntry[K, V]](min(maxCapacity, 4096)),
cap: maxCapacity, less: less)
proc len*[K, V](h: BoundedHeap[K, V]): int = h.data.len
proc isEmpty*[K, V](h: BoundedHeap[K, V]): bool = h.data.len == 0
proc peek*[K, V](h: BoundedHeap[K, V]): HeapEntry[K, V] = h.data[0]
proc siftUp[K, V](h: BoundedHeap[K, V], i: int) =
var idx = i
while idx > 0:
let parent = (idx - 1) div 2
if h.less(h.data[idx].key, h.data[parent].key):
swap(h.data[idx], h.data[parent])
idx = parent
else:
break
proc siftDown[K, V](h: BoundedHeap[K, V], i: int) =
var idx = i
let n = h.data.len
while true:
var best = idx
let left = 2 * idx + 1
let right = 2 * idx + 2
if left < n and h.less(h.data[left].key, h.data[best].key):
best = left
if right < n and h.less(h.data[right].key, h.data[best].key):
best = right
if best == idx:
break
swap(h.data[idx], h.data[best])
idx = best
proc push*[K, V](h: BoundedHeap[K, V], key: K, value: V) =
if h.cap > 0 and h.data.len == h.cap:
if h.less(h.data[0].key, key):
h.data[0] = HeapEntry[K, V](key: key, value: value)
h.siftDown(0)
else:
h.data.add(HeapEntry[K, V](key: key, value: value))
h.siftUp(h.data.len - 1)
proc pop*[K, V](h: BoundedHeap[K, V]): HeapEntry[K, V] =
result = h.data[0]
let last = h.data.len - 1
if last > 0:
h.data[0] = h.data[last]
h.data.setLen(last)
h.siftDown(0)
else:
h.data.setLen(0)
proc toSortedSeq*[K, V](h: BoundedHeap[K, V]): seq[HeapEntry[K, V]] =
var copy = BoundedHeap[K, V](data: @h.data, cap: h.cap, less: h.less)
result = newSeqOfCap[HeapEntry[K, V]](copy.len)
while not copy.isEmpty:
result.add(copy.pop())
proc items*[K, V](h: BoundedHeap[K, V]): seq[HeapEntry[K, V]] = h.data
+840
View File
@@ -0,0 +1,840 @@
import std/unicode
import std/strutils
import ../fts/multilang
type
Stemmer2* = proc(word: string): string {.gcsafe.}
# --- English Porter2 ---
const englishVowels = {'a', 'e', 'i', 'o', 'u', 'y'}
proc isVowelEn(c: char): bool = c in englishVowels
proc findR1R2(word: string): (int, int) =
var r1 = word.len
var r2 = word.len
for i in 1..<word.len:
if not isVowelEn(word[i]) and isVowelEn(word[i - 1]):
r1 = i + 1
break
if r1 < word.len:
for i in (r1 + 1)..<word.len:
if not isVowelEn(word[i]) and isVowelEn(word[i - 1]):
r2 = i + 1
break
if word.len >= 5 and word.startsWith("gener"):
r1 = 5
elif word.len >= 6 and word.startsWith("commun"):
r1 = 6
elif word.len >= 5 and word.startsWith("arsen"):
r1 = 5
return (r1, r2)
proc containsVowelEn(s: string): bool =
for c in s:
if isVowelEn(c): return true
return false
proc endsWithDouble(s: string): bool =
if s.len < 2: return false
let c = s[^1]
if s[^2] != c: return false
return c in {'b', 'd', 'f', 'g', 'm', 'n', 'p', 'r', 't'}
proc endsWithShortSyllable(s: string): bool =
if s.len >= 3:
let a = s[^3]
let b = s[^2]
let c = s[^1]
if not isVowelEn(a) and isVowelEn(b) and not isVowelEn(c) and c != 'w' and c != 'x' and c != 'Y':
return true
if s.len == 2:
if isVowelEn(s[0]) and not isVowelEn(s[1]):
return true
return false
proc isShortWord(s: string, r1: int): bool =
endsWithShortSyllable(s) and r1 >= s.len
proc stemEnglish2*(word: string): string =
if word.len <= 2: return word
var w = word.toLower()
if w[0] == '\'': w = w[1..^1]
if w.len <= 2: return w
# Set initial Y after vowel to Y
var buf = ""
buf.add(w[0])
for i in 1..<w.len:
if w[i] == 'y' and isVowelEn(w[i - 1]):
buf.add('Y')
else:
buf.add(w[i])
w = buf
let (r1init, r2init) = findR1R2(w)
var r1 = r1init
var r2 = r2init
# Step 0
if w.endsWith("'s'"): w = w[0..^4]
elif w.endsWith("'s"): w = w[0..^3]
elif w.endsWith("'"): w = w[0..^2]
# Step 1a
if w.endsWith("sses"):
w = w[0..^3]
elif w.endsWith("ied") or w.endsWith("ies"):
if w.len > 4:
w = w[0..^3] & "i"
else:
w = w[0..^2] & "ie"
elif w.endsWith("us") or w.endsWith("ss"):
discard
elif w.endsWith("s"):
if w.len > 2 and containsVowelEn(w[0..^3]):
w = w[0..^2]
# Step 1b
var step1bExtra = false
if w.endsWith("eedly"):
if w.len - 5 >= r1:
w = w[0..^4] & "ee"
elif w.endsWith("eed"):
if w.len - 3 >= r1:
w = w[0..^2] & "ee"
else:
var found = false
let suffixes1b = ["ingly", "edly", "ing", "ed"]
for suf in suffixes1b:
if w.endsWith(suf):
let stem = w[0..^(suf.len + 1)]
if containsVowelEn(stem):
w = stem
found = true
break
if found:
if w.endsWith("at") or w.endsWith("bl") or w.endsWith("iz"):
w = w & "e"
elif endsWithDouble(w):
w = w[0..^2]
elif isShortWord(w, r1):
w = w & "e"
step1bExtra = true
# Step 1c
if not step1bExtra and w.len > 2:
let lastChar = w[^1]
if (lastChar == 'y' or lastChar == 'Y') and not isVowelEn(w[^2]):
w = w[0..^2] & "i"
# Step 2
let step2Pairs = [
("ational", "ate"), ("tional", "tion"), ("enci", "ence"),
("anci", "ance"), ("abli", "able"), ("entli", "ent"),
("ization", "ize"), ("izer", "ize"), ("ation", "ate"),
("ator", "ate"), ("alism", "al"), ("aliti", "al"),
("alli", "al"), ("fulness", "ful"), ("ousli", "ous"),
("ousness", "ous"), ("iveness", "ive"), ("iviti", "ive"),
("biliti", "ble"), ("bli", "ble"), ("fulli", "ful"),
("lessli", "less"), ("logi", "log"),
]
block step2:
for (suf, repl) in step2Pairs:
if w.endsWith(suf):
if w.len - suf.len >= r1:
w = w[0..^(suf.len + 1)] & repl
break step2
if w.endsWith("li"):
if w.len >= 3 and w.len - 2 >= r1:
let preceding = w[^3]
if preceding in {'c', 'd', 'e', 'g', 'h', 'k', 'm', 'n', 'r', 't'}:
w = w[0..^3]
# Recompute R1/R2 after modifications
let (r1b, r2b) = findR1R2(w)
r1 = r1b
r2 = r2b
# Step 3
let step3Pairs = [
("ational", "ate"), ("tional", "tion"), ("alize", "al"),
("icate", "ic"), ("iciti", "ic"), ("ical", "ic"),
("ness", ""), ("ful", ""),
]
block step3:
for (suf, repl) in step3Pairs:
if w.endsWith(suf):
if w.len - suf.len >= r1:
w = w[0..^(suf.len + 1)] & repl
break step3
if w.endsWith("ative"):
if w.len - 5 >= r2:
w = w[0..^6]
let (r1c, r2c) = findR1R2(w)
r1 = r1c
r2 = r2c
# Step 4
let step4Suffixes = [
"ement", "ance", "ence", "able", "ible", "ment",
"ant", "ent", "ion", "ism", "ate", "iti",
"ous", "ive", "ize", "al", "er", "ic",
]
block step4:
for suf in step4Suffixes:
if w.endsWith(suf):
if suf == "ion":
if w.len - 3 >= r2 and w.len >= 4:
let preceding = w[^(suf.len + 1)]
if preceding == 's' or preceding == 't':
w = w[0..^(suf.len + 1)]
else:
if w.len - suf.len >= r2:
w = w[0..^(suf.len + 1)]
break step4
# Step 5
let (r1d, r2d) = findR1R2(w)
r1 = r1d
r2 = r2d
if w.endsWith("e"):
if w.len - 1 >= r2:
w = w[0..^2]
elif w.len - 1 >= r1 and not endsWithShortSyllable(w[0..^2]):
w = w[0..^2]
elif w.endsWith("l"):
if w.len >= 2 and w[^2] == 'l' and w.len - 1 >= r2:
w = w[0..^2]
# Restore any Y back to y
result = ""
for c in w:
if c == 'Y': result.add('y')
else: result.add(c)
# --- Bulgarian Porter2 ---
proc toRunes(s: string): seq[Rune] =
result = @[]
for r in s.runes:
result.add(r)
proc `$`(runes: seq[Rune]): string =
result = ""
for r in runes:
result.add(r)
proc endsWithRune(word: seq[Rune], suffix: seq[Rune]): bool =
if suffix.len > word.len: return false
let offset = word.len - suffix.len
for i in 0..<suffix.len:
if word[offset + i] != suffix[i]: return false
return true
proc removeSuffixRune(word: seq[Rune], sufLen: int): seq[Rune] =
if sufLen >= word.len: return @[]
result = word[0..^(sufLen + 1)]
proc stemBulgarian2*(word: string): string =
let w = word.toLower()
var runes = toRunes(w)
if runes.len <= 2: return w
let verbEndings = [
("охме", 4), ("яхме", 4), ("ахте", 4), ("яхте", 4),
("ахме", 4),
("ах", 2), ("ях", 2),
("а", 1), ("я", 1), ("е", 1), ("и", 1), ("у", 1),
]
let adjEndings = [
("ият", 3), ("ото", 3), ("ата", 3), ("ите", 3),
("ия", 2), ("ен", 2), ("на", 2), ("но", 2), ("ни", 2),
("то", 2), ("та", 2), ("те", 2),
]
let nounSuffixes = [
("иям", 3), ("ием", 3), ("иях", 3),
("ами", 3), ("ями", 3),
("ом", 2), ("ем", 2), ("ах", 2),
("а", 1), ("я", 1), ("о", 1), ("и", 1), ("е", 1),
("у", 1), ("ю", 1), ("ъ", 1),
]
let derivational = [
("ища", 3), ("ище", 3), ("ция", 3), ("ние", 3),
("ост", 3), ("ски", 3), ("ство", 4),
("ент", 3), ("ант", 3), ("ист", 3),
]
for (suf, slen) in derivational:
let sufRunes = toRunes(suf)
if runes.endsWithRune(sufRunes) and runes.len > slen + 2:
runes = removeSuffixRune(runes, slen)
return $runes
for (suf, slen) in adjEndings:
let sufRunes = toRunes(suf)
if runes.endsWithRune(sufRunes) and runes.len > slen + 2:
runes = removeSuffixRune(runes, slen)
return $runes
for (suf, slen) in verbEndings:
let sufRunes = toRunes(suf)
if runes.endsWithRune(sufRunes) and runes.len > slen + 1:
runes = removeSuffixRune(runes, slen)
return $runes
for (suf, slen) in nounSuffixes:
let sufRunes = toRunes(suf)
if runes.endsWithRune(sufRunes) and runes.len > slen + 1:
runes = removeSuffixRune(runes, slen)
return $runes
result = $runes
# --- German Porter2 ---
const germanVowels = {'a', 'e', 'i', 'o', 'u', 'y'}
proc isVowelDe(c: char): bool = c in germanVowels
proc findR1R2De(word: string): (int, int) =
var r1 = word.len
var r2 = word.len
for i in 1..<word.len:
if not isVowelDe(word[i]) and isVowelDe(word[i - 1]):
r1 = i + 1
break
if r1 < 3: r1 = 3
if r1 < word.len:
for i in (r1 + 1)..<word.len:
if not isVowelDe(word[i]) and isVowelDe(word[i - 1]):
r2 = i + 1
break
return (r1, r2)
proc isValidSEnding(c: char): bool =
c in {'b', 'd', 'f', 'g', 'h', 'k', 'l', 'm', 'n', 'r', 't'}
proc isValidStEnding(c: char): bool =
c in {'b', 'd', 'f', 'g', 'h', 'k', 'l', 'm', 'n', 'r', 't'}
proc stemGerman2*(word: string): string =
if word.len <= 2: return word
var w = word.toLower()
# Normalize umlauts
var buf = ""
for r in w.runes:
case r
of Rune(0x00E4): buf.add('a') # ä
of Rune(0x00F6): buf.add('o') # ö
of Rune(0x00FC): buf.add('u') # ü
of Rune(0x00DF): buf.add("ss") # ß
else: buf.add(r)
w = buf
# Replace U after vowel with u, Y after vowel with y
var buf2 = ""
buf2.add(w[0])
for i in 1..<w.len:
if w[i] == 'u' and isVowelDe(w[i - 1]):
buf2.add('U')
elif w[i] == 'y' and isVowelDe(w[i - 1]):
buf2.add('Y')
else:
buf2.add(w[i])
w = buf2
let (r1init, r2init) = findR1R2De(w)
var r1 = r1init
var r2 = r2init
# Step 1
if w.endsWith("ern") and w.len - 3 >= r1:
w = w[0..^4]
elif w.endsWith("em") and w.len - 2 >= r1:
w = w[0..^3]
elif w.endsWith("er") and w.len - 2 >= r1:
w = w[0..^3]
elif w.endsWith("e") and w.len - 1 >= r1:
w = w[0..^2]
elif w.endsWith("en") and w.len - 2 >= r1:
w = w[0..^3]
elif w.endsWith("es") and w.len - 2 >= r1:
w = w[0..^3]
elif w.endsWith("s"):
if w.len >= 3 and w.len - 1 >= r1 and isValidSEnding(w[^2]):
w = w[0..^2]
let (r1b, r2b) = findR1R2De(w)
r1 = r1b
r2 = r2b
# Step 2
if w.endsWith("est") and w.len - 3 >= r1:
w = w[0..^4]
elif w.endsWith("en") and w.len - 2 >= r1:
w = w[0..^3]
elif w.endsWith("er") and w.len - 2 >= r1:
w = w[0..^3]
elif w.endsWith("st"):
if w.len >= 4 and w.len - 2 >= r1 and isValidStEnding(w[^3]):
w = w[0..^3]
let (r1c, r2c) = findR1R2De(w)
r1 = r1c
r2 = r2c
# Step 3
block step3:
if w.endsWith("keit") and w.len - 4 >= r2:
w = w[0..^5]
break step3
if w.endsWith("heit") and w.len - 4 >= r2:
w = w[0..^5]
break step3
if w.endsWith("lich") and w.len - 4 >= r2:
w = w[0..^5]
break step3
if w.endsWith("isch") and w.len - 4 >= r2:
w = w[0..^5]
break step3
if w.endsWith("ung") and w.len - 3 >= r2:
w = w[0..^4]
break step3
if w.endsWith("end") and w.len - 3 >= r2:
w = w[0..^4]
break step3
if w.endsWith("ig") and w.len - 2 >= r2:
w = w[0..^3]
break step3
if w.endsWith("ik") and w.len - 2 >= r2:
w = w[0..^3]
break step3
# Restore U/Y
result = ""
for c in w:
if c == 'U': result.add('u')
elif c == 'Y': result.add('y')
else: result.add(c)
# --- French Porter2 ---
const frenchVowels = {'a', 'e', 'i', 'o', 'u', 'y'}
proc isVowelFr(c: char): bool = c in frenchVowels
proc findR1R2Fr(word: string): (int, int) =
var r1 = word.len
var r2 = word.len
for i in 1..<word.len:
if not isVowelFr(word[i]) and isVowelFr(word[i - 1]):
r1 = i + 1
break
if r1 < word.len:
for i in (r1 + 1)..<word.len:
if not isVowelFr(word[i]) and isVowelFr(word[i - 1]):
r2 = i + 1
break
return (r1, r2)
proc containsVowelFr(s: string): bool =
for c in s:
if isVowelFr(c): return true
return false
proc stemFrench2*(word: string): string =
if word.len <= 2: return word
var w = word.toLower()
# Normalize accented characters to base + track positions
var buf = ""
for r in w.runes:
case r
of Rune(0x00E9), Rune(0x00E8), Rune(0x00EA), Rune(0x00EB):
buf.add('e')
of Rune(0x00E0), Rune(0x00E2):
buf.add('a')
of Rune(0x00F9), Rune(0x00FB):
buf.add('u')
of Rune(0x00EE), Rune(0x00EF):
buf.add('i')
of Rune(0x00F4):
buf.add('o')
of Rune(0x00E7):
buf.add('c')
of Rune(0x00E6):
buf.add("ae")
of Rune(0x0153):
buf.add("oe")
else:
buf.add(r)
w = buf
let (r1init, r2init) = findR1R2Fr(w)
var r1 = r1init
var r2 = r2init
# Step 1: Remove standard suffixes
block step1:
# -issement / -issant
if w.endsWith("issement"):
if w.len - 8 >= r1 and containsVowelFr(w[0..^(8 + 1)]):
w = w[0..^9]
break step1
if w.endsWith("issant"):
if w.len - 6 >= r1 and containsVowelFr(w[0..^(6 + 1)]):
w = w[0..^7]
break step1
# -ation / -ateur / -ateurs
if w.endsWith("ateurs") and w.len - 6 >= r2:
w = w[0..^7]
break step1
if w.endsWith("ateur") and w.len - 5 >= r2:
w = w[0..^6]
break step1
if w.endsWith("ations") and w.len - 6 >= r2:
w = w[0..^7]
break step1
if w.endsWith("ation") and w.len - 5 >= r2:
w = w[0..^6]
break step1
# -ement / -ements
if w.endsWith("ements") and w.len - 6 >= r1:
w = w[0..^7]
break step1
if w.endsWith("ement") and w.len - 5 >= r1:
w = w[0..^6]
break step1
# -ment
if w.endsWith("ment") and w.len - 4 >= r1:
let stem = w[0..^(4 + 1)]
if stem.len > 0 and isVowelFr(stem[^1]):
w = stem
break step1
# -ité / -ités
if w.endsWith("ites") and w.len - 4 >= r2:
w = w[0..^5]
break step1
if w.endsWith("ite") and w.len - 3 >= r2:
w = w[0..^4]
break step1
# -ible / -ibles
if w.endsWith("ibles") and w.len - 5 >= r2:
w = w[0..^6]
break step1
if w.endsWith("ible") and w.len - 4 >= r2:
w = w[0..^5]
break step1
# -iste / -isme
if w.endsWith("istes") and w.len - 5 >= r2:
w = w[0..^6]
break step1
if w.endsWith("iste") and w.len - 4 >= r2:
w = w[0..^5]
break step1
if w.endsWith("ismes") and w.len - 5 >= r2:
w = w[0..^6]
break step1
if w.endsWith("isme") and w.len - 4 >= r2:
w = w[0..^5]
break step1
# -eux
if w.endsWith("eux") and w.len - 3 >= r1:
w = w[0..^4]
break step1
# -if / -ive / -ifs / -ives
if w.endsWith("ives") and w.len - 4 >= r2:
w = w[0..^5]
break step1
if w.endsWith("ive") and w.len - 3 >= r2:
w = w[0..^4]
break step1
if w.endsWith("ifs") and w.len - 3 >= r2:
w = w[0..^4]
break step1
if w.endsWith("if") and w.len - 2 >= r2:
w = w[0..^3]
break step1
# -ance / -ence
if w.endsWith("ances") and w.len - 5 >= r2:
w = w[0..^6]
break step1
if w.endsWith("ance") and w.len - 4 >= r2:
w = w[0..^5]
break step1
if w.endsWith("ences") and w.len - 5 >= r2:
w = w[0..^6]
break step1
if w.endsWith("ence") and w.len - 4 >= r2:
w = w[0..^5]
break step1
# -eur / -euse
if w.endsWith("euses") and w.len - 5 >= r2:
w = w[0..^6]
break step1
if w.endsWith("euse") and w.len - 4 >= r2:
w = w[0..^5]
break step1
if w.endsWith("eurs") and w.len - 4 >= r2:
w = w[0..^5]
break step1
if w.endsWith("eur") and w.len - 3 >= r2:
w = w[0..^4]
break step1
# -er / -ier
if w.endsWith("iers") and w.len - 4 >= r1:
w = w[0..^5]
break step1
if w.endsWith("ier") and w.len - 3 >= r1:
w = w[0..^4]
break step1
if w.endsWith("er") and w.len - 2 >= r1:
w = w[0..^3]
break step1
# -es / -e / -s
if w.endsWith("es") and w.len - 2 >= r1:
w = w[0..^3]
break step1
if w.endsWith("e") and w.len - 1 >= r1:
w = w[0..^2]
break step1
let (r1b, r2b) = findR1R2Fr(w)
r1 = r1b
r2 = r2b
# Step 2a: Residual suffix cleanup
if w.endsWith("ier"):
w = w[0..^3] & "i"
elif w.endsWith("i"):
discard
result = w
# --- Russian Porter2 ---
const
ruVowelCodes = [
Rune(0x0430), # а
Rune(0x0435), # е
Rune(0x0438), # и
Rune(0x043E), # о
Rune(0x0443), # у
Rune(0x044B), # ы
Rune(0x044D), # э
Rune(0x044E), # ю
Rune(0x044F), # я
]
proc isVowelRu(r: Rune): bool =
for v in ruVowelCodes:
if r == v: return true
return false
proc findR1R2Ru(runes: seq[Rune]): (int, int) =
var r1 = runes.len
var r2 = runes.len
for i in 1..<runes.len:
if not isVowelRu(runes[i]) and isVowelRu(runes[i - 1]):
r1 = i + 1
break
if r1 < runes.len:
for i in (r1 + 1)..<runes.len:
if not isVowelRu(runes[i]) and isVowelRu(runes[i - 1]):
r2 = i + 1
break
return (r1, r2)
proc ruEndsWith(word: seq[Rune], suffix: string): bool =
let sufRunes = toRunes(suffix)
if sufRunes.len > word.len: return false
let offset = word.len - sufRunes.len
for i in 0..<sufRunes.len:
if word[offset + i] != sufRunes[i]: return false
return true
proc ruRemove(word: seq[Rune], sufLen: int): seq[Rune] =
if sufLen >= word.len: return @[]
result = word[0..^(sufLen + 1)]
proc stemRussian2*(word: string): string =
let w = word.toLower()
var runes = toRunes(w)
if runes.len <= 2: return w
let (r1init, r2init) = findR1R2Ru(runes)
var r1 = r1init
var r2 = r2init
# PERFECTIVE GERUND group 1 (requires а/я before): -в, -вши, -вшись
# PERFECTIVE GERUND group 2 (no requirement): -ив, -ивши, -ившись, -ыв, -ывши, -ывшись
let perfG2 = ["ившись", "ывшись", "ивши", "ывши", "ив", "ыв"]
let perfG1 = ["вшись", "вши", "в"]
block perfGerund:
for suf in perfG2:
let sufRunes = toRunes(suf)
if runes.ruEndsWith(suf):
let pos = runes.len - sufRunes.len
if pos >= r1:
runes = ruRemove(runes, sufRunes.len)
break perfGerund
for suf in perfG1:
let sufRunes = toRunes(suf)
if runes.ruEndsWith(suf):
let pos = runes.len - sufRunes.len
if pos >= r1 and pos > 0:
let prevRune = runes[pos - 1]
if prevRune == Rune(0x0430) or prevRune == Rune(0x044F): # а or я
runes = ruRemove(runes, sufRunes.len)
break perfGerund
# REFLEXIVE: -ся, -сь
block reflexive:
for suf in ["ся", "сь"]:
let sufRunes = toRunes(suf)
if runes.ruEndsWith(suf):
runes = ruRemove(runes, sufRunes.len)
break reflexive
# ADJECTIVE endings (try longest first)
let adjEndings = [
"ими", "ыми", "его", "ого", "ему", "ому",
"их", "ых", "ую", "юю", "ая", "яя",
"ое", "ее", "ие", "ые",
]
var foundAdj = false
block adjBlock:
for suf in adjEndings:
let sufRunes = toRunes(suf)
if runes.ruEndsWith(suf):
let pos = runes.len - sufRunes.len
if pos >= r1:
runes = ruRemove(runes, sufRunes.len)
foundAdj = true
break adjBlock
# PARTICIPLE endings (if adjective was found, also remove participle)
if foundAdj:
let partG2 = ["ивш", "ывш", "ующ", "ющ"]
let partG1 = ["вш", "ем", "нн", "т", "ш"]
block participle:
for suf in partG2:
let sufRunes = toRunes(suf)
if runes.ruEndsWith(suf):
let pos = runes.len - sufRunes.len
if pos >= r1:
runes = ruRemove(runes, sufRunes.len)
break participle
for suf in partG1:
let sufRunes = toRunes(suf)
if runes.ruEndsWith(suf):
let pos = runes.len - sufRunes.len
if pos >= r1 and pos > 0:
let prevRune = runes[pos - 1]
if prevRune == Rune(0x0430) or prevRune == Rune(0x044F):
runes = ruRemove(runes, sufRunes.len)
break participle
else:
# VERB endings
let verbG2 = ["ить", "ыть", "ить"]
let verbG1 = ["ала", "яла", "ана", "ена", "ите", "или", "ыли",
"ует", "уют", "ит", "ыт", "ат", "ят", "ут",
"ила", "ыла", "ат", "ят", "ан", "ен",
"ай", "ей", "уй", "ла", "на", "ли",
"ем", "ло", "но", "ет", "ют",
"а", "я", "и", "у", "ю", "ь"]
block verbBlock:
for suf in verbG2:
let sufRunes = toRunes(suf)
if runes.ruEndsWith(suf):
let pos = runes.len - sufRunes.len
if pos >= r1:
runes = ruRemove(runes, sufRunes.len)
break verbBlock
for suf in verbG1:
let sufRunes = toRunes(suf)
if runes.ruEndsWith(suf):
let pos = runes.len - sufRunes.len
if pos >= r1 and pos > 0:
let prevRune = runes[pos - 1]
if prevRune == Rune(0x0430) or prevRune == Rune(0x044F):
runes = ruRemove(runes, sufRunes.len)
break verbBlock
# NOUN endings (only if no verb matched)
let nounEndings = [
"иям", "ием", "иях", "ами", "ями",
"ия", "ие", "ий", "ом", "ем", "ах",
"а", "я", "о", "и", "е", "у", "ю", "ы", "ь",
]
block nounBlock:
for suf in nounEndings:
let sufRunes = toRunes(suf)
if runes.ruEndsWith(suf):
let pos = runes.len - sufRunes.len
if pos >= r1:
runes = ruRemove(runes, sufRunes.len)
break nounBlock
# Remove superlative suffixes: -ейш, -ейше
block superlative:
for suf in ["ейше", "ейш"]:
let sufRunes = toRunes(suf)
if runes.ruEndsWith(suf):
let pos = runes.len - sufRunes.len
if pos >= r1:
runes = ruRemove(runes, sufRunes.len)
break superlative
# Remove derivational suffixes: -ост, -ость
block derivational:
for suf in ["ость", "ост"]:
let sufRunes = toRunes(suf)
if runes.ruEndsWith(suf):
let pos = runes.len - sufRunes.len
if pos >= r2:
runes = ruRemove(runes, sufRunes.len)
break derivational
# Remove trailing нн -> н
if runes.len >= 2:
if runes[^1] == Rune(0x043D) and runes[^2] == Rune(0x043D): # нн
runes = runes[0..^2]
result = $runes
# --- Unified interface ---
proc getStemmer2*(lang: Language): Stemmer2 =
case lang
of langEnglish: return stemEnglish2
of langBulgarian: return stemBulgarian2
of langGerman: return stemGerman2
of langFrench: return stemFrench2
of langRussian: return stemRussian2
else: return stemEnglish2
+1 -1
View File
@@ -50,7 +50,7 @@ type
metric*: DistanceMetric
dimensions*: int
NodeDist = tuple[dist: float64, id: uint64]
NodeDist* = tuple[dist: float64, id: uint64]
proc cosineDistance*(a, b: Vector): float64 =
var dot, normA, normB: float32