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:
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user