TextSearch API
Base.sum — Method
Base.sum(cluster::AbstractVector{<:SparseVector})SparseVector counterpart of sum(::AbstractVector{<:Dict}): concatenate every (index, value) pair from every input vector, sort once by index, then combine consecutive equal indices in a single linear pass. Beat every alternative tried (naive +-folding, pairwise tree merging, a k-way heap merge) at every cluster size benchmarked, and — unlike a dense-accumulator approach — its cost does not depend on the vectors' dimension, only on their total number of stored entries. See sadit/TextSearch.jl#25.
All vectors in cluster must have the same dimension (length).
Distances.evaluate — Method
evaluate(::NormCosine, a::SparseVector, b::SparseVector)::Float64
evaluate(::Cosine, a::SparseVector, b::SparseVector)::Float64
evaluate(::NormAngle, a::SparseVector, b::SparseVector)::Float64
evaluate(::Angle, a::SparseVector, b::SparseVector)::Float64SparseVector counterparts of the Dict-based distance functions above, using sparsedot instead of the plain-merge dot for the underlying inner product. NormCosine/NormAngle assume a/b are already normalized; Cosine/Angle do not.
Example
julia> using SparseArrays
julia> evaluate(NormCosine(), sparsevec(UInt32[1, 2], Float32[0.6, 0.8], 10), sparsevec(UInt32[2], Float32[1.0], 10))
0.19999998807907104SparseArrays.sparsevec — Method
sparsevec(vec::Dict{Ti,Tv}, m=0) where {Ti<:Integer,Tv<:Number}Creates a sparse vector from a Dict-based sparse vector
Example
julia> sparsevec(Dict{UInt32,Float32}(1 => 0.5, 3 => 0.2))
[1] = 0.5
[3] = 0.2TextSearch.BinaryInvertedFile — Function
BinaryInvertedFile(vocsize::Integer, dist=Dist.Sets.Jaccard())Creates an empty InvertedFile indexed by a set distance (e.g. Dist.Sets.Jaccard(), Dist.Sets.Dice(), Dist.Sets.Intersection(), Dist.Sets.CosineSet()), suitable for set/token-membership objects (sets or sorted vectors of integer ids).
TextSearch.WeightedInvertedFile — Method
WeightedInvertedFile(vocsize::Integer)Creates an empty InvertedFile indexed by cosine dissimilarity (Dist.NormCosine()), suitable for weighted sparse vectors (e.g. SparseVectors produced by vectorize).
TextSearch._blended_numtokens — Method
_blended_numtokens(avgdoclen, voc, voc_sample) -> Int64Resolves blend_vocabularies' avgdoclen option into the numtokens to store: :blend sums the surviving occurrences, :sample matches the sample's average document length, and a positive number is used as that average directly.
TextSearch._bow_sizehint — Method
_bow_sizehint(voc::Vocabulary)Estimated final size (number of unique tokens) of a BOW computed under voc, used to sizehint! it up front and avoid rehashing while it's filled. Uses voc's own avgdoclen, falling back to a small default before voc has seen any training documents.
avgdoclen is a mean document length, so it over-estimates the number of distinct tokens a document holds – measured on Spanish Wikipedia, by about 3.4x for whole articles and 1.6x for paragraphs. That is the harmless direction for a sizehint!: too large wastes a little memory per document, too small brings back the rehashing this exists to avoid. A tighter estimate would need a distinct-tokens-per-document statistic, which a Vocabulary does not keep.
TextSearch._candidate_group — Method
_candidate_group(voc, tok, variants, norm) -> Vector{Tuple{String,Symbol,Int}}The vocabulary spellings tok could be searched as besides itself, each with the reason it was reached and its document count. Computed spellings come first, then stored ones, so the order a caller sees follows how much had to be assumed.
TextSearch._check_refit_textconfig — Method
_check_refit_textconfig(expected::TextConfig, got::TextConfig)Errors unless got is the config a refit requires, comparing field by field via the same predicates a merge uses – == on these structs is unreliable (see the note atop mergeprofiles.jl).
Worth checking loudly: a sample tokenized under a different config produces tokens that do not correspond to the base's, and the blend would then quietly interpolate unrelated counters instead of failing.
TextSearch._common_prefix_len — Method
_common_prefix_len(a, b) -> IntNumber of leading characters a and b share (both given as Char vectors).
TextSearch._derivable_forms — Method
_derivable_forms(folded) -> TupleThe corpus spellings a folded token can be computed back into: the token itself, its first letter capitalized, and its upper-case form. These need no storage, which is most of what a variant map would otherwise hold – measured on 272,466 Spanish paragraphs, 46,200 of 68,693 folded-to-token pairs are of this shape, so leaving them out cuts the stored map by 70%.
Accents are the opposite case and are why the map exists at all: from practico there is no way to compute whether the corpus writes práctico or practicó, and both are real words.
TextSearch._extend_lemmas_from_sample — Method
_extend_lemmas_from_sample(base, sample_voc, lemmamap; kwargs...) -> Dict{String,String}Finds lemma entries for the tokens sample_voc brings that base's vocabulary never had.
The grouping runs over the base and sample vocabularies merged, not over the sample alone, and that is the point: a new inflected form usually belongs to a family whose lemma the base already knows, so "audifonos" must be able to elect the base's "audifono". The merged counts also make :most_frequent prefer the established form over the newcomer. Only the new tokens get entries (see extend_lemmas_morphological), so the base's own clustering decisions are never overruled.
TextSearch._fit_vocabulary — Method
_fit_vocabulary(tc, corpus, min_ndocs; label="", verbose=true) -> VocabularyBuilds a vocabulary under tc and prunes it to tokens in at least min_ndocs documents.
The pruning is not cosmetic: the expansion network is an all-pairs kNN over the vocabulary, so this cuts the most expensive stage of a fit quadratically. Pruning to nothing is an error rather than an empty model, because it is always a mistake in the threshold and silently returning nothing wastes whatever comes after.
TextSearch._fold — Method
_fold(tok; lc::Bool, diac::Bool) -> StringOne folded spelling of tok: case folded when lc, marks stripped when diac.
Mirrors what the normalization stage would have produced for this word had the profile been configured that way – lowercase first and then Unicode.normalize, in that order and for the reason recorded in _preprocessing: they disagree on the Turkish dotted capital I, where lowercase gives i and case folding alone gives i plus a combining dot, and the second is a token nobody can type.
TextSearch._impute_removed_stopwords — Method
_impute_removed_stopwords(profiles, vocs, voc, pol, doc_freq_threshold) -> VocabularyRestores what per-batch stopword removal destroyed, so the merged counters can be read at corpus scale.
fit applies stopwords by tokenizing the batch with them in the pipeline, so a flagged token never enters that batch's vocabulary and its counts are simply gone. When every input flagged it there is nothing to do – it is absent from the merge and stays a stopword. The hard case is a token some inputs flagged and others did not: the merged counters then hold only the batches that kept it, which is a fraction of the truth. Measured on Portuguese Wikipedia, 18 of 35 merged stopwords were in that state, como among them at df=0.049 against a real corpus df above 0.5 – an idf near 3.0 where 0.5 is right.
Neither of the obvious rules works. Dropping such a token deletes content words: on English Wikipedia 35 of 89 were flagged by at most 2 of 48 batches, american, united, states, family and history among them, each made locally ubiquitous by one run of stub articles. Keeping it with the partial counts is the inflated-idf bug.
So the missing counts are imputed instead. A batch that flagged a token recorded no number, but it did record a fact: the token's document frequency there exceeded that batch's threshold. threshold * trainsize is therefore a real lower bound, and the tightest one available. Only batches whose vocabulary genuinely lacks the token are imputed for: a profile may list a stopword it never applied, and its counts are then already exact. Occurrences are scaled by the occs-per-document ratio the batches that kept it observed. Every token then carries its best available estimate and the corpus-scale threshold decides: como lands at 0.399 and stays as a normal token, american at 0.055, the was flagged everywhere and remains a stopword.
The estimate is a lower bound, so a token near the threshold can be judged a normal token when the truth is just above it. That is the safe direction: idf already drives a high-document-frequency token's weight toward zero, while deleting a content word is unrecoverable.
TextSearch._input_threshold — Method
_input_threshold(p::TextProfile, default::Real) -> Float64The doc_freq_threshold fit used on p, read from its lineage, or default when it is not recorded – profiles fitted before the threshold was recorded, and merges of merges, whose summarized lineage drops per-batch params.
TextSearch._leader_groups — Method
_leader_groups(items, order, close) -> Vector{Vector{T}}Groups items around seeds: walking them in order, the first unassigned item becomes a seed and every still-unassigned item that is close to that seed joins it.
This deliberately replaces single-linkage for morphology. Single linkage chains – A~B and B~C merge even when A and C are unrelated – and on a real vocabulary the chains swallow everything sharing a prefix: measured on 143k Spanish tokens it produced a 292-member "family" spanning concentra...cons, and merged cara with caracas and caracalla. Requiring closeness to the seed instead bounds every group by one radius around its lemma, which is also exactly the shape "a lemma plus its variants" should have.
Visiting in the selector's own order (see _selector_key) makes the seed the token the selector would have elected anyway.
TextSearch._link_subclusters — Method
_link_subclusters(items, close) -> Vector{Vector{T}}Single-linkage grouping of items under the predicate close(i, j) (indices into items), by union-find. O(length(items)^2) predicate calls, so callers must keep the input small (by blocking, or by having partitioned already).
TextSearch._morphology_metric — Method
_morphology_metric(morphology, qgram) -> (prepare, distance)Builds the pair of functions the subclustering needs: prepare(token_string) computes whatever representation the metric compares, and distance(a, b) scores two prepared representations on [0, 1] (0 = identical surface form).
:jaccard: Jaccard distance over characterqgram-gram sets, viaDist.Sets.Jaccard. Insensitive to where the difference falls, so it handles prefixal, suffixal and infixal variation alike.:levenshtein: edit distance viaDist.Seqs.Levenshtein, normalized by the longer token so a single threshold means the same thing for short and long words.
Both are normalized deliberately: an absolute edit distance of 2 is negligible between long words and total between short ones, so a raw threshold would behave inconsistently across a real vocabulary.
TextSearch._prefix_blocks — Method
_prefix_blocks(voc, ids, prefix_len) -> Vector{Vector{UInt32}}Buckets ids by their tokens' first prefix_len characters. When linking requires a shared prefix of that length, this blocking is exact – two tokens in different buckets can never link – and it is what makes morphology-first clustering affordable: comparing the whole vocabulary pairwise is O(vocsize^2) (10^10 pairs at 143k tokens), while the sum over buckets is smaller by orders of magnitude.
prefix_len <= 0 cannot block, so everything lands in a single bucket – which also means min_common_prefix = 0 gives up the blocking speedup entirely.
Requiring a shared prefix is not only an optimization: character n-gram similarity is position-blind, so without it abioticos/bioticos and abandonadas/donadas link on sharing nearly every gram despite being different words. It encodes that the target language inflects by suffix, so set it to 0 for languages where that does not hold.
TextSearch._profile_reader — Method
_profile_reader(path::AbstractString) -> read_file::FunctionReturns a read_file(name::AbstractString) -> JSON3 closure that fetches and parses a named member of the profile at path – a plain directory if isdir(path), otherwise a .zip archive (opened once and re-read from memory for every subsequent read_file call). This is what lets load_profile not care which of the two forms it was handed.
TextSearch._qgram_ids — Method
_qgram_ids(s, q, vocab) -> Vector{Int32}Sorted, deduplicated ids of s's character q-grams, interning grams through vocab so distinct grams never collide. This is the representation SimilaritySearch.Dist.Sets.Jaccard expects (a sorted set as a vector). Tokens shorter than q are represented by themselves, so they can only match identical tokens.
TextSearch._remap_expansion_to_lemmas — Method
_remap_expansion_to_lemmas(network, distances, lemmas) -> (; query_expansion, distances)Rewrites an expansion network's keys and values through lemmas.
Needed because the network is derived from the unlemmatized vocabulary – it has to be, since the lemma map itself comes from embeddings over that vocabulary – while a profile that applies lemmas no longer has those tokens. Left alone, every inflected entry would be dropped at query time in silence.
Entries that collapse onto the same lemma are merged, keeping each candidate's best rank or distance, and a lemma pointing at itself is dropped. Distances are kept for a token only if every one of its candidates has one, so the two lists can never fall out of alignment.
TextSearch._restrict_query_expansion — Method
_restrict_query_expansion(query_expansion, distances, voc) -> (query_expansion, distances)Drops every network entry naming a token absent from voc, keeping rank order and the parallel distances aligned.
Necessary because the refit prunes: an entry left pointing at a dropped token would be discarded at query time by expand_query! without a word (token2id returning 0), so it would cost file size and tell a reader the network is richer than it is.
TextSearch._selector_key — Method
_selector_key(selector) -> (voc, tid) -> keySort key matching _lemma_pick's preference, so a leader pass can visit candidates in the order the selector would elect them and its seed is already the lemma.
TextSearch._semantic_clustering — Method
_semantic_clustering(algorithm, dist, wordvecs, num_clusters, m)Runs the requested SimilaritySearch clustering over the token embeddings, defaulting num_clusters to ceil(sqrt(m)).
TextSearch._vote_lemmas — Method
_vote_lemmas(profiles, voc) -> Dict{String,String}Merges the per-profile token => lemma maps by plurality vote (ties broken by the canonical-token rule: most frequent, then shortest, then lexicographic), keeping only tokens and lemmas that survive in the merged vocabulary.
Independent votes can disagree in ways a single clustering never does – a => b in some profiles and b => a in others – so the winning edges are then followed to a fixed point so that a whole chain collapses onto one canonical token, and any cycle is resolved by electing its most frequent member. Without that pass the merged map could contain cycles, which would make naive lemma lookup non-terminating.
TextSearch._with_textconfig — Method
_with_textconfig(model::VectorModel, tc::TextConfig) -> VectorModelCopies model with tc as its vocabulary's config, sharing the counter arrays rather than copying them – only the config field differs.
TextSearch.avgdoclen — Method
avgdoclen(voc::Vocabulary)Average document length in tokens (getnumtokens(voc) / gettrainsize(voc)), used by BM25Scorer.
TextSearch.bagofwords! — Method
bagofwords!(bow::BOW, voc::Vocabulary, messages)Computes a bag of words from a multi-field document (a list of texts), accumulating the result into bow. See bagofwords for the non-mutating version.
Example
julia> voc = Vocabulary(TextConfig(), ["hello world"]; verbose=false);
julia> bow = BOW();
julia> TextSearch.bagofwords!(bow, voc, "hello hello world");
julia> bow
Dict{UInt32, Int32}(0x00000002 => 1, 0x00000001 => 2)TextSearch.bagofwords! — Method
bagofwords!(bow::BOW, voc::Vocabulary, tokenlist::TokenizedText)Accumulates the tokens in tokenlist into bow (a BOW), looking up each token's id in voc; out-of-vocabulary tokens are skipped. Returns bow.
Example
julia> voc = Vocabulary(TextConfig(), ["hello world"]; verbose=false);
julia> TextSearch.bagofwords!(BOW(), voc, tokenize(TextConfig(), "hello hello"))
Dict{UInt32, Int32}(0x00000001 => 2)TextSearch.bagofwords — Method
bagofwords(voc::Vocabulary, messages; isnormalized::Bool=false)Tokenizes messages (a string or a list of strings) under voc's TextConfig and returns its bag of words (BOW): a token id => occurrence count mapping. An already-computed BOW is returned unchanged.
Example
julia> voc = Vocabulary(TextConfig(), ["hello world"]; verbose=false);
julia> bagofwords(voc, "hello hello world")
Dict{UInt32, Int32}(0x00000002 => 1, 0x00000001 => 2)TextSearch.bagofwords_corpus — Method
bagofwords_corpus(voc::Vocabulary, corpus::AbstractVector; isnormalized::Bool=false, verbose=true)Computes a list of bag of words (BOWs) from a corpus, one per document, in parallel across threads.
Example
julia> voc = Vocabulary(TextConfig(), ["hello world", "hello there"]; verbose=false);
julia> bagofwords_corpus(voc, ["hello world", "hello there"]; verbose=false)[1]
Dict{UInt32, Int32}(0x00000002 => 1, 0x00000001 => 1)TextSearch.blend_vocabularies — Method
blend_vocabularies(voc_base, voc_sample;
kappa::Real=0, keep_rate::Real=1e-5, keep_floor::Integer=3,
avgdoclen=:blend)
-> VocabularyInterpolates two vocabularies into one, treating voc_base as a prior worth kappa documents and voc_sample as observed evidence.
The blend
Read κ as "the base is worth this many documents". Both counters are then scaled the same way – by the base's average per document – and added to what the sample observed:
ndocs(t) = ndocs_s(t) + round(κ * ndocs_b(t) / N_b)
occs(t) = occs_s(t) + round(κ * occs_b(t) / N_b)
trainsize = N_s + κ
numtokens = sum(occs) # recomputed from the survivorskappa <= 0 defaults to gettrainsize(voc_sample), which weights the two sides equally; halve it for 1/3 base, double it for 2/3. Expressing the base's authority in documents rather than as a fraction is what makes the output sample-sized – so a refitted profile is naturally lighter than the generic one it came from – and makes the knob mean something concrete.
Using the same per-document denominator for both counters is what keeps the result a possible corpus. Scaling occs by the base's share of total tokens instead (occs_b/T_b) looks equally reasonable and is not: the two counters then round against different denominators, and a token carried from the base lands with ndocs >= 1 but occs == 0 – present in documents yet never occurring. Sharing the denominator preserves each token's occurrences-per-document ratio, so occs >= ndocs holds by construction.
avgdoclen
By default (avgdoclen = :blend) numtokens is the sum of the surviving occs, so avgdoclen comes out as a weighted mean of the two corpora's average document lengths. That is the honest reading of the blend – the pseudo-documents the prior contributes are base documents, and they are as long as base documents are. But it moves BM25's length normalization toward the base, and when the two corpora's documents are nothing alike the effect is large: Wikipedia-es against 400 product reviews lands at 141 tokens/document at κ = N_s and 56 at κ = N_s/4, against the sample's own ~21.
avgdoclen = :sample instead sets numtokens so the average matches the sample's, and a positive number sets it to that average directly. This deliberately decouples numtokens from sum(occs), which is safe because that field has exactly one consumer: avgdoclen, and through it BM25Scorer's length normalization. (TpWeighting also divides by a "numtokens", but that one is the document's in-vocabulary token count computed per call in vectorize!, not this.) Use it when the profile will index documents shaped like the sample – which is the usual reason to refit at all – and leave it on :blend when the base's documents are representative of what you will index.
The prune
A token absent from the sample is kept only if the base considered it important:
keep(t) = ndocs_s(t) > 0 || (r_b(t) >= keep_rate && ndocs_b(t) >= keep_floor)keep_rate is scale-free; keep_floor is an absolute floor that stops a token seen in one or two documents of a huge base corpus – a typo, an ID – from clearing a small rate threshold. Everything surviving must additionally round to ndocs >= 1, so a token whose blended presence is negligible falls out on its own.
Note what needs no rule: a token the base did consider important but the sample never shows keeps only its κ-weighted share, so it survives with reduced weight automatically. Lowering importance is arithmetic; dropping is the only part that needs a decision.
TextSearch.centroid — Method
centroid(cluster::AbstractVector{<:SparseVector})Centroid (normalized sum) of a cluster of SparseVectors. See sum(::AbstractVector{<:SparseVector}) for the algorithm.
TextSearch.decode — Method
decode(voc::Vocabulary, bow::Dict)Converts a Dict sparse vector indexed by token id (e.g., a BOW) into a Dict indexed by the corresponding token string.
Example
julia> voc = Vocabulary(TextConfig(), ["hello world"]; verbose=false);
julia> decode(voc, bagofwords(voc, "hello hello"))
Dict{String, Int32}("hello" => 2)TextSearch.derive_variants — Method
derive_variants(voc::Vocabulary; min_ndocs=1, maxforms=8) -> Dict{String,Vector{String}}Builds the query-side variant map for voc: for a folded spelling, the vocabulary tokens it should reach that cannot be computed from it.
This is what lets a profile keep case and diacritics without becoming unsearchable. Preserving them separates senses that folding destroys – measured on Spanish Wikipedia, granada folded returns only the city, while unfolded it also returns the heraldic charge (gules azur bordura), and likewise for cuba (the barrel), concepción (the concept) and león (the animal). The cost is that a query typed leon matches nothing, since the corpus writes León. This map is that bridge, and it is query-only: applying it while indexing would blur the very distinctions it exists to make searchable.
It cannot lean on the query-expansion network, and that is measured rather than assumed: across 16,376 case-twinned Spanish tokens the twin appears in its counterpart's expansion list only 13.6% of the time and at rank 1 in 6.3%. Where it does appear, the pair are function words whose capital is merely sentence-initial; where it does not, the two forms have genuinely different senses and the network is right to keep them apart.
This is computed, never stored
A profile does not carry a variant map: it is a pure function of the vocabulary the profile already holds, so storing one is a second copy of the same information – and a copy that goes wrong, because per-part maps cannot be combined into the map the combined vocabulary yields. Measured on 9 parts of Portuguese Wikipedia, unioning them gave 21,646 keys against the 30,968 the merged vocabulary itself produces: a strict subset missing 30%, tropecar -> tropeçar among them at 132 documents corpus-wide and about 15 per part, under any per-part floor. Deriving from the merged counters instead costs 0.24s over 479,245 tokens.
What it leaves out
Derivable capitalization, per _derivable_forms: madrid -> Madrid is not included because it is computed at query time from the folded form. Two thirds of the pairs are of that shape, and the fraction falls with frequency – 67% of pairs at 5 documents against 53% at 100 – because rare tokens are disproportionately proper nouns whose only variation is a capital, while frequent ones carry real accent alternatives.
Anything below min_ndocs, which defaults to no filtering at all. The floor was worth having while the map was an artifact on disk; now that it is transient, its only remaining job is cost, and there is little to buy: on the 479,245-token Portuguese vocabulary a floor of 1 gives 61,925 keys in 0.62s and 10.3 MB against 30,968 in 0.27s and 4.1 MB at a floor of 20. The 62k map has twice the coverage of exactly the long tail a person is most likely to mistype and least likely to find otherwise. Note also that a vocabulary pruned at fit time already imposes its own floor: these profiles use min_ndocs=5 there, so 1, 2 and 5 here produce identical maps.
Query-time quality is not this function's job either. A bridged spelling is admitted only if it is not negligible beside the commonest spelling of its group – see negligible_ratio in QueryPolicy – which is a relative test and a better one than any absolute count.
Values are ordered by document frequency, most frequent first, and capped at maxforms.
TextSearch.download_profile — Method
download_profile(nickname_or_url::AbstractString;
repo::AbstractString="sadit/TextSearch.jl",
tag::AbstractString="v1.1.0",
dest::Union{Nothing,AbstractString}=nothing,
url::Union{Nothing,AbstractString}=nothing,
force::Bool=false) -> StringDownloads a pre-computed linguistic profile (<nickname>.zip) from a GitHub release or direct URL and saves it locally. By default, installs under ~/.textsearch/profiles/<nickname>.zip (or $TEXTSEARCH_HOME/profiles/<nickname>.zip), or into dest if explicitly specified. Returns the file path of the downloaded archive.
TextSearch.dvec — Method
dvec(x::AbstractSparseVector)Converts an sparse vector into a dict-based sparse vector
Example
julia> dvec(sparsevec(Dict{UInt32,Float32}(1 => 0.5, 3 => 0.2)))
Dict{UInt32, Float32}(0x00000003 => 0.2, 0x00000001 => 0.5)TextSearch.encode — Method
encode(voc::Vocabulary, bow::Dict)Converts a Dict sparse vector indexed by token string into a Dict indexed by token id, the inverse of decode.
Example
julia> voc = Vocabulary(TextConfig(), ["hello world"]; verbose=false);
julia> encode(voc, Dict("hello" => 2))
Dict{UInt32, Int64}(0x00000001 => 2)TextSearch.expand_query! — Method
expand_query!(vec::SparseVector, voc::Vocabulary, query_expansion;
distances=nothing, weight_fn=nothing, normalize::Bool=true) -> vecExpands a query's sparse tf-idf vector IN PLACE with weighted contributions from each present token's queryexpansion (`queryexpansion, e.g. as produced by [LSI.queryexpansion](@ref)). This mutatesvec-- pass an unnormalized, disposable query vector (vectorize(model, query; normalize=false)`); never a vector you still need afterwards, and never a document vector (documents are never expanded, only queries). Normalizing before calling this would also make the original-vs-queryexpansion weight ratio depend on how many tokens the query had, not on the intended per-query_expansion weighting – that's why normalize (default true) happens here, as the final step.
query_expansion maps a token to its neighbor tokens in rank order (nearest first). For each of vec's original nonzero (tokenID, weight) pairs (captured once, before any appending), looks up its string via gettoken(voc, tokenID); if it's a key of query_expansion, appends weight * weight_fn(...) at token2id(voc, query_expansion) for every neighbor (an OOV queryexpansion – token2id returning 0 – is silently skipped, matching bagofwords!/vectorize!'s existing convention). The appended entries are then merged into vec's existing nonzeros: the combined (nzind, nzval) arrays are heap-sorted by id (reusing SimilaritySearch.heapify!/heapsort!, the same coupled-array sort used to build a SparseVector out of a KnnQueue), duplicate ids (a queryexpansion that was also already present, or reached via two different original tokens) are combined in a single two-pointer reduction pass, and the backing arrays are resize!d down to the final count – an in-place O(n log n) merge, no new allocation for the index/value storage itself.
Weighting modes
There are two, chosen by whether distances is given:
- rank (
distances === nothing, the default):weight_fnreceives the neighbor's 1-based rank, and defaults to1/rank. This is the normal mode. A network's ranking is what transfers between models – distances live in whichever embedding space produced them, and a merged or refitted network's distances are no longer distances in any single space at all. - distance: pass
distances, a parallel mappingtoken => Vector{Float32}aligned withquery_expansion[token];weight_fnthen receives the distance and defaults toexp(-d)(1.0at distance0, decaying smoothly). Pass e.g.d -> d < 0.3 ? 0.5 : 0.0for a hard cutoff. A token missing fromdistances, or a short distance list, falls back to rank weighting for the neighbors it does not cover, so a partially-populateddistancesis safe rather than an error.
TextSearch.expand_query! — Method
expand_query!(bow::AbstractDict{<:Integer,<:Real}, voc::Vocabulary, query_expansion) -> bowExpands a query's bag-of-words IN PLACE by adding every present token's queryexpansion as extra keys – the BM25InvertedFile counterpart of the SparseVector method above. There is no `weightfn/normalize/distanceshere: BM25 scoring (bm25score) never reads the query side's frequencies, only which token ids are present ("query's own frequencies are not used"), so an injected query_expansion only needs to make its id present inbow` – any positive count works, and an id already present (e.g. the query_expansion also appears literally in the query) is left untouched rather than overwritten. This is why a network's distances are not needed on the normal path at all.
As with the SparseVector method, bow's original keys are snapshotted once (via collect) before any insertion, so newly-added queryexpansion ids are never themselves expanded. An OOV queryexpansion (token2id returning 0) is silently skipped, matching bagofwords!'s existing convention.
TextSearch.expansion_sources — Method
expansion_sources(r::QueryResolution) -> Vector{String}The tokens a consumer should look up in a query-expansion network: one per typed token, its group's commonest spelling.
Not every token that was searched, and this is measured rather than stylistic. Expansion over the whole bridged set mixes senses, because bridging deliberately reaches spellings the corpus barely holds and their neighbour lists come from a handful of documents: on Spanish Wikipedia paragraphs SOL (5 documents) gives digitalizada máx chip flash SDRAM, Ano (5) gives the Annobón islands, rio (12, the verb reír) gives llorar tiró Nazgûl, and musica (9, Italian-language paragraphs) gives libreto Puccini Verdi Semiramide. A search for musica de leon returned Antonio Vivaldi and a chess article.
Expanding only what the user typed is not the fix either – it fails in exactly those cases, since the typed form is the rare one. The dominant spelling is: sol bridged gives Sol (1,659 documents) and afelio perihelio eclipses eclíptica, rio gives río and afluente confluencia cauce desemboca. The rarer spellings stay in the search set as matching terms, where a wrong one costs a handful of false positives instead of eight high-idf junk terms.
When nothing was bridged, the group is the typed token alone and this is exactly the token list – so an unbridged query expands as it always did.
TextSearch.explain — Method
explain(r::QueryResolution) -> Vector{String}One human-readable line per typed token that gained something, for a consumer that wants to tell the user what was actually searched.
TextSearch.extend_lemmas_morphological — Method
extend_lemmas_morphological(voc::Vocabulary, lemmas::AbstractDict;
candidates=nothing,
morphology::Symbol=:jaccard, morphology_threshold::Real=0.3,
qgram::Integer=2, min_common_prefix::Integer=3,
selector::Symbol=:most_frequent) -> Dict{String,String}Derives additional token => lemma entries for voc from surface similarity alone, and returns only the new ones (merge them into lemmas yourself).
This exists because morphology is the signal that actually groups an inflection family: lemma_clusters uses embeddings only to split a family whose members mean different things, never to form one. So a family can be recovered without fitting any embedding – which is what makes it usable on a vocabulary that arrived after the model was trained, e.g. the tokens a refit's sample brings that its base profile never saw (refit_profile). Nothing here needs wordvecs, an LSI, or a second pass over a corpus. The tradeoff is that no semantic check can veto a grouping, so two look-alike words with unrelated meanings will merge where full lemma_clusters would have kept them apart.
candidates bounds both the cost and the scope. Only prefix blocks containing at least one candidate token are examined – the reason this stays cheap when voc is a whole base vocabulary and only a handful of tokens are new – and only candidate tokens get entries. That restriction is deliberate: a family may well contain two tokens the base's own clustering saw and chose not to link, and silently overruling that decision is not this function's business. Pass nothing to consider everything.
Tokens already keyed in lemmas are skipped, so no chain token -> lemma -> other lemma can be created. Note that under an applied lemma stage they are not vocabulary tokens to begin with.
TextSearch.filter_tokens! — Method
filter_tokens!(voc::Vocabulary, arr::AbstractVector{TokenizedText})Applies filter_tokens!(voc, text) to every tokenized document in arr.
TextSearch.filter_tokens! — Method
filter_tokens!(voc::Vocabulary, text::TokenizedText)Removes tokens from a given tokenized text based using the valid vocabulary
Example
julia> voc = Vocabulary(TextConfig(), ["hello world"]; verbose=false);
julia> tks = tokenize(TextConfig(), "hello unknownword world");
julia> filter_tokens!(voc, tks); collect(tks)
["hello", "world"]TextSearch.filter_tokens — Method
filter_tokens(pred::Function, model::VectorModel)Returns a copy of model reduced to the tokens for which pred(t) is true, where t is a (; id, occs, ndocs, weight, token) named tuple (see also filter_tokens(pred, voc::Vocabulary)).
Example
julia> corpus = ["hello world", "hello there", "the cat sat"];
julia> voc = Vocabulary(TextConfig(), corpus; verbose=false);
julia> model = VectorModel(IdfWeighting(), TfWeighting(), voc);
julia> vocsize(filter_tokens(t -> t.ndocs >= 2, model))
1TextSearch.filter_tokens — Method
filter_tokens(pred::Function, voc::Vocabulary)Returns a copy of reduced vocabulary based on evaluating pred function for each entry in voc
Example
julia> voc = Vocabulary(TextConfig(), ["hello world", "hello there"]; verbose=false);
julia> voc2 = filter_tokens(t -> t.ndocs >= 2, voc);
julia> vocsize(voc2)
1TextSearch.fit_profile — Method
fit_profile(textconfig::TextConfig, corpus; kwargs...) -> TextProfileDistills corpus into a portable TextProfile: vocabulary and counters, weights, a stopword set, an expansion network and a lemma map, with the lineage recording how.
This exists because the ordering is not obvious and getting it wrong is silent. Three passes over the corpus, and each one has to be where it is:
- Stopwords before the vocabulary. They are detected from an unfiltered pass whose only product is the list of tokens above the threshold, and then removed while building the vocabulary the encoder trains on – so a filtered token never enters the counters, the factorization, or the network. It is also the single largest stage of a fit: measured on 272,466 Spanish Wikipedia paragraphs, 36.5s of 123.2s.
- The encoder, then the lemmas. Lemma families are found by clustering token embeddings, so the embeddings have to exist first.
- The vocabulary again, under the lemma map, when
lemmas.applyis set. A lemma is a normalization, so it belongs in theTextConfigwhere every consumer applies it to documents and queries alike and the idf counts an inflection family together instead of splitting it across forms. This pass cannot be folded into an earlier one – the map is derived from embeddings over the vocabulary it rewrites. LSI is deliberately not redone afterwards: the embeddings' job was to find the families and they did.
Keywords, grouped as the concerns they belong to
min_ndocs = 1– drop tokens in fewer documents than this, before the encoder runs.stopwords = (; doc_freq_threshold=0.0, reuse=nothing)–0disables detection.reusetakes a set another batch already detected, which is how batches of one corpus end up with identical sets and therefore an exact merge (nothing to impute).encoder = (; outdim=256, scaling=:none, factorization=:auto, wordvectors=nothing)– LSI unlesswordvectorshands over external embeddings, in which case they are used as they are. Reading them from a file is the caller's business; this takes vectors.expansion = (; k=8, head_df=0.0, max_target_ratio=50.0, approx=:auto, construction_recall=0.97, search_recall=0.9)– seequery_expansion.lemmas = (; apply=false, algorithm=:fft, ...)– seelemma_clusters.apply=falseis the default because a base profile computes the map and leaves the choice to whoever tunes from it.verbose = true.
Example
julia> p = fit_profile(TextConfig(), corpus; min_ndocs=5, stopwords=(; doc_freq_threshold=0.5));
julia> isbase(p)
trueTextSearch.fold_lemmas — Method
fold_lemmas(voc::Vocabulary, lemmas) -> (; voc, folded, capped, dropped)Rewrites voc's tokens through lemmas, merging each inflection family's counters into its lemma. Used to bring a base vocabulary that was built without a lemma step onto the same footing as a sample tokenized with one.
The two counters fold differently, and only one is exact:
occsis exact. Occurrences are additive, so a family's total occurrence count is the sum of its forms'.ndocsoverestimates. A document containing both"casa"and"casas"counts once for each, but once folded it should count once for"casa"– and a vocabulary carries no co-occurrence information to correct with.
That overestimate is why every ndocs is capped at trainsize. The cap is a correctness requirement, not tidiness: ndocs > trainsize makes idf negative (log2((0.5+trainsize)/(0.5+ndocs))) and drives BM25's numerator (trainsize - ndocs + 0.5) below zero. capped reports how often it bit, so the approximation stays visible instead of assumed harmless.
A token whose lemma is absent from voc is dropped rather than reintroduced: that happens when the lemma was itself filtered out at fit time (a stopword, or pruned as rare), and resurrecting it here would smuggle back a token the pipeline deliberately excludes. folded counts remapped tokens, dropped the discarded ones.
TextSearch.getndocs — Method
getndocs(voc::Vocabulary, tokenID::Integer)
getndocs(voc::Vocabulary)Number of documents containing the token tokenID (0 is out-of-vocabulary and yields 0 instead of erroring), or the whole per-token vector when called without a tokenID.
Example
julia> voc = Vocabulary(TextConfig(), ["hello world", "hello there"]; verbose=false);
julia> getndocs(voc, token2id(voc, "hello"))
2TextSearch.getnumtokens — Method
getnumtokens(voc::Vocabulary)Total number of (non-unique) tokens seen while building voc.
TextSearch.getoccs — Method
getoccs(voc::Vocabulary, tokenID::Integer)
getoccs(voc::Vocabulary)Total occurrences of the token tokenID across the corpus (0 is out-of-vocabulary and yields 0 instead of erroring), or the whole per-token vector when called without a tokenID.
Example
julia> voc = Vocabulary(TextConfig(), ["hello world", "hello there"]; verbose=false);
julia> getoccs(voc, token2id(voc, "hello"))
2TextSearch.getpolicy — Method
getpolicy(p::TextProfile) -> TextConfig
getpolicy(tc::TextConfig) -> TextConfigThe corpus-independent half of a text configuration: normalization and tokenization, with no transformation. This is what two profiles must share exactly to be merged, and what a user can write by hand without any data.
TextSearch.gettextconfig — Method
gettextconfig(p::TextProfile) -> TextConfigThe TextConfig this profile tokenizes with: its policy plus the artifacts it applies.
The stage order lives in TokenPipeline and not here, which is the point: lemmas run before the stopword filter, because with the filter first a form that is not itself a stopword survives it and is only then rewritten into one ("las" → "la"), smuggling the stopword back into the vocabulary. This function only decides which artifacts are applied.
TextSearch.gettoken — Method
gettoken(voc::Vocabulary, tokenID::Integer)
gettoken(voc::Vocabulary)The token string for tokenID (0 is out-of-vocabulary and yields "" instead of erroring), or the whole token vector when called without a tokenID.
Example
julia> voc = Vocabulary(TextConfig(), ["hello world", "hello there"]; verbose=false);
julia> gettoken(voc, token2id(voc, "hello"))
"hello"TextSearch.gettrainsize — Method
gettrainsize(voc::Vocabulary)Number of documents used to build voc.
TextSearch.isbase — Method
isbase(p::TextProfile) -> Bool
istuned(p::TextProfile) -> BoolWhether p is a bootstrap model or one adapted to a dataset, derived from its lineage: a profile with no :refit step is a base, one with a refit is tuned. Reading it off the lineage rather than storing a label means it cannot contradict what actually happened, and a refit of an already-tuned profile stays tuned without a rule for it.
TextSearch.istuned — Function
isbase(p::TextProfile) -> Bool
istuned(p::TextProfile) -> BoolWhether p is a bootstrap model or one adapted to a dataset, derived from its lineage: a profile with no :refit step is a base, one with a refit is tuned. Reading it off the lineage rather than storing a label means it cannot contradict what actually happened, and a refit of an already-tuned profile stays tuned without a rule for it.
TextSearch.lemma_clusters — Method
lemma_clusters(voc::Vocabulary, wordvecs::AbstractDatabase;
algorithm::Symbol=:fft, num_clusters::Integer=0,
selector::Symbol=:most_frequent, dist=Dist.Cosine(),
morphology::Symbol=:jaccard, morphology_threshold::Real=0.3,
qgram::Integer=2, min_common_prefix::Integer=3,
order::Symbol=:morphology_first, semantic_threshold::Real=1.0) -> Dict{String,String}Derives a token => lemma map by combining two signals:
- Semantic clustering of
voc's tokens by their embeddings inwordvecs(columnt= embedding ofgettoken(voc, t), e.g. fromLSI.wordvectors), via one ofSimilaritySearch'sfft/dnet/randsel/multirandsel.num_clusters = 0defaults toceil(sqrt(vocsize(voc))). - Morphological subclustering inside each semantic cluster (
morphology,morphology_threshold,qgram– see_morphology_metric), so only tokens that also look alike end up sharing a lemma.
Then one canonical token is elected per group (selector: :most_frequent by default, :shortest, or :shortest_then_most_frequent) and every other member maps to it. The selector also decides seeding order, so it is more consequential than a tie-break: :shortest lets a short misspelling win, and a junk seed fragments the family around it – measured on 143k Spanish tokens, the typo guera seeded a group that swallowed guerra and left guerras stranded. :most_frequent seeds on the form the corpus actually uses, which recovered guerras -> guerra, jugadores -> jugador and concentraciones -> concentracion in the same run. Subclusters of one are left alone. Returns only non-identity entries – a lookup miss means the token is its own lemma.
order decides which signal partitions first:
:morphology_first(default): surface-similar families over the whole vocabulary (made affordable by blocking on the required shared prefix), thensemantic_thresholdsplits a family whose members are far apart in embedding space. Whole conjugations collapse correctly this way (abandona,abandonado,abandonar,abandone, ... ->abandono).:semantic_first: the original order – cluster by embedding, then split each cluster by surface similarity. Retained because it is the only order that respects a caller-suppliedalgorithm/num_clusters, but it fragments inflection families across clusters.
semantic_threshold is a distance under dist, so with the default cosine it lives on [0, 2]; the default 1.0 was picked by measurement rather than taste. Tightening it does not buy precision – it mostly deletes correct inflections (at 0.9 only 4 of 10 probed inflections survive, against 9 of 10 at 1.0), while loosening it past ~1.05 stops catching anything (the artifacts it legitimately removes are cross-language and truncation pairs such as academic/academia and abstracta/abstract).
Step 2 is what makes the result lemma-shaped rather than topic-shaped: embeddings alone put "guerra" next to "belico" rather than next to "guerras" (measured ~2% morphological pairs on Spanish Wikipedia), while surface similarity alone would happily merge "casa" with "caso". Pass morphology=:none to recover the purely semantic behaviour, which elects one representative per semantic cluster and is better described as topic representatives than as lemmas.
Example
lemmas = lemma_clusters(voc, wordvectors(lsi))
lemmas["casas"] # "casa"TextSearch.lineage_summary — Method
lineage_summary(p::TextProfile) -> StringOne line reading how p was produced, e.g. "fit(trainsize=20000) -> merge(n_sources=16) -> refit(kappa=400.0)". Used by textsearch info.
TextSearch.list_remote_profiles — Method
list_remote_profiles(; repo::AbstractString="sadit/TextSearch.jl",
tag::AbstractString="v1.1.0",
url::Union{Nothing,AbstractString}=nothing) -> Vector{NamedTuple}Queries and returns available pre-computed linguistic profiles from GitHub releases or a custom URL. Returns a vector of (name=nickname, filename=name, size=size_in_bytes, url=download_url, tag=tag).
TextSearch.load_profile — Method
load_profile(path::AbstractString) -> TextProfileReads back a profile written by save_profile. path may be the directory it produced or a .zip archive of it (see zip_profile); this is auto-detected via isdir(path), and a .zip is read directly from memory with no extraction.
The returned TextProfile rebuilds its own TextConfig from the stored policy and the artifacts marked applied, so what it tokenizes with always matches what it carries.
A profile written by an older format version is refused by name rather than half-parsed: there is no compatibility path, since carrying two layouts is what let the applied and saved copies of an artifact drift apart in the first place.
TextSearch.merge_profiles — Method
merge_profiles(profiles; doc_freq_threshold=0.5, query_expansion_k=0, rrf_k=60) -> TextProfileMerges several TextProfiles of one corpus into a single corpus-wide profile:
p = merge_profiles(load_profile.(paths))
save_profile(dir, p)This is what makes fit's batching usable: batching a large corpus produces one independent profile per batch, and merging folds them back into the single corpus-wide profile.
What is exact, and what is not
- Vocabulary counts and weights are exact.
occs/ndocs/trainsize/numtokensare additive across disjoint document batches, and the weighting scheme is recomputed from the merged counters – so the merged IDF is the true corpus-wide IDF, not an average of per-batch ones. This is the main reason to merge rather than to pick one batch. - Query expansion are a rank-fusion consensus, not a recomputation – each input's distances come from its own embedding space (see
_fuse_query_expansion). Recomputing them exactly would need the corpus, or a persisted projection, neither of which a profile carries. Scores are summed across inputs, i.e. a consensus count – see_fuse_query_expansionfor why normalizing by the inputs that could have voted, though it looks fairer, was measured and rejected. No merge can repair a missing embedding either: a token only one input kept has a neighbour list resting on that one input's opinion. - Lemmas are a plurality vote over the inputs' clusterings (see
_vote_lemmas). - Stopwords are recomputed from the merged counters at
doc_freq_threshold, then unioned with the inputs' own sets – a token every input already removed is absent from the merged vocabulary and could not be re-derived, but is still a stopword. A token only some inputs removed is then dropped from the merged vocabulary: the inputs that removed it never recorded its counts, so what survives is a fraction of the truth (measured:comoat df=0.049 against a real corpus df above 0.5), and no merge can reconstruct the rest. What the merged counters newly flag is only reported, never dropped – those counts are exact, and keeping them is the reason to merge at all. An artifact counts as applied in the merge if any input applied it. - Lineage keeps the inputs' stages, one entry per distinct stage with the number of inputs that contributed it, followed by the
:mergestep. Merging tuned profiles therefore yields a tuned profile; per-batch params are dropped, since they describe batches the merged profile no longer has.
Inputs must share their policy – normalization and tokenization – and their weighting scheme. Nothing about their artifacts has to match: differing stopword sets union, differing lemma maps vote, differing networks fuse. That asymmetry is the reason policy and artifacts are separate concepts. EntropyWeighting cannot be merged, since recomputing it needs the labeled corpus.
query_expansion_k = 0 keeps as many neighbors per token as the richest input had.
TextSearch.merge_voc — Method
merge_voc(voc1::Vocabulary, voc2::Vocabulary[, ...])
merge_voc(pred::Function, voc1::Vocabulary, voc2::Vocabulary[, ...])Merges two or more vocabularies into a new one. A predicate function can be used to filter token entries.
Note: All vocabularies should had been created with a compatible TextConfig to be able to work on them.
Example
julia> cfg = TextConfig();
julia> voc1 = Vocabulary(cfg, ["hello world"]; verbose=false);
julia> voc2 = Vocabulary(cfg, ["hello there"]; verbose=false);
julia> vocsize(merge_voc(voc1, voc2))
3TextSearch.push_token! — Method
push_token!(voc::Vocabulary, token, occs::Integer, ndocs::Integer)
push_token!(voc::Vocabulary, token; occs::Integer=0, ndocs::Integer=0)Registers token in voc if not already present (assigning it a new id), or accumulates occs/ndocs into its existing entry otherwise. Returns the token's id.
Example
julia> voc = Vocabulary(TextConfig(), 0, 0);
julia> TextSearch.push_token!(voc, "cat"; occs=1, ndocs=1)
0x00000001TextSearch.query_tokens — Function
query_tokens(voc::Vocabulary, query, qp::QueryPipeline=QueryPipeline(); policy=qp.policy) -> ResolvedQueryThe query pipeline: turns what a person typed into the terms to search for, and records why.
policy overrides qp.policy for this call and nothing else. The point is that a QueryPolicy is a property of the query while the rest of a QueryPipeline is a property of the corpus: the variant map and the expansion network are derived once, cost real time to derive (0.24s over a half-million-token vocabulary), and belong to the index that holds them. The policy is four scalars. So the policy is what travels, and the maps are reused exactly as they are – which is what lets one index answer both the corrected query and the literal one, the "showing results for … / search instead for …" pair QueryPolicy exists to make possible. Building a whole pipeline per call would rederive nothing but would still be a second place where the pipeline gets assembled.
Reusing the maps under any policy is correct rather than merely cheap: correction never reads variants when policy.correction === :off (see resolve_query_tokens), and expansion is gated on policy.expansion here, so handing over a map or a network that this call has been told not to use changes nothing.
query is raw text, a TokenizedText, or an already-tokenized vector of strings. Text is tokenized under voc's own TextConfig – the same one the documents went through, which it must be, since the vocabulary's ids and counts came from it.
Then, in order:
- Correction.
resolve_query_tokensreplaces spellings the evidence says are wrong and leaves the rest alone. Every spelling it produces weighs1. - Expansion. For each typed token, the network is looked up under one spelling – the commonest of its corrected group, per
expansion_sources– and its neighbours are added with a weight:exp(-d)whenqp.distancescovers them,1/rankotherwise. Both are the weightingsexpand_query!used, kept so the numbers do not move.
A neighbour reachable from two query tokens appears twice, and one the person also typed appears alongside the typed term: those are contributions, and it is the representation that decides what to do with them – queryvector adds them up, querybow and querytokenset collapse them. Neighbours absent from voc are dropped, matching what the vector-level path did with an id of 0.
TextSearch.querybow — Method
querybow(voc::Vocabulary, q::ResolvedQuery) -> BOWThe terms as a BOW of vocabulary ids, presence only: every term gets a count of 1 and the weights are discarded.
That is not a shortcut. BM25 scoring never reads the query side's frequencies – only which ids are present – so a weight there would be carried through the whole search and then ignored, and BOW's counts are Int32 anyway. See bm25score.
TextSearch.querytokenset — Method
querytokenset(q::ResolvedQuery) -> Set{String}The terms as a plain set, for a consumer that matches by token intersection and has no use for weights – textsearch search's grep-like matching, for one.
TextSearch.queryvector — Method
queryvector(model::VectorModel, q::ResolvedQuery; normalize=true) -> SparseVectorThe terms as a weighted sparse vector under model: each term is weighted by the model as usual and then scaled by its QueryTerm weight, so expansion neighbours enter attenuated by rank or distance while typed and corrected spellings enter at full strength.
normalize (default true) is the last step, as it was in expand_query! – a cosine index needs it and doing it before scaling would undo the attenuation.
TextSearch.refit_profile — Method
refit_profile(base, sample_voc::Vocabulary; kwargs...) -> NamedTuple
refit_profile(base, sample_docs; kwargs...) -> NamedTupleAdapts the bootstrap profile base to a dataset, given a sample of it, and returns a new self-contained profile: nothing in the result refers back to base, so it can be saved with save_profile and used on its own.
base is anything with the fields load_profile returns (model, query_expansion, query_expansion_distances, lemmas, stopword_candidates, encoder) – a loaded profile, or one assembled in memory. The return value has that same shape, as merge_profiles's does.
The first form is the core, and takes a Vocabulary the caller built however it liked – streamed, accumulated across runs with push_token!/update_voc!, or from a source that is not a document list at all. It must be built under refit_textconfig(base; apply_lemmas), and is checked against it. The second form is a convenience that tokenizes sample_docs for you.
What is adjusted, and what is not
- Counters are interpolated by
blend_vocabulariesand the vocabulary pruned there; the weight vector is then recomputed, which is what makes the tf-idf and BM25 paths tuned by one operation rather than only the former. - Lemmas are reused rather than re-derived: the base already paid for them. With
apply_lemmas, they enter theTextConfigand the base's counters are folded through the same map (fold_lemmas) so both sides stay comparable.extend_lemmas(corpus form only, since it needs to retokenize) additionally recovers families for tokens the base never saw, from surface similarity alone – seeextend_lemmas_morphological. Without it those tokens stay unmerged, which is the price of not fitting an embedding. avgdoclenis:blendby default and can be pinned to the sample's with:sample; seeblend_vocabulariesfor why that choice matters to BM25.- Query expansion are inherited, restricted to tokens that survived. No embedding is fit here – that is exactly what makes a refit cheap next to a fit, and the point of bootstrapping.
- Stopword candidates are recomputed from the blended counters, but the applied stopword set stays the base's. It has to: the base's counts were collected under that set, and swapping it mid-blend would compare two incomparable vocabularies. New candidates are reported for review, the same detected-versus-applied split the profile format already has.
EntropyWeighting is rejected, as it is for a merge: its weights are supervised and cannot be re-derived from a profile's contents.
Set verbose to see the vocabulary sizes, how much of the result the base accounts for, and the fold/cap counts from any lemma folding.
TextSearch.refit_textconfig — Method
refit_textconfig(base; apply_lemmas::Bool=true, lemmas=nothing) -> TextConfigThe TextConfig a refit of base runs under, and the one a caller building its own sample Vocabulary must tokenize with.
This is public because it is an invariant, not an implementation detail: the blend interpolates two vocabularies token by token, so both sides have to be produced by the same normalization, tokenization, stopword set and lemma step. Tokenizing a sample under anything else silently compares tokens that do not correspond, and the resulting numbers mean nothing.
Everything is inherited from base unchanged, with one deliberate exception: when apply_lemmas is set and base carries a lemma map it did not itself apply, that map enters the config's TokenPipeline, whose lemma stage runs before its stopword stage – the reverse order silently readmits stopwords, since "las" is not in a set holding "la" until after it is rewritten. That is the point of a base profile keeping its lemmas unapplied – whether to lemmatize belongs to the refit, and a tuned model that declines it simply does not carry the map. When lemmas are added here, refit_profile folds the base's own counts through the same map so both sides stay comparable.
lemmas overrides which map is applied, defaulting to base.lemmas. That is what lets a caller lemmatize under a map extended beyond the base's – see extend_lemmas_morphological – while keeping everything else about the config identical.
See also refit_profile, fold_lemmas.
TextSearch.resolve_query_tokens — Function
resolve_query_tokens(voc::Vocabulary, tokens, variants=nothing,
policy::QueryPolicy=QueryPolicy()) -> QueryResolutionTurns the tokens of a query into the tokens to search with, recording why.
Each typed token defines a group: the vocabulary spellings it could be searched as – itself, the spellings computed from its folded form per _derivable_forms, and whatever the stored variants map holds for that folded form. The group's commonest spelling is its dominant, and a spelling holding less than 1 / policy.negligible_ratio of the dominant's documents is negligible. policy.correction decides what to do with that; see QueryPolicy for the three modes.
Correcting replaces; enriching adds
A typed spelling is dropped from the result exactly when something was bridged for it and the evidence says it was wrong: absent from the vocabulary, or negligible. That is what a correction is, and it is why :off has to exist – a consumer that corrects by default owes the person the same query answered literally, the way a commercial engine offers "search instead for …". explain phrases the two cases differently so a consumer can render that offer.
Where no evidence says otherwise the typed spelling stays and bridging only adds: under :always a healthy sol reaches Sol while remaining itself.
Presence is not evidence of intent
min_ndocs=5 on the vocabulary means unaccented misspellings and foreign-language fragments are tokens, so "it exists, therefore they meant it" fails. On 272,466 Spanish Wikipedia paragraphs ingles holds 7 documents against inglés's 5,188, dia 10 against día's 7,093, musica 9 against música's 4,404 – and of the 518 map keys that are themselves vocabulary tokens, 111 have a spelling ten times commoner and 36 have one fifty times commoner. End to end, search musica returned 0 paragraphs while stopping at the typed form and 314 after correcting it.
The ratio is also what keeps a bridge from dragging in spellings the corpus barely holds, closing a gap where resolution admitted any spelling merely present while derive_variants applied min_ndocs when building the map: sol does not reach SOL (5 documents against 1,659), whose neighbours were digitalizada máx chip flash SDRAM.
On ambiguity
practico typed without an accent is genuinely ambiguous between an adjective and a conjugated verb, and under :always it reaches both. That is the mirror image of what del_diac=false buys on the document side, and the split is the point: the corpus keeps the distinction, so idf and embeddings stay per-sense, while the query bridges it.
TextSearch.save_profile — Method
save_profile(dir::AbstractString, p::TextProfile) -> dirSerializes a TextProfile into dir (created if missing) as a small directory of plain, human-readable JSON files: one per "large" piece – vocabulary.json, weights.json, and stopwords.json/lemmas.json/query_expansion.json/query_expansion_distances.json for whichever artifacts are non-empty – tied together by a manifest.json holding everything else.
The manifest keeps policy and artifacts apart, which is the point of the layout:
policy: { normalization: {...}, tokenization: {...} }
artifacts: { stopwords: {file, applied}, lemmas: {file, applied}, query_expansion: {file, ...} }
lineage: [ {stage, params}, ... ]Each artifact is named once, with the marker saying whether the profile applies it. The token transformation is not serialized at all: it is derived from these on load, so the applied lemma map cannot differ from the saved one.
Deliberately NOT a generic object-graph dump (unlike e.g. JLD2): every field is encoded by hand into a small, versioned schema, so every file is fully inspectable/diffable/portable and there is nothing pointer- or code-shaped to accidentally serialize.
Load it back with load_profile, or package it for distribution with zip_profile. A TokenizationConfig with custom (non-empty) generators errors clearly rather than silently mis-saving.
TextSearch.sparse_coo — Method
sparse(cols::AbstractVector{<:Dict}, m=0; minweight=1e-9)
sparse_coo(cols::AbstractVector{<:Dict}, minweight=1e-9)Creates a sparse matrix from an array of Dict sparse vectors.
Example
julia> cols = [Dict{UInt32,Float32}(1 => 0.5), Dict{UInt32,Float32}(2 => 0.8)];
julia> Matrix(sparse(cols))
2×2 Matrix{Float32}:
0.5 0.0
0.0 0.8TextSearch.sparsedot — Method
sparsedot(a::SparseVector, b::SparseVector; small_threshold::Int=30, ratio_threshold::Float64=3.0)Adaptive dot product between two SparseVectors:
- both sides have fewer than
small_thresholdstored entries, or their sizes are withinratio_thresholdof each other: a plain linear merge (the same algorithmLinearAlgebra.dotalready uses forSparseVector). - otherwise (one side much larger than the other — e.g. a short query against a long document): a Hwang-Lin/galloping merge — for each stored entry of the smaller side, an exponential ("galloping") search with memory of the last found position locates its match in the larger side in
O(log gap)instead of a full linear scan.
This is deliberately not a method of LinearAlgebra.dot(::SparseVector,::SparseVector): SparseArrays already owns that method (a plain merge), and shadowing it package-wide would be a much more aggressive form of type piracy than TextSearch's existing Dict overloads of dot/normalize! (neither SparseVector nor dot belong to TextSearch, whereas extending dot for Dict doesn't collide with any other package's definitions). evaluate for SparseVector uses sparsedot internally.
Example
julia> using SparseArrays
julia> sparsedot(sparsevec(UInt32[1, 2], Float32[0.6, 0.8], 10), sparsevec(UInt32[2], Float32[1.0], 10))
0.8f0TextSearch.stopword_candidates — Function
stopword_candidates(voc::Vocabulary, threshold::Real=0.5) -> Vector{String}
stopword_candidates(model::VectorModel, threshold::Real=0.5) -> Vector{String}Flags tokens whose document-frequency ratio getndocs(voc, id) / gettrainsize(voc) exceeds threshold as stopword candidates, sorted by decreasing ratio (most extreme first). A frequency heuristic only – it does not inspect token semantics – so results should be reviewed before being wired into a TokenPipeline's stopwords stage.
Detection is per spelling; removal is per word. Under a profile that keeps case (lc=false) a function word is several vocabulary tokens, and the threshold measures each separately – so it sees the fraction of documents containing a spelling, not a word. Measured on 272,466 Spanish Wikipedia paragraphs at threshold=0.1, detection caught the four commonest paragraph-initial forms (El, En, La, Los) and let 52 twins through, including Las (22,040 documents, df 0.081), A (19,701), Se (17,315), Por (12,891) and De (9,800) – the same function word filtered in one casing and indexed as content in the other. So once a spelling is flagged, every other casing of it that the vocabulary holds is flagged with it.
The alternative – pooling document frequencies across casings before comparing to the threshold – was rejected: the pooled value is not observable from these counters (a document containing both de and De is counted twice, and the sum can exceed 1), and it would shift the calibration of a threshold that was measured per spelling.
Casing is folded; diacritics are not. Folding diacritics would merge té/te, más/mas and sí/si, deleting content words – which is what a profile with del_diac=false exists to prevent. The cost of folding case is small and worth naming: an acronym colliding with a function word goes too (ES, 41 documents, follows es), which lowercasing profiles already did.
Example
candidates = stopword_candidates(voc, 0.5)
textconfig = TextConfig(voc.textconfig; pipeline=TokenPipeline(stopwords=Set(candidates)))TextSearch.table — Method
table(model::VectorModel, TableConstructor)Builds a Tables.jl-compatible table (e.g., a DataFrame) with one row per token, using TableConstructor (e.g. DataFrame) as the row-table constructor. Columns are token, ndocs, occs, and weight.
Example
julia> using DataFrames
julia> corpus = ["hello world", "hello there", "the cat sat"];
julia> voc = Vocabulary(TextConfig(), corpus; verbose=false);
julia> model = VectorModel(IdfWeighting(), TfWeighting(), voc);
julia> table(model, DataFrame)
6×4 DataFrame
Row │ token ndocs occs weight
│ String Int32 Int32 Float32
─────┼────────────────────────────────
1 │ hello 2 2 0.485427
2 │ world 1 1 1.22239
3 │ there 1 1 1.22239
4 │ the 1 1 1.22239
5 │ cat 1 1 1.22239
6 │ sat 1 1 1.22239TextSearch.table — Method
table(voc::Vocabulary, TableConstructor)Builds a Tables.jl-compatible table (e.g., a DataFrame) with one row per token, using TableConstructor (e.g. DataFrame) as the row-table constructor. Columns are token, ndocs, and occs.
Example
julia> using DataFrames
julia> corpus = ["hello world", "hello there", "the cat sat"];
julia> voc = Vocabulary(TextConfig(), corpus; verbose=false);
julia> table(voc, DataFrame)
6×3 DataFrame
Row │ token ndocs occs
│ String Int32 Int32
─────┼──────────────────────
1 │ hello 2 2
2 │ world 1 1
3 │ there 1 1
4 │ the 1 1
5 │ cat 1 1
6 │ sat 1 1TextSearch.token2id — Method
token2id(voc::Vocabulary, tok::AbstractString)::UInt32Looks up the id of tok in voc; returns 0 when tok is out of vocabulary.
Example
julia> voc = Vocabulary(TextConfig(), ["hello world"]; verbose=false);
julia> token2id(voc, "hello")
0x00000001
julia> token2id(voc, "unknown")
0x00000000TextSearch.tokenize_and_append! — Method
tokenize_and_append!(voc::Vocabulary, corpus; isnormalized::Bool=false)Parse each document in the given corpus and appends each token to the vocabulary.
Example
julia> voc = Vocabulary(TextConfig(), 0, 0);
julia> tokenize_and_append!(voc, ["hello world", "hello there"]);
julia> vocsize(voc)
3TextSearch.update_voc! — Method
update_voc!(voc::Vocabulary, another::Vocabulary)
update_voc!(pred::Function, voc::Vocabulary, another::Vocabulary)Update voc vocabulary using another vocabulary. Optionally a predicate can be given to filter vocabularies.
Note 1: corpuslen remains unchanged (the structure is immutable and a new Vocabulary should be created to update this field). Note 2: Both voc and another vocabularies should had been created with a compatible TextConfig to be able to work on them.
Example
julia> cfg = TextConfig();
julia> voc = Vocabulary(cfg, 0, 0);
julia> update_voc!(voc, Vocabulary(cfg, ["hello world"]; verbose=false));
julia> vocsize(voc)
2TextSearch.vectorize! — Method
vectorize!(buff::VectorizeBuffer, model::VectorModel, text; normalize=true, minweight=1e-6, isnormalized::Bool=false)Tokenizes text and weights it using model's local/global weighting scheme, returning the result as a SparseVector{Float32,Int32}; entries with a weight below minweight are dropped, and the result is L2-normalized unless normalize=false. buff is used as scratch space (see VectorizeBuffer; tokenization scratch space is borrowed separately via tokenizerbuffer). See vectorize for a version that manages the scratch buffer for you.
Example
julia> corpus = ["hello world", "hello there", "the cat sat"];
julia> voc = Vocabulary(TextConfig(), corpus; verbose=false);
julia> model = VectorModel(IdfWeighting(), TfWeighting(), voc);
julia> buff = TextSearch.VectorizeBuffer();
julia> TextSearch.vectorize!(buff, model, "hello world")
6-element SparseArrays.SparseVector{Float32, Int32} with 2 stored entries:
[1] = 0.369076
[2] = 0.929399TextSearch.vectorize — Method
vectorize(model::VectorModel, text; normalize=true, minweight=1e-6, isnormalized::Bool=false)Computes the weighted sparse vector (a SparseVector{Float32,Int32}) representation of text under model. text can be a string or a list of strings (a multi-field document).
Example
julia> corpus = ["hello world", "hello there", "the cat sat"];
julia> voc = Vocabulary(TextConfig(), corpus; verbose=false);
julia> model = VectorModel(IdfWeighting(), TfWeighting(), voc);
julia> vectorize(model, "hello world")
6-element SparseArrays.SparseVector{Float32, Int32} with 2 stored entries:
[1] = 0.369076
[2] = 0.929399TextSearch.vectorize_corpus — Method
vectorize_corpus(model::VectorModel, corpus; normalize=true, minweight=1e-6, isnormalized::Bool=false, verbose=true)Computes the vectorize representation of every document in corpus, processed in parallel across threads (the batch size is picked automatically from the size of corpus, as in SimilaritySearch.getminbatch).
Example
julia> corpus = ["hello world", "hello there", "the cat sat"];
julia> voc = Vocabulary(TextConfig(), corpus; verbose=false);
julia> model = VectorModel(IdfWeighting(), TfWeighting(), voc);
julia> vectorize_corpus(model, corpus; verbose=false)[1]
6-element SparseArrays.SparseVector{Float32, Int32} with 2 stored entries:
[1] = 0.369076
[2] = 0.929399TextSearch.vocabulary_from_thesaurus — Method
vocabulary_from_thesaurus(textconfig::TextConfig, tokens::AbstractVector)Creates a Vocabulary directly from a list of tokens (a thesaurus), instead of tokenizing a corpus; every token is registered with occs=1 and ndocs=1.
Example
julia> voc = vocabulary_from_thesaurus(TextConfig(), ["cat", "dog", "bird"]);
julia> vocsize(voc)
3
julia> token2id(voc, "cat")
0x00000001TextSearch.vocsize — Method
vocsize(voc::Vocabulary)Number of unique tokens in voc.
TextSearch.with_applied — Method
with_applied(p::TextProfile; stopwords, lemmas, query_expansion) -> TextProfilep with different artifacts applied, rematerializing the TextConfig. This is how a consumer turns lemmatization off (textsearch search --no-lemmas) or how a refit decides to apply a base's carried map: change the marker, not the pipeline by hand.
TextSearch.zip_profile — Function
zip_profile(dir::AbstractString, zippath::AbstractString=dir * ".zip") -> zippathPackages a profile directory (as written by save_profile) into a single .zip archive at zippath, ready to distribute as one file. load_profile reads a .zip produced this way directly (no extraction needed).
TextSearch.AppliedArtifacts — Type
AppliedArtifacts(; stopwords=false, lemmas=false, query_expansion=false)Which of a profile's artifacts are in play, as opposed to merely carried.
The distinction is the point of a base profile: a generic model computes a lemma map and a query_expansion network, but whether to apply them belongs to the model being tuned from it. A tuned profile that declines lemmatization simply does not apply the map, and one that never needed it does not carry it either.
stopwords and lemmas are tokenization-time and enter the config a profile derives, which serves fitting, indexing and searching alike – they must, since the vocabulary was counted under it. query_expansion is query-time and works on tokens rather than text, so it does not enter the config; it is applied by expand_query!.
There is no entry for orthographic variants, and that is deliberate: a variant map is a pure function of the vocabulary, so a profile does not carry one to apply or decline. A consumer derives it with derive_variants when it wants to correct a query, and whether to correct at all is a QueryPolicy – a property of the query, not of the profile.
TextSearch.BOW — Type
BOW = Dict{UInt32,Int32}A bag of words: a sparse token id => occurrence count mapping for a single document, as produced by bagofwords/bagofwords!.
Example
julia> BOW(0x00000001 => 2, 0x00000002 => 1)
Dict{UInt32, Int32}(0x00000002 => 1, 0x00000001 => 2)TextSearch.BinaryGlobalWeighting — Type
BinaryGlobalWeighting()The weight is 1 for known tokens, 0 for out of vocabulary tokens
TextSearch.BinaryLocalWeighting — Type
BinaryLocalWeighting()The weight is 1 for known tokens, 0 for out of vocabulary tokens
TextSearch.CombineWeighting — Type
CombineWeightingAbstract type for the strategies that turn a per-token, per-class distribution and its empirical entropy into a single global weight (via the internal combine_weight function), used by EntropyWeighting. Available strategies: NormalizedEntropy and SigmoidPenalizeFewSamples.
TextSearch.EntropyWeighting — Type
EntropyWeighting()A GlobalWeighting that scores each token by the empirical entropy of its occurrences across document classes/labels, instead of the plain document frequency used by IdfWeighting — tokens whose occurrences concentrate on few classes get a higher weight than tokens spread uniformly across all classes. Since it needs document labels, it is not built via the generic VectorModel(gw, lw, voc) constructor; use VectorModel(::EntropyWeighting, lw, voc, corpus, labels) instead.
Example
julia> corpus = ["me gusta", "me encanta", "no me gusta", "odio esto"];
julia> labels = ["pos", "pos", "neg", "neg"];
julia> voc = Vocabulary(TextConfig(), corpus; verbose=false);
julia> model = VectorModel(EntropyWeighting(), TfWeighting(), voc, corpus, labels; verbose=false);
julia> vectorize(model, "me gusta")
6-element SparseArrays.SparseVector{Float32, Int32} with 1 stored entry:
[1] = 1.0TextSearch.FreqWeighting — Type
FreqWeighting()Frequency weighting
TextSearch.GlobalWeighting — Type
GlobalWeightingAbstract type for global weighting
TextSearch.IdIntWeight — Type
IdIntWeight(id, weight)Stores a pair of objects to be accessed. Similar to IdWeight but it stores an integer weight
TextSearch.IdWeight — Type
IdWeight(id, weight)Stores a pair of entries of the posting lists
TextSearch.IdfWeighting — Type
IdfWeighting()Inverse document frequency weighting
TextSearch.LineageStep — Type
LineageStep(stage::Symbol, params::Dict{String,Any})One step in how a profile came to be: :fit from a corpus, :merge of several profiles, or :refit against a dataset sample. params carries the stage's own details (a fit's encoder and corpus size, a merge's source count, a refit's kappa), as JSON-serializable scalars.
This replaces the encoder field, which had drifted into recording lineage anyway – a merge wrote kind=:merged and a refit kind=:refit into a field named for the encoder.
TextSearch.LocalWeighting — Type
LocalWeightingAbstract type for local weighting
TextSearch.NormalizedEntropy — Type
NormalizedEntropy()A CombineWeighting that weights a token as 1 - entropy / maxent: tokens that discriminate well between classes (low entropy) get a weight close to 1, tokens spread uniformly across classes (entropy close to maxent) get a weight close to 0.
Example
julia> corpus = ["me gusta", "me encanta", "no me gusta", "odio esto"];
julia> labels = ["pos", "pos", "neg", "neg"];
julia> voc = Vocabulary(TextConfig(), corpus; verbose=false);
julia> model = VectorModel(EntropyWeighting(), TfWeighting(), voc, corpus, labels; mindocs=1, comb=NormalizedEntropy(), verbose=false);
julia> vectorize(model, "me gusta")
6-element SparseArrays.SparseVector{Float32, Int32} with 2 stored entries:
[1] = 0.9996127
[2] = 0.0278291TextSearch.QueryPipeline — Type
QueryPipeline(; policy=QueryPolicy(), variants=nothing, expansion=nothing, distances=nothing)Everything the query side of a model needs, as plain data: the QueryPolicy to answer a query under, the orthographic variant map correction bridges with, and the expansion network with its optional distances.
There is one query pipeline and it lives here, which is the point of this type. Before it, the work existed twice with each copy able to do something the other could not:
- The vector-level path (
expand_query!, called from both inverted files) weighted the terms it added, by rank or by distance. But it iterated a query vector's nonzeros – already token ids – so a typed spelling absent from the vocabulary never reached it and correction was impossible by construction. - The string-level path (the
textsearchCLI's own) corrected first and expanded only from the commonest spelling of each corrected group, but added its terms unweighted, because it fed aSetfor grep-like matching.
Unifying them is therefore not a choice between the two: query_tokens runs on strings, where correction is possible, and emits weights, so nothing is lost. What each consumer does with those weights is its own business – see querybow, queryvector and querytokenset.
variants may be nothing, which disables correction as surely as policy.correction = :off; derive one with derive_variants once per model rather than once per query, since it is a pure function of the vocabulary and costs 0.6s over 479,245 tokens.
TextSearch.QueryPolicy — Type
QueryPolicy(; correction=:auto, expansion=true, expansion_k=0, negligible_ratio=50)How a query should be treated, as plain data travelling beside the query text.
The shape follows what commercial search does: a query is answered with the most probable reading of it, and the person is always offered the same query answered literally. Neither correcting nor expanding is a property of the profile – the same profile serves both – so it is not baked into the artifact; it is a mark on the query, and every consumer (the CLI, an application, a service endpoint) passes one of these instead of inventing its own flags.
correction – orthographic bridging, see resolve_query_tokens
:auto(default) – bridge only where the evidence says the typed spelling is wrong: it is not in the vocabulary, or it is negligible beside a commoner spelling of the same word. Where it bridges it replaces, because that is what correcting means.:off– search exactly what was typed. This is the "search instead for …" escape, and a consumer that corrects by default owes the person a way to reach it.:always– bridge every token whether or not anything suggests it is wrong. Trades precision for reach, and it is the only way to reach an accented alternative of a spelling that is itself common (typedpracticois not negligible besidepráctico, so:autoleaves it alone).
negligible_ratio
What "negligible" means: a spelling holding less than 1 / negligible_ratio of the documents of its group's commonest spelling. 1 makes every spelling but the commonest negligible, and Inf makes none of them so. Measured on 272,466 Spanish Wikipedia paragraphs, the ratio between a typed spelling and its commonest alternative decays smoothly – of the 517 map keys that are themselves vocabulary tokens, 266 sit in [1,2) and the counts fall through 87, 50, 39, 23 and 15 to [35,50), then 6, 5, 11 and 15 above – so there is no gap to snap to, but the region around the default is sparse and the choice is not delicate. At 50, musica (9 documents against música's 4,404) is corrected while granada (43 against Granada's 1,438, ratio 33) is not.
expansion
Whether to widen the query with the profile's expansion network, and expansion_k how many neighbours per token (0 = all the profile stored). On by default and turned off on request, the same way as correction: both are guesses about intent, so both are answerable literally.
TextSearch.QueryResolution — Type
QueryResolution(tokens, resolved)The outcome of resolve_query_tokens: tokens is what to search with, and resolved records how each typed token got there.
Reportability is the point of the second field. What this does is spelling correction – a simple, deterministic kind, covering case and diacritics but not transpositions or wrong letters – and a search that silently substitutes what the user asked for owes them a way to see it. "Showing results for X" needs this structure; so does deciding not to correct at all.
TextSearch.QueryTerm — Type
QueryTerm(token, source, factor, reason)One term to search for, where it came from, and how much of that source's weight it carries.
reason is :typed for a spelling the person wrote, :derived or :variant for a correction (see ResolvedToken), and :expansion for a neighbour the network contributed. For the first three source == token and factor == 1: they are the same word differently spelled, and nothing about a corrected spelling makes it a weaker match than the typo it replaced.
An expansion term names the query token whose list it came from, and factor is exp(-d) or 1/rank. It is a factor rather than an absolute weight because that is what a weighted representation needs: queryvector gives the neighbour factor times the source term's own weight in the query, which is what expand_query! did and what its tests pin. A neighbour of a rare, high-idf query word should enter heavier than a neighbour of a common one.
TextSearch.ResolvedQuery — Type
ResolvedQuery(terms, resolution)What query_tokens produces: the QueryTerms to search for, and the QueryResolution recording what correction did to each spelling that was typed – so a consumer can render explain and offer the literal query back.
TextSearch.ResolvedToken — Type
ResolvedToken(typed, ndocs, kept, added, dominant, dominantdocs)What happened to one token of a query: the form as typed, how many documents hold that exact spelling (ndocs, zero when it is not a vocabulary token), whether that spelling was kept in the search set, every form that was added for it as form => reason, dominant – the commonest spelling of its group, which is the one allowed to contribute query expansion (see expansion_sources) – and dominantdocs, how many documents hold that. dominant is empty only when no spelling of the group is in the vocabulary at all.
Both counts are carried because a correction is only explicable as a comparison. "appears in only 1,020 documents" is not a reason at corpus scale, where 1,020 documents is a perfectly ordinary word; "1,020 against música's 219,000" is.
kept is false exactly when the token was corrected: something was bridged for it and the evidence said the typed spelling was wrong – absent from the vocabulary, or negligible beside a commoner spelling of the same word. Together with ndocs it tells a consumer which of three things to report: a spelling that was not there at all, one that was there but too rare to be what was meant, or an enrichment that left the typed form standing.
Reasons currently produced:
:derived– a spelling computed from the folded form, per_derivable_forms:madridreachingMadrid,usareachingUSA. Nothing is stored for these.:variant– a spelling that had to be stored, because it cannot be computed:practicoreachingpracticó,leonreachingLeón.
The reason is a symbol rather than a Bool so that a future mechanism reports through the same channel without changing this signature. The obvious next one is edit-distance correction – guerar -> guerra, a transposition no fold can reach – which SimilaritySearch can index the vocabulary's strings for; it would report as :edit. Keeping the reasons open matters because a deterministic fold and a distance guess are different kinds of claim, and a consumer telling the user what was searched should be able to distinguish them. Deliberately not built yet.
TextSearch.SigmoidPenalizeFewSamples — Type
SigmoidPenalizeFewSamples()Like NormalizedEntropy, but additionally down-weights tokens seen in very few documents (low ndocs) via a sigmoid-like penalty on log2(ndocs), so that rare tokens don't get an unduly high weight just because they appear in a single class.
Example
julia> corpus = ["me gusta", "me encanta", "no me gusta", "odio esto"];
julia> labels = ["pos", "pos", "neg", "neg"];
julia> voc = Vocabulary(TextConfig(), corpus; verbose=false);
julia> model = VectorModel(EntropyWeighting(), TfWeighting(), voc, corpus, labels; mindocs=1, comb=SigmoidPenalizeFewSamples(), verbose=false);
julia> vectorize(model, "me gusta")
6-element SparseArrays.SparseVector{Float32, Int32} with 2 stored entries:
[1] = 0.99974245
[2] = 0.02269659TextSearch.TextModel — Type
TextModelAbstract type for text-to-vector weighting models (see VectorModel).
TextSearch.TextProfile — Type
TextProfile(model; stopwords, lemmas, query_expansion, query_expansion_distances, applied, lineage)A finished, portable text model: the vocabulary and weights in model, plus the artifacts a corpus produced, plus the lineage that says how it got here.
Each artifact is stored once, and model.voc.textconfig is rebuilt by the constructor as the materialization of the profile's policy plus whichever artifacts applied selects. That is a structural guarantee rather than a convention: there is no way to hold a profile whose tokenizer applies a different lemma map than the one it saves.
Whether a profile is a base or a tuned model is read off the lineage rather than declared – see isbase/istuned – so it cannot contradict the facts, and it answers "where did this come from?" at the same time.
Save and load with save_profile/load_profile, combine batches of one corpus with merge_profiles, and adapt one to a dataset with refit_profile.
TextSearch.TfWeighting — Type
TfWeighting()Term frequency weighting
TextSearch.TpWeighting — Type
TpWeighting()Term probability weighting
TextSearch.VectorModel — Type
VectorModel{_G<:GlobalWeighting, _L<:LocalWeighting}Combines a Vocabulary with a local/global term-weighting scheme (e.g. TfWeighting+IdfWeighting for classical TF-IDF) to turn bags of words into weighted sparse vectors (SparseVector{Float32,Int32}) via vectorize/vectorize!. Build one with VectorModel(gw, lw, voc).
Fields
global_weighting: theGlobalWeightingscheme (e.g. IDF), applied per-token, corpus-wide.local_weighting: theLocalWeightingscheme (e.g. TF), applied per-token, per-document.voc: the underlyingVocabulary.maxoccs: maximum per-token occurrence count invoc, used by some local weightings.weight: precomputed per-token global weight (weight[tokenID]).
Example
julia> corpus = ["hello world", "hello there", "the cat sat"];
julia> voc = Vocabulary(TextConfig(), corpus; verbose=false);
julia> model = VectorModel(IdfWeighting(), TfWeighting(), voc);
julia> vectorize(model, "hello world")
6-element SparseArrays.SparseVector{Float32, Int32} with 2 stored entries:
[1] = 0.369076
[2] = 0.929399TextSearch.VectorModel — Method
VectorModel(ent::EntropyWeighting, lw::LocalWeighting, voc::Vocabulary, corpus::AbstractVector, labels::AbstractVector;
mindocs=3,
smooth=3,
weights=:balance,
comb::CombineWeighting=NormalizedEntropy(),
verbose=true
)Creates a VectorModel with EntropyWeighting as its global weighting scheme. Unlike the generic VectorModel(gw, lw, voc) constructor, this one needs the actual corpus and a matching labels vector (one label per document) to compute, for each token, its occurrence distribution across classes and the resulting entropy-based weight.
mindocs: tokens occurring in fewer thanmindocsdocuments get weight0.smooth: additive (Laplace-like) smoothing applied to the per-class occurrence counts before computing entropy, to avoid zero counts.weights: how to reweight classes before computing entropy —:balancecompensates for class-size imbalance,:none(ornothing) leaves classes unweighted, or pass anAbstractVectorof per-class weights directly.comb: theCombineWeightingstrategy combining entropy and evidence into the final per-token weight.
Example
julia> corpus = ["me gusta", "me encanta", "no me gusta", "odio esto"];
julia> labels = ["pos", "pos", "neg", "neg"];
julia> voc = Vocabulary(TextConfig(), corpus; verbose=false);
julia> model = VectorModel(EntropyWeighting(), TfWeighting(), voc, corpus, labels; mindocs=1, verbose=false);
julia> vectorize(model, "me gusta")
6-element SparseArrays.SparseVector{Float32, Int32} with 2 stored entries:
[1] = 0.9996127
[2] = 0.0278291TextSearch.VectorModel — Method
VectorModel(gw::GlobalWeighting, lw::LocalWeighting, voc::Vocabulary; weight=nothing)Creates a VectorModel for the given vocabulary voc using the local weighting lw (e.g. TfWeighting) and global weighting gw (e.g. IdfWeighting). The per-token global weight vector is computed from voc unless weight is given explicitly (e.g. when reusing weights computed elsewhere, such as EntropyWeighting).
Example
julia> corpus = ["hello world", "hello there", "the cat sat"];
julia> voc = Vocabulary(TextConfig(), corpus; verbose=false);
julia> model = VectorModel(IdfWeighting(), TfWeighting(), voc);
julia> length(model.weight)
6TextSearch.VectorModel — Method
VectorModel(voc::Vocabulary) -> VectorModelTF-IDF over voc: shorthand for VectorModel(IdfWeighting(), TfWeighting(), voc).
It has a name because that combination is what nearly every use wants – 31 of the 43 in this repository – and spelling out two weighting schemes to say "the usual one" reads as though a choice were being made. The three-argument form is how the other twelve say what they mean.
TextSearch.VectorizeBuffer — Type
VectorizeBuffer(n=128)Pooled per-thread scratch space for vectorize!: ids accumulates every in-vocabulary token id seen in a document (with repeats), which is then sorted and run-length-encoded to recover per-token occurrence counts — the same merge-based strategy sum(::AbstractVector{<:SparseVector}) uses, avoiding the Dict allocation/hashing a BOW would need for this per-call, performance-sensitive path.
TextSearch.Vocabulary — Type
VocabularyHolds the token ⇄ id mapping produced while parsing a corpus, along with per-token occurrence and document-frequency counters. A Vocabulary is the entry point of the processing pipeline: it is built from a TextConfig and a corpus, and is later consumed by VectorModel, BM25Scorer, and bagofwords.
Fields
textconfig: theTextConfigused to tokenize the corpus that produced this vocabulary.token:id -> tokenstring table.occs:id -> total number of occurrencesof the token across the corpus.ndocs:id -> number of documentscontaining the token.token2id:token -> idreverse mapping (0means "unknown token").trainsize: number of documents used to build the vocabulary.numtokens: total number of (non-unique) tokens seen while building the vocabulary.
Example
julia> voc = Vocabulary(TextConfig(), ["hello world", "hello there"]; verbose=false);
julia> vocsize(voc)
3
julia> token2id(voc, "hello")
0x00000001TextSearch.Vocabulary — Method
Vocabulary(textconfig::TextConfig, corpus; buffsize=2^16, isnormalized::Bool=false, verbose=true)Tokenizes corpus under textconfig and builds the resulting Vocabulary. corpus can be any vector of documents (each document a string or a list of strings) or an iterable/generator of documents (useful for corpora too large to fit in memory); in the generator case, documents are consumed and tokenized in batches of buffsize.
Example
julia> voc = Vocabulary(TextConfig(), ["hello world", "hello there"]; verbose=false);
julia> vocsize(voc), gettrainsize(voc)
(3, 2)TextSearch.Vocabulary — Method
Vocabulary(textconfig::TextConfig, trainsize::Int, numtokens::Int)Creates an empty Vocabulary (no tokens registered yet) preallocated with capacity hints based on trainsize (following Heaps' law). trainsize and numtokens may be 0 when unknown ahead of time; use push_token! or tokenize_and_append! to fill it, or use Vocabulary(textconfig, corpus) to build it directly from a corpus.
Example
julia> voc = Vocabulary(TextConfig(), 0, 0);
julia> TextSearch.push_token!(voc, "cat"; occs=1, ndocs=1)
0x00000001
julia> vocsize(voc)
1SimilaritySearch.Intersections._remove_empty! — Method
_remove_empty!(L, P)Inplace removal of empty lists
SimilaritySearch.Intersections._sort! — Method
_sort!(L, P)Adaptive bubble sort, efficient than other approaches because we expect a few sets and almost sorted
SimilaritySearch.Intersections.binarysearch — Function
binarysearch(A, x, sp=1, ep=length(A))
Finds the insertion position of x in A in the range sp:ep
SimilaritySearch.Intersections.bk! — Function
bk!(output, L, P, findpos::Function=doublingsearch) -> int. sizeComputes the intersection of a list of posting lists using the Barbay and Kenyon algorithm.
See umerge! if you need to change how matches are captured (onmatch! function).
SimilaritySearch.Intersections.bkt! — Function
bkt!(output, L, [P,], [findpos::Function=doublingsearch]; t::Int=length(L)) -> num. matchesBarybay & Kenyon t-thresholds
See umerge! if you need to change how matches are captured (onmatch! function).
SimilaritySearch.Intersections.doublingsearch — Function
doublingsearch(A, x, sp=1, ep=length(A))
Finds the insertion position of x in A, starting at sp
SimilaritySearch.Intersections.doublingsearchrev — Function
doublingsearchrev(A, x, sp=1, ep=length(A))
Finds the insertion position of x in A, starting at the end
SimilaritySearch.Intersections.imerge2! — Method
imerge2!(output, A, B) -> num. matchesComputes the intersection of A and B (sorted arrays), using a merge-like algorithm, and stores it in output or calls onmatch(A, i, B, j) on every match. See bk! for how change the behaviour of matches.
SimilaritySearch.Intersections.seqsearch — Function
seqsearch(A, x, sp=1, ep=length(A))
Sequential search, i.e., it starts from sp to ep
SimilaritySearch.Intersections.seqsearchrev — Function
seqsearchrev(A, x, sp=1, ep=length(A))
Reverse sequential search, i.e., it starts from ep to sp
SimilaritySearch.Intersections.svs_! — Function
svs(postinglists, intersect2=baezayates!) -> outputComputes the intersection of the ordered lists in postinglists using a small vs small strategy. Accepts an intersection algorithm of two sets.
This method does not give explicit support for onmatch2!
SimilaritySearch.Intersections.umerge! — Function
umerge!(output, L, P=ones(Int32, length(L_)); t::Int=1) -> num. matchesMerges posting lists in L and saves the union in output. The merge result is stored into output array. You can customize how to do this specializing the onmatch!(output, L, P, t::Int) function.
Arguments:
L: The array of posting lists, the array can be destroyed in the process.P: The array of current positions in posting lists, i.e., initial state as an array of ones of size $|L|$.t: Computes t-thresholds, i.e., t from 1 (union) to |L| (intersection) of posting lists inLusingfindposstoring the result set inoutput.
About the callback function
output, L and P are the arguments same than the input, while t is the actual number of lists having the match. Note 1: you should access L[i][P[i]] to get the entry of the ith list, i.e., $1 \leq t \leq |P|$. Note 2: L and P as container lists will be also modified, the contained lists remain untouched.
SimilaritySearch.Intersections.xmerge! — Function
xmerge!(output, L, P=ones(Int32, length(L_)); t::Int=1) -> num. matchesSolves t-threshold set operation using other algorithms choosing among them by given t
Arguments:
output: vector like to store the t-threshold setL: the list of posting lists to be merged. The posting lists are left untouched but the container is modified.P: indices of the current merging-state (idem toL)t: the threshold, i.e.,t=1(union) ...t=|L|performs intersection)
Simple wrapper around other specific operations depending on t value
See umerge! if you need to modify the output behaviour.
Base.length — Method
length(idx::AbstractInvertedFile)Number of indexed elements (i.e., objects with postings already built). This can be less than length(database(idx)) if db was grown (e.g. via push_item!(database(idx), obj) or append_items!(database(idx), items) directly) without a following index! call to catch up – mirrors SearchGraph's length/len contract.
SimilaritySearch.InvertedFiles._index_block! — Method
_index_block!(idx::AbstractInvertedFile, ctx::InvertedFileContext, sp::Int, n::Int)Per-type hook for index!: builds postings and any bookkeeping (e.g. sizes/doclens) for database(idx)[sp:n], resizing bookkeeping vectors as needed. The caller (index!) updates idx.len[] afterward.
SimilaritySearch.InvertedFiles.convertident — Method
convertident(u)Converts an element of an identiterator fallback into a plain key/id, discarding any paired weight if present as a Pair.
SimilaritySearch.InvertedFiles.has_exact_fastpath — Method
has_exact_fastpath(dist::PreMetric)::BoolWhether the score computed while merging posting lists (via set_distance_evaluate) is already the exact dist value. When false, search_invfile instead evaluates dist directly against the objects stored in the index's db for every merge candidate — see FallbackInvFileOutput in invfilesearch.jl; raise t above the default 1 to bound how many such evaluations happen per query.
SimilaritySearch.InvertedFiles.identiterator — Method
identiterator(dist::PreMetric, obj)Distance-aware id-only iterator for obj. Defaults to the distance-agnostic identiterator(obj) dispatch tree above; overload this for a specific (DistType, ObjType) pair when the same native object type must generate different candidate ids depending on which distance the enclosing index is built for (e.g. a shingle-based candidate encoding for a sequence distance).
SimilaritySearch.InvertedFiles.identiterator — Method
identiterator(obj)Iterator over the plain ids/keys in obj, for callers that only need to know which ids/keys are present (e.g. InvertedFile building/re-sorting/searching its posting lists, which never need a weight: the handful of distances with an exact fast path score from intersection size and set sizes alone, and any other distance is evaluated directly against the full objects kept in db – see InvertedFile). Dense Vectors are not accepted directly – convert to a SparseVector first (e.g. via SparseArrays.sparse) so the reduction to non-zero components is explicit in the caller's code.
SimilaritySearch.InvertedFiles.search_invfile — Method
search_invfile(idx::InvertedFile, ctx::InvertedFileContext, q, Q, res::AbstractKnnQueue, t)
Find candidates for solving query Q using idx. It calls callback on each candidate (objID, dist)
Arguments
idx: inverted indexq: the query object, only used for distances without an exact fast path (seeInvertedFiles.has_exact_fastpath)Q: the set of involved posting lists, seeselect_posting_listst: threshold (t=1 union, t > 1 solves the t-threshold problem); for distances without an exact fast path,talso bounds how many realevaluatecalls happen per query — raise it to reduce cost.
SimilaritySearch.InvertedFiles.select_posting_lists — Method
select_posting_lists(idx::AbstractInvertedFile, ctx::InvertedFileContext, q)Fetches and prepares the involved posting lists to solve q
SimilaritySearch.InvertedFiles.set_distance_evaluate — Method
set_distance_evaluate(dist::PreMetric, intersection::Integer, size1::Integer, size2::Integer)Computes a score for a candidate found while merging posting lists, given the intersection size of the matching posting lists and the total number of non-zero entries of each of the two compared elements (size1, size2). Only defined for the handful of distances with an exact closed form (see has_exact_fastpath) — the resulting value is exact, computed purely from these three integers, with no need to touch the original objects. For any other dist, search_invfile does not call this function at all — it evaluates dist directly against the stored objects for every merge candidate instead (see FallbackInvFileOutput in invfilesearch.jl); use t > 1 to bound how many such evaluations happen per query.
SimilaritySearch.InvertedFiles.sort_postinglist! — Method
sort_postinglist!(adj::AbstractAdjList, N)Sorts a single posting list N (as returned by neighbors(adj, tokenID)) back into the order the merge/search algorithms rely on: ascending by id, for plain token adjacency (UInt32). Override for a different concrete adjacency element type (e.g. a compressed encoding).
SimilaritySearch.append_items! — Function
append_items!(idx, ctx, items)Appends all items elements into the index idx. It work in parallel using all available threads. Grows database(idx) then delegates the actual indexing work to index!, which is the sole emitter of the :add! log event for this batch – this function itself does not log, per the exactly-once contract documented on OBSERVE.
Arguments:
idx: The inverted indexitems: The database of sparse objects, it can be only indices if each object is a list of integers or a set of integers,SparseVectors, among other combinations (seeidentiteratorfor the exact set of natively supported object types; dense vectors are not accepted directly — convert withSparseArrays.sparsefirst).n: The number of items to insert (defaults to all)
SimilaritySearch.index! — Method
index!(idx::AbstractInvertedFile, ctx::InvertedFileContext)Builds postings for every object already present in database(idx) but not yet indexed, i.e. the block database(idx)[length(idx)+1 : length(database(idx))]. It is a no-op (nothing is logged) if db has not grown past length(idx). Mirrors SearchGraph's index!: grow database(idx) first (e.g. push_item!(database(idx), obj) / append_items!(database(idx), items)), then call index!(idx, ctx) to catch up. push_item!/append_items! on idx itself already call this internally, so it only needs to be called explicitly when db was grown directly. This is the sole emitter of the :add! log event for the batch it indexes – see the exactly-once contract documented on OBSERVE.
SimilaritySearch.push_item! — Function
push_item!(idx::AbstractInvertedFile, ctx::InvertedFileContext, obj)Inserts a single element into the index. This operation is not thread-safe.
Arguments
idx: The inverted indexctx: the index's contextobj: The object to be indexed
SimilaritySearch.search — Method
search(idx::AbstractInvertedFile, ctx::InvertedFileContext, q, res::AbstractKnnQueue; t=1)Searches q in idx using the cosine dissimilarity, it computes the full operation on idx. res specify the query
SimilaritySearch.InvertedFiles.AbstractInvertedFile — Type
abstract type AbstractInvertedFile <: AbstractSearchIndex endAbstract inverted file; the concrete data structure is InvertedFile.
SimilaritySearch.InvertedFiles.DictInvertedFile — Type
const DictInvertedFile{DistType, KeyType, DbType} = InvertedFile{DistType, AdjDict{KeyType, UInt32}, DbType}A dictionary-backed inverted file mapping posting list keys of type KeyType (e.g., String, Vector{UInt8}, NTuple, Int) to document identifiers (UInt32). Empty or non-existent posting lists are never stored in memory or disk, enabling use over arbitrary or massive key spaces.
Constructors
DictInvertedFile(::Type{KeyType}, dist::PreMetric=Dist.Sets.Jaccard(); db::AbstractDatabase=VectorDatabase(Any[]), hint_size::Integer=0)DictInvertedFile(dist::PreMetric=Dist.Sets.Jaccard(); KeyType::Type=Any, db::AbstractDatabase=VectorDatabase(Any[]), hint_size::Integer=0)
SimilaritySearch.InvertedFiles.InvertedFile — Type
InvertedFile(vocsize::Integer, dist::PreMetric=Dist.Sets.Jaccard(); db::AbstractDatabase=VectorDatabase(Any[]))Creates an empty InvertedFile with plain token/set-membership posting lists (AdjType's element type is UInt32), for the given vocabulary size and distance function dist (typically one of the set metrics in Dist.Sets, e.g. Jaccard, Dice, Intersection, CosineSet, RogersTanimoto; or any other PreMetric — e.g. Dist.NormCosine() for sparse-vector/MIPS-style cosine search — via the generic direct-evaluate fallback).
Arguments
vocsize: the vocabulary size of the indexdist: the distance function to be used in searches
Keyword arguments
db: the database that will receive a copy of every indexed object (must supportpush_item!/append_items!for incremental construction, e.g. aVectorDatabase); defaults to an empty, untypedVectorDatabase. Ifdbis passed already non-empty, callindex!once before searching to build postings for its contents.
SimilaritySearch.InvertedFiles.InvertedFile — Type
struct InvertedFile{DistType<:PreMetric, AdjType<:AbstractAdjList, DbType<:AbstractDatabase} <: AbstractInvertedFileA general-purpose inverted index: a sparse matrix-like representation mapping component dimensions (or set elements/tokens) to identifiers (AdjType's element type is UInt32, plain token/set membership; other concrete adjacency element types, e.g. a compressed encoding, can be added by extending getcontainer, internal_push!, and sort_postinglist!). It always keeps the original indexed object in db.
Fields
dist: distance function used at search time (e.g.Dist.Sets.Jaccard(),Dist.NormCosine()).adj: posting lists (non-zero id-elements, in rows).sizes: number of non-zero values in each element (non-zero values in columns); resized/populated only up tolen[].db: the original indexed objects, one per identifier; always populated bypush_item!/append_items!, but may hold more objects than have actually been indexed – seelen.len: number of objects already indexed (postings built); may be less thanlength(database(idx))ifdbwas grown directly without a followingindex!call to catch up.
For a handful of distances (the set metrics in Dist.Sets, see InvertedFiles.has_exact_fastpath) the score computed while merging posting lists is already exact, at O(1) cost. For any other distance (including Dist.NormCosine), every merge candidate is instead scored by evaluating dist directly against the objects stored in db, so results for that path are exact too — the number of such evaluations (hence cost) is controlled by the t-threshold parameter of search; raise t above the default 1 to bound the number of real evaluations per query.
SimilaritySearch.InvertedFiles.PostingList — Type
struct PostingListA single posting list: the (sorted) identifiers of every object containing token tokenID, plain ids with no associated weight (InvertedFile never needs one – see identiterator).
SimilaritySearch.InvertedFiles._index_block! — Method
_index_block!(idx::BM25InvertedFile, ctx::InvertedFileContext, sp::Int, n::Int)Decoupled indexing path: builds postings and doclens for idx.db[sp:n], reading the already-encoded SparseVecViews directly out of db (no re-tokenization). Used by index! when db was grown directly (e.g. push_item!(database(idx), docvec)) rather than through the fused append_items!/push_item! entry points.
SimilaritySearch.append_items! — Method
append_items!(idx::BM25InvertedFile, ctx::InvertedFileContext, corpus; kwargs...)Adds every document in corpus to idx, computing each one's bag of words under idx.voc first. corpus can hold raw text (AbstractString), already-tokenized TokenizedText, or pre-tokenized string vectors; a corpus of already-computed BOWs is accepted directly by the generic SimilaritySearch.append_items! method without going through this conversion. See also push_item!.
Example
julia> voc = Vocabulary(TextConfig(), ["hello world", "hello there"]; verbose=false);
julia> invfile = BM25InvertedFile(voc);
julia> ctx = InvertedFileContext();
julia> append_items!(invfile, ctx, ["hello world", "hello there"]);
julia> length(invfile)
2SimilaritySearch.push_item! — Method
push_item!(idx::BM25InvertedFile, ctx::InvertedFileContext, doc)Adds a single document doc to idx, computing its bag of words under idx.voc first. doc can be raw text (AbstractString), already-tokenized TokenizedText, or a pre-tokenized string vector; an already-computed BOW is accepted directly by the generic SimilaritySearch.push_item! method without going through this conversion. See also append_items!.
Example
julia> corpus = ["hello world", "hello there"];
julia> voc = Vocabulary(TextConfig(), corpus; verbose=false);
julia> invfile = BM25InvertedFile(voc);
julia> ctx = InvertedFileContext();
julia> append_items!(invfile, ctx, corpus);
julia> push_item!(invfile, ctx, "hello again");
julia> length(invfile)
3SimilaritySearch.search — Method
search(idx::BM25InvertedFile, ctx::InvertedFileContext, qtext, res::AbstractKnnQueue; t::Int=1)Solves a top-k query over idx for qtext (raw text, TokenizedText, or an already-computed bag of words), accumulating matches into res (an AbstractKnnQueue, e.g. KnnSorted or KnnHeap). Documents are ranked by BM25 score (stored internally as a negative value in res, so lower "distance" still means better match, consistent with SimilaritySearch.jl's convention). Returns res.
Example
julia> corpus = ["hello world", "hello there", "the cat sat"];
julia> voc = Vocabulary(TextConfig(), corpus; verbose=false);
julia> invfile = BM25InvertedFile(voc);
julia> ctx = InvertedFileContext();
julia> append_items!(invfile, ctx, corpus);
julia> res = knnqueue(KnnSorted, 2);
julia> search(invfile, ctx, "hello", res);
julia> collect(IdView(res))
UInt32[0x00000001, 0x00000002]TextSearch.BM25._bm25_fused_index_and_grow! — Function
_bm25_fused_index_and_grow!(idx, ctx, items, startID, n, tol=1e-6)Encodes each raw/BOW document in items[1:n] into its SparseVecView term-frequency representation, registers its postings into idx.adj, and stores the vector into idx.db – all in a single pass (this is the "fused" behavior append_items!/push_item! use for raw-text/ BOW input, kept for efficiency: unlike the generic InvertedFile, BM25InvertedFile's db element type is derived from its input, so there is no cheap way to grow db ahead of indexing for this path – see _index_block! for the decoupled path instead, used when db is grown directly with already-encoded SparseVecViews).
TextSearch.BM25.bm25_internal_push_object! — Method
bm25_internal_push_object!(idx, docID, obj, tol) -> (doclen, docvec)Registers obj (a pair (tokenID, freq) iterable) into idx.adj under docID and builds its SparseVecView term-frequency representation (docvec, to be stored at docID in idx.db by the caller). Returns obj's total token count (doclen) and docvec.
TextSearch.BM25.bm25_query_vector — Method
bm25_query_vector(idx::BM25InvertedFile, q)Converts q (a bag of words – BOW/Dict, or anything else pairiterator accepts) into a SparseVector, for bm25score to use in onmatch!. Passes q through unchanged if it's already a SparseVectorLike.
TextSearch.BM25.bm25_register_postings! — Method
bm25_register_postings!(idx::BM25InvertedFile, docID::Integer, docvec) -> doclenRegisters an already-encoded document vector docvec (anything pairiterator-compatible, typically the SparseVecView already stored at idx.db[docID]) into idx.adj under docID, and returns its token count (doclen). This is the postings-only half of bm25_internal_push_object! – it does not parse/build a SparseVecView, since docvec is assumed to already be one (e.g. read back from idx.db by _index_block!).
TextSearch.BM25.bm25doclen — Method
bm25doclen(doc::SparseVectorLike)Total token count of doc (a document's term-frequency sparse vector – a SparseVecView, as stored in BM25InvertedFile's db, or a SparseVector). Used by bm25score.
TextSearch.BM25.bm25score — Method
bm25score(bm25::BM25Scorer, voc::Vocabulary, query::SparseVectorLike, doc::SparseVectorLike)::Float32Computes the BM25 relevance score of doc for query – each a term-frequency sparse vector (a SparseVecView, e.g. one of BM25InvertedFile's own db entries via database, or a SparseVector) – by merging their nonzero indices (nzind, assumed sorted ascending) in a single linear pass and summing tokenscore at every token id present in both. Higher is more relevant. query's own frequencies are not used (BM25 doesn't weight by query-side term frequency), only which tokens it contains.
Example
julia> corpus = ["hello world", "hello there", "the cat sat"];
julia> voc = Vocabulary(TextConfig(), corpus; verbose=false);
julia> bm25 = BM25Scorer(voc);
julia> invfile = BM25InvertedFile(voc);
julia> ctx = InvertedFileContext();
julia> append_items!(invfile, ctx, corpus);
julia> bm25score(bm25, voc, database(invfile)[1], database(invfile)[1]) # "hello world" scored against itself
2.9917173f0
julia> bm25score(bm25, voc, database(invfile)[1], database(invfile)[2]) # "hello world" scored against "hello there"
0.96917987f0TextSearch.BM25.tokenscore — Method
tokenscore(bm25::BM25Scorer, toknumdocs, doclen, tokfreqindoc)Computes the BM25 contribution of a single token to a document's score, given the number of documents containing the token (toknumdocs, i.e. its document frequency), the document's length in tokens (doclen), and the token's frequency in the document (tokfreqindoc). Used internally by bm25score and BM25InvertedFile search.
Example
julia> corpus = ["hello world", "hello there", "the cat sat"];
julia> voc = Vocabulary(TextConfig(), corpus; verbose=false);
julia> tokenscore(BM25Scorer(voc), 2, 3, 1)
0.8908208f0TextSearch.BM25.BM25InvertedFile — Type
BM25InvertedFile{AdjType<:AbstractAdjList,DbType<:AbstractDatabase} <: AbstractInvertedFileAn inverted-file index (built on top of SimilaritySearch.InvertedFiles) that answers approximate/exact top-k queries ranked by BM25 relevance. Build it with BM25InvertedFile(voc), populate it with append_items!/push_item!, and query it with search (from SimilaritySearch.jl).
Follows the same design as SimilaritySearch.InvertedFiles.InvertedFile: adj only ever stores plain document ids (AdjType's element type is UInt32, exactly like the generic InvertedFile); every other per-document detail needed to score a match – term frequencies – is fetched from db instead of being duplicated into the posting lists.
Fields
voc: theVocabularyshared by every indexed document (also used to tokenize/encode query text).bm25: theBM25Scorerused to rank matches.adj: the adjacency list of posting lists (one per token id), mapping each token to the ids of the documents containing it.doclens: number of tokens per indexed document.db: each indexed document's term-frequency vector, oneSparseVecView(token id =>UInt32frequency) per document, always populated bypush_item!/append_items!; may hold more documents than have actually been indexed – seelen.len: number of documents already indexed (postings built); may be less thanlength(database(idx))ifdbwas grown directly (e.g.push_item!(database(idx), docvec)) without a followingindex!call to catch up. Growingdbdirectly with pre-computedSparseVecViews and then callingindex!(invfile, ctx)is supported and builds postings from the already-stored vectors; the raw-text/BOW-takingappend_items!/push_item!methods remain fused (encode+store+register in one pass) for efficiency.query_expansion:nothing, or a query-expansion network (e.g. as produced byLSI.query_expansion) used to enrich queries viaexpand_query!. Never applied to documents, only to queries.
Example
julia> using SimilaritySearch
julia> corpus = ["hello world", "hello there", "the cat sat"];
julia> voc = Vocabulary(TextConfig(), corpus; verbose=false);
julia> invfile = BM25InvertedFile(voc);
julia> ctx = InvertedFileContext();
julia> append_items!(invfile, ctx, corpus);
julia> length(invfile)
3
julia> res = knnqueue(KnnSorted, 2);
julia> search(invfile, ctx, "hello", res);
julia> collect(IdView(res))
UInt32[0x00000001, 0x00000002]TextSearch.BM25.BM25InvertedFile — Method
BM25InvertedFile(p::TextProfile; k1=1.2f0, b=0.75f0, δ=1f0, policy=QueryPolicy(), expansion=p.applied.query_expansion)Creates an empty BM25InvertedFile from a fitted TextProfile – which is the point of having profiles at all, so it is worth being precise about what comes from where.
The scorer's statistics are the profile's: trainsize and avgdoclen are the corpus the profile was fitted on, and the document frequency behind every idf is read from its vocabulary at search time. The document lengths are the index's, filled in by append_items! as documents arrive. That split is the whole idea: a profile fitted on 6,665,754 Portuguese paragraphs lends its idf and its length normalization to an index holding 20,000 of them, instead of each small index inventing statistics from what little it has.
Tokenization is the profile's too, since the vocabulary's ids and counts came from it.
How queries are answered follows the profile, with one deliberate asymmetry. Expansion is gated by the profile: the network is handed to the index only when applied.query_expansion says the profile endorses it, since it is an artifact the profile may carry without meaning it to be used – pass expansion=true to take it anyway, which is what a base profile needs. Correction is gated by the policy, because it depends on nothing but the vocabulary, which every profile has; the variant map is derived once here rather than once per query, and comes out empty at no cost for a profile that folds case and diacritics.
Example
julia> idx = BM25InvertedFile(profile); # corrects, does not expand
julia> idx = BM25InvertedFile(profile; expansion=true); # ...and expands anyway
julia> idx = BM25InvertedFile(profile; policy=QueryPolicy(correction=:off)); # literal queriesTextSearch.BM25.BM25InvertedFile — Method
BM25InvertedFile(voc::Vocabulary; k1=1.2f0, b=0.75f0, δ=1f0, query_expansion=nothing, distances=nothing, query=nothing)Creates an empty BM25InvertedFile, fitting its BM25Scorer from voc (see BM25Scorer(voc) for k1/b/δ). Populate it with append_items!/push_item!.
How queries are answered is a QueryPipeline, stored on the index. query_expansion (e.g. as produced by LSI.query_expansion) is the short way to say "expand with this network"; pass query instead to also correct spellings, which needs a variant map – and note that deriving one per query would be far too slow, which is why it lives on the index.
Example
julia> voc = Vocabulary(TextConfig(), ["hello world"]; verbose=false);
julia> invfile = BM25InvertedFile(voc);
julia> length(invfile)
0TextSearch.BM25.BM25Scorer — Type
BM25ScorerPrecomputed coefficients for the Okapi BM25 (BM25+) scoring function, used to rank documents against a query given per-token document frequencies and document lengths. Build one with BM25Scorer(voc) or BM25Scorer(trainsize, avgdoclen); score individual (token-frequency, document-length) pairs with tokenscore, or whole query/document bags of words with bm25score. BM25InvertedFile uses a BM25Scorer internally to answer top-k queries efficiently.
Fields
k1_plus_1, k1_mult_1_min_b, and k1_mult_b_div_avg_doc_len are combinations of the BM25 k1/b hyperparameters and the corpus' average document length, precomputed for faster scoring; δ is the BM25+ lower-bound correction term; trainsize is the number of documents the corpus statistics were computed from.
Example
julia> corpus = ["hello world", "hello there", "the cat sat"];
julia> voc = Vocabulary(TextConfig(), corpus; verbose=false);
julia> bm25 = BM25Scorer(voc);
julia> bm25.trainsize
3TextSearch.BM25.BM25Scorer — Method
BM25Scorer(trainsize::Integer, avgdoclen::AbstractFloat; k1=1.2f0, b=0.75f0, δ=1f0)Creates a BM25Scorer for a corpus of trainsize documents with average document length avgdoclen. k1 controls term-frequency saturation, b controls document-length normalization, and δ is the BM25+ lower-bound correction.
Example
julia> bm25 = BM25Scorer(3, 3.5);
julia> bm25.trainsize
3TextSearch.BM25.BM25Scorer — Method
BM25Scorer(voc::Vocabulary; k1=1.2f0, b=0.75f0, δ=1f0)Creates a BM25Scorer using the training size and average document length already computed in voc (see trainsize and avgdoclen).
Example
julia> corpus = ["hello world", "hello there", "the cat sat"];
julia> voc = Vocabulary(TextConfig(), corpus; verbose=false);
julia> BM25Scorer(voc).trainsize
3Base.empty! — Method
empty!(buff::TokenizerBuffer; normtext=true, tokens=true, unigrams=true)Clears the requested scratch fields of buff in place. Returns buff.
TextSearch.Tokenizer._preprocessing — Method
_preprocessing(config::TextConfig, text) -> AbstractStringCase folding and the three group-substitutions, in one replace pass rather than three.
Each replace allocates a full copy of the text, so chaining them costs three allocations and three scans per document; one call with several pairs costs one of each. Measured on 60,000 Spanish Wikipedia paragraphs (33.1M characters), normalization drops from 8.96s to 8.36s, and the output is byte-identical over 70,000 documents. The pairs stay in their original order because a multi-pair replace tries them in order at each position, which reproduces what the chain did: a URL is consumed whole by re_url before re_num can see the digits inside it.
The lowercase is NOT redundant with the casefold=true that normalize_text passes to Unicode.normalize, which is what it looks like. They differ on the Turkish dotted capital I (U+0130): lowercase maps it to i, while Unicode case folding maps it to i plus a combining dot above, so with del_diac=false the token becomes i̇lhan – which no one can type, so a query for ilhan stops matching. Measured on real text, 28 of 10,000 Spanish articles contain it. It also has to run before the regexes, since re_url is case-sensitive.
TextSearch.Tokenizer._split_config_kwargs — Method
Splits flat keywords into the sub-config each one belongs to, refusing anything unknown.
TextSearch.Tokenizer.alltokengenerators — Method
alltokengenerators(cfg::TokenizationConfig)::Vector{AbstractTokenGenerator}Builds the full, ordered list of AbstractTokenGenerators cfg runs: the built-in ones implied by cfg.nlist, followed by cfg.generators (any extra/custom generators). Called once per tokenize invocation.
Example
julia> alltokengenerators(TokenizationConfig(nlist=[1, 2]))
2-element Vector{AbstractTokenGenerator}:
UnigramGenerator()
NWordGenerator(2)TextSearch.Tokenizer.apply_pipeline — Method
apply_pipeline(p::TokenPipeline, tok) -> Union{Nothing,String}Runs p's stages over one token, in the fixed order (lemma rewrite, then the stopword filter), returning nothing when the token is dropped. On the hot path: an inactive stage is a === nothing check the compiler can hoist, so an identity pipeline costs two comparisons per token.
TextSearch.Tokenizer.flush_token! — Method
flush_token!(buff::TokenizerBuffer, pipe::TokenPipeline, gen::AbstractTokenGenerator, mark_token_type::Bool)Pushes the token accumulated in buff.io to the token list, applying gen's tokentag (when mark_token_type) and the TokenPipeline's stages; discards empty strings and tokens the pipeline drops.
TextSearch.Tokenizer.generate! — Method
generate!(gen::AbstractTokenGenerator, buff::TokenizerBuffer, pipe::TokenPipeline, mark_token_type::Bool)Runs gen over buff, appending its produced tokens to buff.tokens. Called by tokenize for every generator in alltokengenerators; a new AbstractTokenGenerator subtype implements this method to define what it does. UnigramGenerator's tokens are emitted as a side effect of the shared unigrams pass, so its own generate! is a no-op.
TextSearch.Tokenizer.isemoji — Function
isemoji(c::Char, emojis::Set{Char}=DEFAULT_EMOJIS)::BoolTests whether c is one of the emoji characters in emojis (by default, DEFAULT_EMOJIS, the set known to TextSearch, loaded from emojis.txt). Used by normalize_text when TextConfig's group_emo option is set.
Example
julia> isemoji('😀')
true
julia> isemoji('a')
falseTextSearch.Tokenizer.isidentity — Method
whether p runs no stage at all, i.e. every token passes through unchanged
TextSearch.Tokenizer.needs_unigrams — Method
needs_unigrams(gen::AbstractTokenGenerator)::BoolWhether gen needs the shared word-level unigrams basis (see unigrams) computed before it runs. Defaults to false; NWordGenerator and UnigramGenerator override it to true.
TextSearch.Tokenizer.normalize_text — Method
normalize_text(config::TextConfig, text::AbstractString, output::Vector{Char}; limits::Bool=true, isnormalized::Bool=false)Normalizes a given text using the specified transformations of config. If isnormalized=true, skips preprocessing and normalization passes, writing text directly to output.
Example
julia> buff = Char[];
julia> normalize_text(TextConfig(), "Café", buff);
julia> String(buff)
" cafe "TextSearch.Tokenizer.normalize_text — Method
normalize_text(textconfig::TextConfig, text; limits::Bool=false, isnormalized::Bool=false)Convenience method that normalizes text under textconfig (see normalize_text(config, text, output; limits)) and returns the result as a String instead of writing into a caller-provided buffer.
Example
julia> normalize_text(TextConfig(), "Café!!")
"cafe!!"TextSearch.Tokenizer.nwords — Method
nwords(gen::NWordGenerator, buff::TokenizerBuffer, pipe::TokenPipeline, mark_token_type)TextSearch.Tokenizer.tokenize — Method
tokenize(textconfig::TextConfig, text)
tokenize(copy_::Function, textconfig::TextConfig, text)
tokenize(textconfig::TextConfig, text, buff)
tokenize(copy_::Function, textconfig::TextConfig, text, buff)Tokenizes text using the given configuration. The tokenize makes heavy usage of buffers, and when these buffers are shared it is mandatory to create a copy of the result (buff.tokens).
Change the default copy function to make an additional filtering of the tokens. You can also pass the identity function to avoid copying.
Example
julia> collect(tokenize(TextConfig(), "Hello world!!"))
["hello", "world", "!!"]TextSearch.Tokenizer.tokenize_corpus — Method
tokenize_corpus(textconfig::TextConfig, arr; isnormalized::Bool=false, verbose=true)
tokenize_corpus(copy_::Function, textconfig::TextConfig, arr; isnormalized::Bool=false, verbose=true)Tokenize a list of texts. The copy_ function is passed to tokenize as first argument.
Example
julia> corpus = ["hello world", "the cat sat"];
julia> toks = tokenize_corpus(TextConfig(), corpus; verbose=false);
julia> collect(toks[1])
["hello", "world"]TextSearch.Tokenizer.tokenize_paragraphs — Method
tokenize_paragraphs(text::AbstractString)::Vector{String}
tokenize_paragraphs(textconfig::TextConfig, text::AbstractString)::Vector{String}
tokenize_paragraphs([textconfig::TextConfig,] arr::AbstractVector)::Vector{String}Splits text into paragraphs separated by two or more newlines (\n\n+ or \r\n\r\n+). Trims leading and trailing whitespace from each paragraph and filters out empty paragraphs. When textconfig is provided, normalizes the text before paragraph splitting.
TextSearch.Tokenizer.tokenize_sentences — Method
tokenize_sentences(text::AbstractString)::Vector{String}
tokenize_sentences(textconfig::TextConfig, text::AbstractString; isnormalized::Bool=false)::Vector{String}
tokenize_sentences([textconfig::TextConfig,] arr::AbstractVector; isnormalized::Bool=false)::Vector{String}Splits text into sentences using sentence-ending punctuation (., !, ?) followed by whitespace or newlines. Trims leading and trailing whitespace from each sentence and filters out empty sentences. When textconfig is provided, normalizes each sentence after splitting.
TextSearch.Tokenizer.tokenizerbuffer — Method
tokenizerbuffer(f)Borrows a TokenizerBuffer from Tokenizer's own pool, passes it to f, and returns it to the pool afterwards. Unlike the buffer-less tokenize methods (which release their borrowed buffer before returning), the buffer stays borrowed for the whole extent of f, so it is safe to alias its contents (e.g. via borrowtokenizedtext) as long as they are consumed inside f.
Example
julia> TextSearch.Tokenizer.tokenizerbuffer() do buff
tokenize(borrowtokenizedtext, TextConfig(), "hello world", buff) |> collect
end
["hello", "world"]TextSearch.Tokenizer.tokentag — Method
tokentag(gen::AbstractTokenGenerator)::Union{Char,Nothing}The single-character tag appended (as \ttag) to every token gen produces when mark_token_type=true. Defaults to nothing (untagged).
TextSearch.Tokenizer.unigrams — Method
unigrams(buff::TokenizerBuffer, pipe::TokenPipeline)Performs the word tokenization
TextSearch.Tokenizer.AbstractTokenGenerator — Type
AbstractTokenGeneratorAbstract type for a single token-producing strategy inside a TokenizationConfig's generators list. TokenizationConfig's nlist keyword argument is convenience sugar that builds the built-in generators below; passing generators directly (or mixing in your own AbstractTokenGenerator subtype) is how new kinds of tokens can be added without touching TokenizationConfig or the tokenizer's dispatch logic (e.g. character q-grams, skip-grams, or collocations, none of which are built-in anymore).
Implementing a new generator kind requires:
- a struct
<: AbstractTokenGeneratorholding whatever parameters it needs; needs_unigrams(defaults tofalse) if it needs the shared word-levelunigramsbasis computed first;TextSearch.Tokenizer.generate!performing the actual token production;- optionally
tokentag(defaults tonothing, i.e. untagged) for the single-character tag appended to each token whenmark_token_type=true.
A new generator kind works with any TokenPipeline without further changes: the pipeline's stages act on whatever tokens a generator produces. This is also the right home for anything that changes how text becomes tokens – splitting getUserName into three words, keeping H2O whole – since a generator sees the word stream and may emit one token or several, while the pipeline is strictly per-token and data-driven.
TextSearch.Tokenizer.NWordGenerator — Type
NWordGenerator(q)Produces word q-grams (q > 1) from the shared unigram basis (tagged 'n'). Built from TokenizationConfig's nlist keyword argument for every entry other than 1.
TextSearch.Tokenizer.NormalizationConfig — Type
NormalizationConfig(;
del_diac::Bool=true,
del_dup::Bool=false,
del_punc::Bool=false,
group_num::Bool=true,
group_url::Bool=true,
group_usr::Bool=false,
group_emo::Bool=false,
lc::Bool=true,
re_user::Regex=DEFAULT_RE_USER,
re_url::Regex=DEFAULT_RE_URL,
re_num::Regex=DEFAULT_RE_NUM,
emojis::Set{Char}=DEFAULT_EMOJIS
)Defines the text normalization stage of a TextConfig (see its normalization field): utf8 normalization, character removal, whitespace normalization, casing, etc. Consumed by normalize_text.
del_diac: indicates if diacritic symbols should be removeddel_dup: indicates if duplicate contiguous symbols must be replaced for a single symboldel_punc: indicates if punctuaction symbols must be removedgroup_num: indicates if numbers should be grouped _numgroup_url: indicates if urls should be grouped as _urlgroup_usr: indicates if users (@usr) should be grouped as _usrgroup_emo: indicates if emojis should be grouped as _emolc: indicates if the text should be normalized to lower casere_user,re_url,re_num: the regexes used to detect@usermentions, URLs, and numbers when their correspondinggroup_*flag is set (seenormalize_text). Override them to customize detection (e.g. for a different language or domain).emojis: the set of emoji characters grouped whengroup_emois set (seeisemoji).
Example
julia> buff = Char[];
julia> normalize_text(TextConfig(normalization=NormalizationConfig()), "Café", buff);
julia> String(buff)
" cafe "TextSearch.Tokenizer.TextConfig — Type
TextConfig(;
normalization::NormalizationConfig=NormalizationConfig(),
tokenization::TokenizationConfig=TokenizationConfig(),
pipeline::TokenPipeline=TokenPipeline()
)Defines a preprocessing and tokenization pipeline, composed of 3 independent stages:
normalization: aNormalizationConfig(utf8 normalization, character removal, whitespace normalization, casing, etc.).tokenization: aTokenizationConfig(unigrams, word n-grams, and any extra customAbstractTokenGenerators).pipeline: aTokenPipelineof per-token stages applied to every generated token (lemma normalization or stopword removal).language: which language this configuration is for, as an ISO 639-1 symbol (:es,:pt,:en, ...) or:unknown.
language currently changes nothing about tokenization – it is recorded, not acted on. It lives here rather than in a TextProfile because the language is not something a corpus produces, it is what selects the policy: whether to strip diacritics, whether suffix-anchored morphology fits, whether function words arrive as free tokens at all. Those are decisions the tokenizer will eventually make from this field, and a field the tokenizer must read belongs in the tokenizer's config.
It also earns its keep immediately: merge_profiles compares policy for equality, so declaring the language is what stops a Spanish profile from silently merging with a Portuguese one – their normalization and tokenization are identical, so nothing else distinguishes them. A detected language distribution ("this corpus turned out 87% Spanish") would be an observation about data, and would belong in a profile's lineage instead.
This is the corpus-independent half of a text model – it can be written by hand with no data. The artifacts a corpus produces (stopword sets, lemma maps, queryexpansion networks) live in a TextProfile, which materializes the pipeline from whichever of them it applies. Query-time expansion is likewise a profile-level decision (`applied.queryexpansion`), not a flag here: it is a search-time behaviour whose data does not live in the tokenizer.
Two ways to say the same thing
Any setting of the two sub-configs can be given directly, so the nesting is there when a whole sub-config is being passed around and absent when only one flag is being changed:
TextConfig(lc=false, del_diac=false) # flat
TextConfig(normalization=NormalizationConfig(lc=false, del_diac=false)) # nested, identicalThe flat form exists because the nested one is what actually gets written, over and over, for a single flag – and because nlist=[1] was spelled out in fifty places across this repository while already being the default. Naming a setting both ways is an error rather than one silently winning.
Example
julia> collect(tokenize(TextConfig(), "cats"))
["cats"]
julia> collect(tokenize(TextConfig(lc=false), "Cats"))
["Cats"]TextSearch.Tokenizer.TokenPipeline — Type
TokenPipeline(; lemmas=nothing, stopwords=nothing)The per-token stages of a TextConfig, as plain data in a fixed order rather than an open set of composable hooks.
lemmas rewrite a token to its lemma `Dict{String,String}`
stopwords drop a token entirely `Set{String}`nothing means the stage does not run, and the order above is the order they run in.
Both stages apply to documents and queries alike, so one config serves fitting, indexing and searching. Orthographic bridging – letting a query typed leon reach León – deliberately does not live here: deciding it needs to know whether the typed token is in the vocabulary at all, which is not something a pipeline of plain data can answer. See resolve_query_tokens.
Why a fixed pipeline and not composable transformations
This replaced an AbstractTokenTransformation hierarchy (IgnoreStopwords, LemmaTransformation, ChainTransformation, plus a transform hook dispatching on both the transformation and the token generator). That design was right when TextSearch was more open, and by the time it was removed it held exactly two real stages, both of which are data rather than behaviour: a set to filter by and a map to rewrite through. A generic mechanism for two known things bought nothing and cost three specific problems.
Order. The stages are not commutative and the wrong order fails silently. With the stopword filter first, "las" is not in a set containing "la", survives the filter, and is only then rewritten to "la" – so the stopword lands in the vocabulary through the back door. This was documented backwards once and only measurement caught it. Here the order is in the code, once, and there is nowhere else to express it.
Per-type dispatch. merge_profiles compared transformations through a method per type, and the missing method for LemmaTransformation made it reject two profiles carrying identical lemma maps as incompatible. Comparing two TokenPipelines is comparing two fields and cannot have a missing method.
Cost. ChainTransformation's field was typed AbstractVector{<:AbstractTokenTransformation}, which is not concrete, so every step of every token went through a dynamic dispatch. Measured on 120,000 Spanish Wikipedia paragraphs (71.0M characters): lemmas+stopwords chained took 14.65s and 3.62 GB against 10.12s and 2.61 GB for the stopword filter alone – 45% more time and a gigabyte more garbage for one extra dictionary lookup per token.
Where extensibility lives now
Not here. A new kind of token – character q-grams, skip-grams, collocations, chemical formulas, splitting getUserName into three words – is a AbstractTokenGenerator in TokenizationConfig's generators list, which is the documented extension point and the right place for it: generators see the word stream, so they can emit one token or several.
What has no home here is an algorithmic per-token rewrite or filter that cannot be expressed as data – a stemmer, say, which is exactly what the removed Snowball extension was. Adding one means adding a named field with a documented position in the order, which is cheaper than the mechanism this replaced: that needed a new type, a transform_unigram method, a comparison method (the one that was forgotten), and a decision about where it chained.
Example
julia> p = TokenPipeline(lemmas=Dict("casas" => "casa", "rojas" => "roja"),
stopwords=Set(["la"]));
julia> cfg = TextConfig(tokenization=TokenizationConfig(nlist=[1]), pipeline=p);
julia> collect(tokenize(cfg, "las casas rojas"))
["casa", "roja"]"las" becomes "la" and is then dropped, which is the order this type exists to guarantee.
TextSearch.Tokenizer.TokenPipeline — Method
TokenPipeline(p::TokenPipeline; lemmas, stopwords)Copy constructor: rebuilds p overriding only the named stages. Pass nothing to turn a stage off.
TextSearch.Tokenizer.TokenizationConfig — Type
TokenizationConfig(;
nlist::Vector=Int8[],
mark_token_type::Bool=true,
generators::Vector{<:AbstractTokenGenerator}=AbstractTokenGenerator[]
)Defines the tokenization stage of a TextConfig (see its tokenization field): unigrams and word n-grams, computed from the output of the normalization stage.
nlist: a list of words n-grams to use (1emits plain unigrams viaUnigramGenerator, any other value emits word n-grams viaNWordGenerator).mark_token_type: each token ismarkedwith its type (nword) when is true.generators: extraAbstractTokenGenerators to run in addition to the onesnlistbuilds; this is the extension point for adding new kinds of tokens (e.g. character q-grams, skip-grams, or collocations, none of which are built-in anymore) without needing a newTokenizationConfigkeyword argument (seealltokengenerators).
Note: If nlist and generators are both empty, then it defaults to nlist=[1]
Example
julia> cfg = TokenizationConfig(nlist=[1, 2]);
julia> collect(tokenize(TextConfig(tokenization=cfg), "cats sat"))
["cats", "sat", "cats sat n"]TextSearch.Tokenizer.TokenizedText — Type
TokenizedText(tokens::AbstractVector{String})Wraps a list of string tokens for a single document. TokenizedText is TextSearch's universal contract for pre-tokenized text. It behaves like an AbstractVector{String} (supporting indexing, iteration, push!, append!, etc.) and is recognized by tokenize, Vocabulary, bagofwords, vectorize, and append_items! to bypass TextSearch's internal normalization and tokenization pipeline.
Use TokenizedText when integrating external tokenizers (e.g., WordTokenizers.jl, HuggingFace/subword tokenizers, spaCy, or custom tokenization functions).
Example
julia> using TextSearch
# Standard tokenization output:
julia> collect(tokenize(TextConfig(), "Hello world!!"))
3-element Vector{String}:
"hello"
"world"
"!!"
# Integrating an external tokenizer:
julia> external_tokens = ["custom", "tokenization", "output"];
julia> tok_doc = TokenizedText(external_tokens);
julia> voc = Vocabulary(TextConfig(), [tok_doc]; verbose=false);
julia> vocsize(voc)
3TextSearch.Tokenizer.TokenizerBuffer — Type
TokenizerBuffer(n=128)Self-contained scratch space reused across tokenization calls to avoid reallocating on every call: normtext holds the normalized text, tokens accumulates the produced tokens, unigrams holds the word-level basis used by NWordGenerator (and any custom generator needing it), and io is scratch space for building individual token strings.
Tokenizer pools these internally for its own buffer-less convenience API (see tokenize); callers that need to hold a buffer across several calls (e.g. to safely alias its contents via borrowtokenizedtext) should borrow one from the same pool via tokenizerbuffer instead of constructing their own.
TextSearch.Tokenizer.UnigramGenerator — Type
UnigramGenerator()Emits the word-level unigrams themselves as output tokens (untagged). Built from TokenizationConfig's nlist keyword argument when it contains 1. Every other generator that needs the word-level basis (see needs_unigrams) triggers the same underlying computation regardless of whether UnigramGenerator is present — this generator only controls whether the plain words also appear in the output.
TextSearch.Tokenizer.DEFAULT_EMOJIS — Constant
DEFAULT_EMOJIS::Set{Char}The built-in emoji set (loaded from emojis.txt), used as TextConfig's default emojis field. Pass a different Set{Char} to TextConfig(; emojis=...) to override it.
TextSearch.Tokenizer.DEFAULT_RE_NUM — Constant
DEFAULT_RE_NUM::RegexThe built-in number regex, used as TextConfig's default re_num field. Pass a different Regex to TextConfig(; re_num=...) to override it.
TextSearch.Tokenizer.DEFAULT_RE_URL — Constant
DEFAULT_RE_URL::RegexThe built-in URL regex, used as TextConfig's default re_url field. Pass a different Regex to TextConfig(; re_url=...) to override it.
TextSearch.Tokenizer.DEFAULT_RE_USER — Constant
DEFAULT_RE_USER::RegexThe built-in @user-mention regex, used as TextConfig's default re_user field. Pass a different Regex to TextConfig(; re_user=...) to override it.
TextSearch.LSI._lanczos_svd — Method
_lanczos_svd(A, k) -> Union{Nothing,Tuple}Truncated SVD of A keeping the top k singular triplets via ARPACK's implicitly restarted Lanczos iteration (Arpack.svds): exact to working precision (measured ~3e-7 relative error on the singular values) while never forming a Gram matrix, which is what makes it both the accurate and the fast choice at scale.
ARPACK's own iteration is sequential and it is not re-entrant (unsynchronized static state, so it must not be called concurrently from multiple threads – LSI factorizes one batch at a time, so that is not a constraint here). It is not, however, serial in throughput: the heavy work goes to BLAS, so on a multicore host it does use many cores (~17 of 64 measured), just less effectively than a dense eigen, which is BLAS-3 rather than mostly BLAS-1/2.
Returns nothing when ARPACK cannot deliver k converged triplets – either by failing to converge or by throwing – so the caller can fall back to the exact dense path rather than abort a long fit.
TextSearch.LSI._query_expansion_localradius — Method
_query_expansion_localradius(voc, wordvecs, idx, ictx, kk, kcap, rank, q, mingroup)Assembles a queryexpansion network whose per-token neighbor count is decided by the data instead of by a fixed k, via [`SimilaritySearch.bichromaticmetricjoin`](@ref) as a self-join.
A top-k network gives every token exactly k neighbors whether or not it has k real ones, so a token in a sparse region of the embedding gets filler and a token in a dense one gets truncated. The join instead estimates a cutoff radius per token from the reverse view of the same search: every token that ranked t among its own closest rank candidates votes for t with that distance, and t's cutoff is the q-quantile of its voters. Tokens with fewer than mingroup voters fall back to a pooled global cutoff.
Two consequences worth knowing before choosing this over :topk. The surviving pairs are those where the other token found t in its own top-kk, so the network becomes mutual-ish rather than a plain per-token top-k: a token that nobody's neighborhood reaches gets no query_expansion even if it has close ones of its own. And the output size is data-dependent, so kcap is applied only as a ceiling to keep a pathologically dense token from carrying thousands of neighbors.
TextSearch.LSI._select_topk — Method
_select_topk(nzind, nzval, topk::Integer) -> (nzind, nzval)The topk largest-weighted entries of the parallel (nzind, nzval) arrays, restored to ascending index order afterward (so the result is still a valid sparse-vector index list, and iterating it twice gives the same order). Returns the inputs unchanged, not copied, when there are topk or fewer entries already.
TextSearch.LSI.query_expansion — Function
query_expansion(lsi::LatentSemanticIndexing, k::Integer=8;
dist=Dist.Cosine(), normalize::Bool=true, verbose::Bool=true, approx=:auto,
construction_recall::Real=0.97, search_recall::Real=0.9) -> Dict{String,Vector{Pair{String,Float32}}}Builds a queryexpansion network from lsi's vocabulary embeddings (wordvectors); see the (voc, wordvecs, k) method above for the underlying algorithm and for what approx/ `constructionrecall/search_recallcontrol.normalizeis forwarded to [wordvectors`](@ref) before searching.
Example
net = query_expansion(lsi, 5)
net["dog"] # ["dogs" => 0.02, "puppy" => 0.11, ...]TextSearch.LSI.query_expansion — Function
query_expansion(voc::Vocabulary, wordvecs::AbstractDatabase, k::Integer=8;
dist=Dist.Cosine(), verbose::Bool=true, approx=:auto,
construction_recall::Real=0.97, search_recall::Real=0.9)
-> (; query_expansion::Dict{String,Vector{String}}, distances::Dict{String,Vector{Float32}})Builds a query_expansion network from voc's token embeddings in wordvecs (column t = embedding of gettoken(voc, t), e.g. from wordvectors or an externally supplied matrix): for every vocabulary token, finds its k nearest neighbors (by dist, cosine by default) among all other tokens' embeddings, via SimilaritySearch.allknn. The token itself is always excluded from its own neighbor list.
The two halves come back separately, as parallel per-token lists sorted by increasing distance (lower means more similar): query_expansion[tok] are the neighbor tokens in rank order, and distances[tok][i] is the distance to query_expansion[tok][i]. They are split because only the ranking participates in the normal query-expansion path – BM25 ignores the query side's weights entirely, and the distances stop being distances in any single space as soon as a network is merged or refitted. Keeping them apart lets a consumer (or a profile on disk) carry the ranking alone, which is where nearly all of a network's size lives.
Two arguments express what the network is for rather than filtering it for quality, which is the distinction that makes them work where eight quality filters did not (see the long note above this function):
head_df: a token whose document frequency exceeds this gets no list at all. No default is possible, because a document frequency is a ratio relative to whatever a document is:0.05means "in one paragraph in twenty" for a paragraph-level profile and something entirely different for an article-level one. Whoever knows the unit sets it;0disables. Measured on 272,466 Spanish Wikipedia paragraphs,0.05leaves 57 tokens without a list – the function words plusanos ano parte forma ciudad ser ha donde– at a cost of 0.1% of all pairs.max_target_ratio: drop a neighbour whose document frequency exceeds the source's by more than this factor. This one is scale-invariant, being a ratio of two frequencies within the same corpus, so it carries a real default.planeta->martemoves toward something rarer (0.29) and survives any value; a maximum-idf token pointing atporjumps 20,000x and does not. Known cost: it also cuts the legitimate rare -> common direction, such as a misspelling pointing at the correct word.0disables.
approx selects how the all-pairs search is done, and matters enormously on real vocabularies – an exhaustive search is O(vocabulary²):
:auto(default): approximate whenlength(wordvecs) > QUERY_EXPANSION_APPROX_THRESHOLD, exhaustive below it (where exhaustive is already fast and exact, so there is nothing to gain from approximating).true: always approximate – build aSearchGraph, autotuning construction toMinRecall(construction_recall)and then the search parameters toMinRecall(search_recall).false: always exhaustive, viaParallelExhaustiveSearch. Exact, and unusably slow past a few tens of thousands of tokens.
Example
net = query_expansion(voc, wordvectors(lsi), 5)
net.query_expansion["dog"] # ["dogs", "puppy", ...]
net.distances["dog"] # [0.02, 0.11, ...]TextSearch.LSI.wordvectors — Method
wordvectors(lsi::LatentSemanticIndexing; normalize::Bool=true) -> MatrixDatabase{Matrix{Float32}}Returns the LSI embedding of every vocabulary token, as a (outdim(lsi), vocsize(lsi)) matrix database – column t is the embedding of gettoken(lsi.model, t). This is exactly lsi.P (optionally column-normalized): a document's LSI vector (via vectorize/ vectorize_corpus) is a weighted sum of its tokens' columns of lsi.P, so these per-token vectors live in the same projected space and are directly comparable to each other and to document vectors (e.g. via Dist.Cosine()/Dist.NormCosine()). Set normalize=false to keep the raw (scaling-adjusted) lsi.P columns instead of unit-normalizing them.
Example
X = wordvectors(lsi) # (outdim(lsi), vocsize(lsi)) MatrixDatabase
X[5] # the embedding of gettoken(lsi.model, 5)TextSearch.vectorize! — Method
vectorize!(out::AbstractVector{Float32}, lsi::LatentSemanticIndexing, vec::SparseVectorLike; normalize::Bool=true, minweight::Real=1e-6, isnormalized::Bool=false, topk::Union{Nothing,Integer}=nothing)
vectorize!(out::AbstractVector{Float32}, lsi::LatentSemanticIndexing, text; normalize::Bool=true, minweight::Real=1e-6, isnormalized::Bool=false, topk::Union{Nothing,Integer}=nothing)Projects a document (sparse vector or raw text) into the lower-dimensional dense LSI space in-place into out.
topk: restrict the projection to the topk heaviest tf-idf entries
topk=nothing (default) projects the full weighted vector, as before. Set topk to an Integer to project only its topk largest-weight entries (ties broken by index, so the result is deterministic) – a cheap ablation with a real, measured effect rather than a theoretical one:
Measured on a 6,175-article Spanish Wikipedia pilot (self near-duplicate retrieval between two disjoint paragraph ranges of the same article, in a separate exploratory sweep outside this repository): topk=4 scores recall@1 0.219 at outdim=64 against 0.182 for the full vector (no topk at all) – fewer, heavier tokens identify a specific document better than the whole weighted bag does. The effect reverses for recall@k at larger k (full vector 0.463 vs topk=4's 0.438): a wider candidate set is better served by more information, a single best guess by less.
Use a larger topk (or none) when indexing documents than when encoding a query at search time. A short query's own generic/template words (e.g. "capital", "government") can crowd out the one entity token that actually disambiguates it out of a small topk, while a document has more legitimate content to choose an anchor set from – measured on the same pilot's real-question evaluation, restricting a query to topk=4 gained almost nothing over the full vector (both near chance), unlike the clear win topk=4 gave on document-vs-document retrieval. There is no universal number this can default to (it trades off against outdim, vocabulary size, and document length), so nothing is enforced – topk is opt-in and symmetric by default (nothing on both sides), and choosing different values for indexing vs. querying is the caller's call to make deliberately.
TextSearch.vectorize — Method
vectorize(lsi::LatentSemanticIndexing, text_or_sparsevec; normalize::Bool=true, minweight::Real=1e-6, isnormalized::Bool=false, topk::Union{Nothing,Integer}=nothing)Projects a raw text or sparse vector into the dense LSI space, returning a Vector{Float32} of length outdim(lsi). See vectorize! for what topk does.
TextSearch.vectorize_corpus — Method
vectorize_corpus(lsi::LatentSemanticIndexing, corpus;
normalize::Bool=true,
minweight::Real=1e-6,
isnormalized::Bool=false,
verbose::Bool=true,
topk::Union{Nothing,Integer}=nothing) -> MatrixDatabase{Matrix{Float32}}Vectorizes every document in corpus into the dense LSI space in parallel across threads via @BATCHES, returning a MatrixDatabase of size (outdim(lsi), length(corpus)) ready for dense similarity search. See vectorize! for what topk does – typically a larger topk (or nothing) here, at indexing time, than at query time.
TextSearch.LSI.LatentSemanticIndexing — Type
LatentSemanticIndexing{M<:AbstractMatrix{Float32}, VM<:VectorModel} <: TextModelLatent Semantic Indexing (LSI) model that projects sparse vector representations produced by a VectorModel into a lower-dimensional dense semantic space via Truncated Singular Value Decomposition (SVD).
Fields
model: The underlyingVectorModelused to tokenize and weight text.P: Dense projection matrix of size(k, m)wherek = outdimandm = indim = vocsize(model).s: Vector of singular values of lengthk.k: Output dimension (k <= maxoutdim).maxoutdim: Requested maximum output dimension (default: 128).scaling: Scaling applied to singular vectors (:none,:inv_singular_values,:singular_values).
TextSearch.LSI.LatentSemanticIndexing — Method
LatentSemanticIndexing(corpus;
config::TextConfig=TextConfig(),
gw::GlobalWeighting=IdfWeighting(),
lw::LocalWeighting=TfWeighting(),
maxoutdim::Integer=128,
normalize::Bool=true,
minweight::Real=1e-6,
isnormalized::Bool=false,
verbose::Bool=true,
scaling::Symbol=:none)Convenience constructor that builds an LSI model directly from a text corpus using default or provided TextConfig.
TextSearch.LSI.LatentSemanticIndexing — Method
LatentSemanticIndexing(config::TextConfig, corpus;
gw::GlobalWeighting=IdfWeighting(),
lw::LocalWeighting=TfWeighting(),
maxoutdim::Integer=128,
normalize::Bool=true,
minweight::Real=1e-6,
isnormalized::Bool=false,
verbose::Bool=true,
scaling::Symbol=:none)Convenience constructor that builds a Vocabulary and VectorModel from config and corpus, then fits and returns a LatentSemanticIndexing model.
TextSearch.LSI.LatentSemanticIndexing — Method
LatentSemanticIndexing(model::VectorModel, corpus;
maxoutdim::Integer=128,
normalize::Bool=true,
minweight::Real=1e-6,
isnormalized::Bool=false,
verbose::Bool=true,
scaling::Symbol=:none,
factorization::Symbol=:auto)Computes a Latent Semantic Indexing (LSI) projection matrix from corpus weighted by model. corpus can be a collection of raw texts or pre-vectorized sparse vectors (AbstractVector{<:SparseVectorLike} or AbstractDatabase).
Keyword Arguments
maxoutdim: Target embedding dimension (default:128).normalize: Whether to L2-normalize vectors during intermediate vectorization (default:true).minweight: Threshold below which sparse vector weights are dropped (default:1e-6).isnormalized: Set totrueif input texts are already normalized (default:false).verbose: Whether to display progress bar during corpus vectorization (default:true).scaling: Scaling factor applied to projection coordinates::none(default): standard orthogonal concept projection P = U_k^T.:inv_singular_values: classical LSI document coordinate scaling P = Σk^{-1} Uk^T.:singular_values: singular value weighted projection P = Σk Uk^T.
factorization: how the truncated SVD is computed, which decides whether a large corpus is tractable at all::auto(default)::fullwhilemin(vocsize, length(corpus))is at mostLSI_FULL_FACTORIZATION_MAX,:lanczosabove it.:lanczos:_lanczos_svd– ARPACK's restarted Lanczos iteration. Exact to working precision and the fastest option at scale; falls back to:fullif ARPACK fails to converge.:full: exact, via a dense Gram matrix and a completeeigen. CostsO(min(m,n)^3)time andmin(m,n)^2memory regardless ofmaxoutdim(it computes every eigenpair and keepsmaxoutdimof them), so it is only appropriate for small corpora.
Both options are exact; the choice is purely about cost, so there is no accuracy knob to tune here.
TextSearch.LSI.LSI_FULL_FACTORIZATION_MAX — Constant
LSI_FULL_FACTORIZATION_MAXLargest Gram-matrix side (min(vocsize, ndocs)) for which factorization=:auto still uses the exact dense :full path. Above it, :auto switches to :lanczos: measured on Spanish Wikipedia slices, :full wins below a couple of thousand documents (n=2000: 4.6s vs 14.8s) and loses badly above (n=8000: 48.8s vs 11.5s), since its cost grows with the cube of this side while ARPACK's is driven by the number of nonzeros.
TextSearch.LSI.QUERY_EXPANSION_APPROX_THRESHOLD — Constant
The vocabulary size past which query_expansion' approx=:auto prefers an approximate index.
SimilaritySearch.Projections.bitsketch — Method
bitsketch(ri::RandomIndexing, doc; minweight::Real=1e-6, isnormalized::Bool=false) -> Vector{UInt64}
vectorize(::Union{Type{BitSketch}, BitSketch}, ri::RandomIndexing, doc; minweight::Real=1e-6, isnormalized::Bool=false) -> Vector{UInt64}Computes a SimHash-style binary bit sketch (packed into UInt64 words) from a document projected by RandomIndexing.
TextSearch.vectorize! — Method
vectorize!(out::AbstractVector{Float32}, ri::RandomIndexing, vec::SparseVectorLike; normalize::Bool=true, minweight::Real=1e-6, isnormalized::Bool=false)
vectorize!(out::AbstractVector{Float32}, ri::RandomIndexing, text; normalize::Bool=true, minweight::Real=1e-6, isnormalized::Bool=false)Projects a document (sparse vector or raw text) into the lower-dimensional dense Random Indexing space in-place into out.
TextSearch.vectorize — Method
vectorize(m::Module, ri::RandomIndexing, text_or_sparsevec; kwargs...)Projects a document into the Random Indexing space and quantizes it using SQu8 or SQgu8.
TextSearch.vectorize — Method
vectorize(ri::RandomIndexing, text_or_sparsevec; normalize::Bool=true, minweight::Real=1e-6, isnormalized::Bool=false) -> Vector{Float32}Projects a raw text or sparse vector into the dense Random Indexing space, returning a Vector{Float32} of length outdim(ri).
TextSearch.vectorize_corpus — Method
vectorize_corpus(m::Module, ri::RandomIndexing, corpus; kwargs...)Projects an entire corpus with Random Indexing and quantizes it with SQu8 or SQgu8.
TextSearch.vectorize_corpus — Method
vectorize_corpus(ri::RandomIndexing, corpus;
normalize::Bool=true,
minweight::Real=1e-6,
isnormalized::Bool=false,
verbose::Bool=true) -> MatrixDatabase{Matrix{Float32}}Vectorizes every document in corpus into the dense Random Indexing space in parallel across threads via @BATCHES, returning a MatrixDatabase of size (outdim(ri), length(corpus)) ready for dense similarity search.
TextSearch.RI.BitSketch — Type
BitSketchType tag used to request binary SimHash-style bit sketches when calling vectorize or vectorize_corpus.
TextSearch.RI.RandomIndexing — Type
RandomIndexing(model::VectorModel, corpus=nothing;
maxoutdim::Integer=1024,
method::Symbol=:gaussian,
rng::AbstractRNG=Random.default_rng())Constructs a RandomIndexing model from a VectorModel.
Arguments
model: The vocabulary and weighting model.corpus: Optional corpus parameter (ignored during construction, provided for API symmetry with LSI).
Keyword Arguments
maxoutdim: Target projection dimension (default:1024).method: Random projection algorithm::gaussian(default): Gaussian random projection matrix with unit-norm columns.:qr: Orthonormal random projection matrix via QR factorization.:sparse_random(or:ternary): Sparse ternary random projection (±1 with sparse support).
rng: Random number generator (default:Random.default_rng()).
TextSearch.RI.RandomIndexing — Type
RandomIndexing{M<:AbstractMatrix{Float32}, VM<:VectorModel} <: TextModelRandom Indexing (RI) model that projects sparse vector representations produced by a VectorModel into a lower-dimensional dense semantic space via random projections (SimilaritySearch.Projections), with default output dimension maxoutdim=1024.
Fields
model: The underlyingVectorModelused to tokenize and weight text.P: Projection matrix of size(k, m)wherek = outdimandm = indim = vocsize(model).k: Output dimension (k = maxoutdim).maxoutdim: Target embedding dimension (default: 1024).method: Random projection method used (:gaussian,:qr,:sparse_random).
TextSearch.RI.RandomIndexing — Method
RandomIndexing(corpus;
config::TextConfig=TextConfig(),
maxoutdim::Integer=1024,
method::Symbol=:gaussian,
gw::GlobalWeighting=IdfWeighting(),
lw::LocalWeighting=TfWeighting(),
verbose::Bool=true,
rng::AbstractRNG=Random.default_rng())Convenience constructor: creates a RandomIndexing model directly from raw corpus using default text configuration.
TextSearch.RI.RandomIndexing — Method
RandomIndexing(config::TextConfig, corpus;
maxoutdim::Integer=1024,
method::Symbol=:gaussian,
gw::GlobalWeighting=IdfWeighting(),
lw::LocalWeighting=TfWeighting(),
minfreq::Integer=1,
maxfreq::Integer=0,
verbose::Bool=true,
rng::AbstractRNG=Random.default_rng())Convenience constructor: builds a Vocabulary and VectorModel from corpus using config, and then creates a RandomIndexing model.