Wikipedia Word Embeddings with LSI

by: Eric S. Téllez

This demonstration shows how to use Latent Semantic Indexing (LSI) to generate dense word embeddings from Spanish and English Wikipedia, and how to use those embeddings for synonym search, arithmetic analogies, and UMAP visualisations.

Embeddings are generated with TextSearch.jl from Wikipedia’s introductory corpus (the first 3 paragraphs per article). The vocabulary profile and global TF-IDF weights were trained on the full corpus (~14M paragraphs in ES, ~46M in EN), but the SVD is computed on the introductory subset—giving more weight to the definitional content of each article.

Note: This demo requires having run generate_wiki_embeddings.jl to produce the embedding files in demos/data/.

using SimilaritySearch, SimSearchManifoldLearning
using TextSearch, TextSearch.LSI
using Plots, StatsBase, LinearAlgebra, Markdown, Random, Printf, JSON3

Loading the embeddings

The embeddings are stored in a GloVe-like text format: one line per token, with the token followed by whitespace-separated vector components.

"""
Loads embeddings in GloVe text format.
Returns (matrix Float16 dim×n, vocab Vector{String}).
We use Float16 to reduce memory usage; distance functions convert to Float32 dynamically during computation.
"""
function load_glove_format(path::String; dtype=Float16, max_words=100_000)
    @info "Loading embeddings from $path (max $max_words words)"
    lines = readlines(path)
    n     = min(length(lines), max_words)
    dim   = length(split(lines[1])) - 1
    M     = Matrix{dtype}(undef, dim, n)
    vocab = Vector{String}(undef, n)
    for i in 1:n
        parts    = split(lines[i])
        vocab[i] = parts[1]
        for (j, v) in enumerate(@view parts[2:end])
            M[j, i] = parse(Float32, v)
        end
    end
    M, vocab
end

# Load Spanish embeddings (top 100k tokens)
emb_es, vocab_es = load_glove_format(joinpath(@__DIR__, "data", "wiki-es-lsi.txt"); max_words=100_000)

# Inverse mapping: token → index
vocab2id_es = Dict(w => i for (i, w) in enumerate(vocab_es))

@info "ES: $(length(vocab_es)) tokens, dim=$(size(emb_es, 1))"
# Load English embeddings (top 100k tokens)
emb_en, vocab_en = load_glove_format(joinpath(@__DIR__, "data", "wiki-en-lsi.txt"); max_words=100_000)
vocab2id_en = Dict(w => i for (i, w) in enumerate(vocab_en))
@info "EN: $(length(vocab_en)) tokens, dim=$(size(emb_en, 1))"

Building the search index

We use SearchGraph with normalized cosine distance to perform fast approximate nearest neighbor search. Since all LSI vectors are unit-normalized (\(\|v\|_2 = 1\)), Dist.NormCosine() computes cosine distance without recalculating vector norms.

dist = Dist.NormCosine()

# Spanish index
db_es  = MatrixDatabase(emb_es)
G_es   = SearchGraph(dist, db_es)
ctx_es = SearchGraphContext(hyperparameters_callback=OptimizeParameters(MinRecall(0.99)))
index!(G_es, ctx_es)
optimize_index!(G_es, ctx_es, MinRecall(0.9))

# English index
db_en  = MatrixDatabase(emb_en)
G_en   = SearchGraph(dist, db_en)
ctx_en = SearchGraphContext(hyperparameters_callback=OptimizeParameters(MinRecall(0.99)))
index!(G_en, ctx_en)
optimize_index!(G_en, ctx_en, MinRecall(0.9))

Query expansion and synonym network

The LSI embeddings allow precomputing a query expansion network: for each token, its \(k\) nearest neighbors in semantic space form an expansion set.

# Load pre-computed query expansion network
qexp_obj = JSON3.read(read(joinpath(@__DIR__, "data", "wiki-es-qexp.json"), String))
qexp_es  = qexp_obj.query_expansion

function show_expansion(qexp, word)
    sym = Symbol(word)
    haskey(qexp, sym) || return Markdown.parse("> ⚠️ '$word' not found in expansion network")
    neighbors = qexp[sym]
    isempty(neighbors) && return Markdown.parse("> '$word' has no expansion (high-frequency token)")
    Markdown.parse("""
    **$word** → $(join(["_$(v)_" for v in neighbors], ", "))
    """)
end

for p in ["ciencia", "guerra", "amor", "tecnología", "ciudad", "arte", "economía"]
    show_expansion(qexp_es, p) |> display
end

cienciaficción, especulativa, fantasía, postciberpunk, Asimov, fandom, fantástica, distópica

guerrabando, Thanagarianos, lucharon, combatieron, bélico, conflicto, luchó, librada

amoramar, amorosa, sueños, Kāmadeva, soledad, amorosas, triste, loca

tecnologíatecnologías, tecnológicas, tecnológicos, informáticas, automatización, tecnológica, desarrollo, robótica

ciudadAlgenrodt, Resurgam, Caleo, אֶת, Mikros, Bulero, Mahrouk, Ftouh

artecontemporáneo, exponer, exposiciones, invisual, Arte, exposición, pictóricas, coleccionismo

economíaeconómicas, económica, económico, macroeconómico, macroeconomía, economistas, productivos, economías

Do arithmetic analogies work?

Analogies of the form “king is to man as queen is to woman” are well known in word2vec models. In LSI, the semantic space is linear and derived from document-level co-occurrence. Linear analogy arithmetic works when concepts share similar document distributions:

\[\vec{v}_{\text{target}} \approx \vec{v}_a - \vec{v}_b + \vec{v}_d \quad \text{where } a:b :: c:d\]

function analogy(G, ctx, vocab, vocab2id, a, b, d, k=10)
    for w in (a, b, d)
        haskey(vocab2id, w) || return Markdown.parse("> ⚠️ Word '$w' not found in vocabulary")
    end
    va = Float32.(G[vocab2id[a]])
    vb = Float32.(G[vocab2id[b]])
    vd = Float32.(G[vocab2id[d]])
    vc = va - vb + vd
    normalize!(vc)

    res = knnqueue(ctx, k + 3)  # request extra candidates to filter out inputs
    search(G, ctx, Float16.(vc), res)

    exclude = Set([a, b, d])
    L = [
        """### Analogy: _$(a)_ is to _$(b)_ as _?_ is to _$(d)_""",
        """| # | Token | Distance |""",
        """|---|-------|----------|"""
    ]
    j = 0
    for p in IdDistView(res)
        w = vocab[p.id]
        w in exclude && continue
        j += 1
        j > k && break
        push!(L, """| $j | **$w** | $(@sprintf "%.4f" p.dist) |""")
    end
    Markdown.parse(join(L, "\n"))
end

# Classic analogies in Spanish
analogy(G_es, ctx_es, vocab_es, vocab2id_es, "rey", "hombre", "mujer") |> display
analogy(G_es, ctx_es, vocab_es, vocab2id_es, "París", "Francia", "España") |> display
analogy(G_es, ctx_es, vocab_es, vocab2id_es, "Madrid", "España", "Francia") |> display
analogy(G_es, ctx_es, vocab_es, vocab2id_es, "perro", "animal", "planta") |> display

Analogy: rey is to hombre as ? is to mujer

# Token Distance
1 consorte 0.1914
2 reina 0.2124
3 III 0.2197
4 IV 0.2231
5 II 0.2271
6 duque 0.2314
7 conde 0.2319
8 regente 0.2349
9 Hohenstaufen 0.2402
10 Balduino 0.2422

Analogy: París is to Francia as ? is to España

# Token Distance
1 Gourmont 0.4688
2 Carpeaux 0.4888
3 Théodore 0.5059
4 Corot 0.5068
5 Gleizes 0.5122
6 Bichat 0.5127
7 Puvis 0.5132
8 Lévy 0.5146
9 Musset 0.5249
10 simbolista 0.5298

Analogy: Madrid is to España as ? is to Francia

# Token Distance
1 Dykinson 0.4155
2 madrid 0.4385
3 Humanes 0.4497
4 Torrelodones 0.4561
5 Carmena 0.4663
6 comisionada 0.4702
7 Griñón 0.4727
8 Leguina 0.4756
9 RUBIO 0.4795
10 BESCAM 0.4810

Analogy: perro is to animal as ? is to planta

# Token Distance
1 disfraza 0.6113
2 soborna 0.6152
3 atormentado 0.6211
4 enamora 0.6230
5 Trama 0.6255
6 Argumento 0.6260
7 novia 0.6270
8 roba 0.6270
9 chica 0.6309
10 obsesiona 0.6309
# Analogies in English
analogy(G_en, ctx_en, vocab_en, vocab2id_en, "king", "man", "woman") |> display
analogy(G_en, ctx_en, vocab_en, vocab2id_en, "Paris", "France", "Spain") |> display
analogy(G_en, ctx_en, vocab_en, vocab2id_en, "Madrid", "Spain", "France") |> display

Analogy: king is to man as ? is to woman

# Token Distance
1 regnant 0.1680
2 consort 0.1709
3 queen 0.1860
4 regent 0.1948
5 princess 0.2129
6 dowager 0.2144
7 throne 0.2280
8 Amalaric 0.2363
9 Laodice 0.2378
10 duchess 0.2402

Analogy: Paris is to France as ? is to Spain

# Token Distance
1 Ribera 0.3403
2 Carles 0.3730
3 Seville 0.3735
4 Pujol 0.3740
5 Valladolid 0.3750
6 Zaragoza 0.3755
7 Aragonese 0.3760
8 Cervera 0.3809
9 Catalonia 0.3848
10 Vallès 0.3867

Analogy: Madrid is to Spain as ? is to France

# Token Distance
1 Belfort 0.1543
2 Hauts 0.1602
3 Essonne 0.1602
4 Oise 0.1709
5 Aisne 0.1758
6 Calais 0.1787
7 Provence 0.1904
8 Pas 0.1914
9 Marne 0.1934
10 Île 0.1938

UMAP visualization

We project a sample of the most frequent vocabulary tokens into 2D using UMAP, using SearchGraph for the kNN neighborhood graph. We color the points using a 3D UMAP embedding normalized to RGB channels.

# Sample the most frequent vocabulary tokens for visualization
sample_size = min(15_000, length(vocab_es))
sample_idx  = 1:sample_size

db_sample  = SubDatabase(db_es, collect(sample_idx))
G_sample   = SearchGraph(dist, db_sample)
ctx_sample = SearchGraphContext(hyperparameters_callback=OptimizeParameters(MinRecall(0.95)))
index!(G_sample, ctx_sample)

e2, e3 = let k=15, n_epochs=75, neg_sample_rate=3, tol=1e-3, min_dist=0.3f0,
              layout=SpectralLayout()
    @time "UMAP 2D" U2 = fit(UMAP, G_sample; k, neg_sample_rate, layout, n_epochs, tol, min_dist)
    @time "UMAP 3D" U3 = fit(U2, 3; neg_sample_rate, n_epochs, tol)
    @time "predict 2D" e2 = clamp.(predict(U2), -10f0, 10f0)
    @time "predict 3D" e3 = clamp.(predict(U3), -10f0, 10f0)
    e2, e3
end
function normcolors(V)
    mn, mx = extrema(V)
    @. V = clamp((V - mn) / (mx - mn), 0, 1)
end

normcolors(@view e3[1, :])
normcolors(@view e3[2, :])
normcolors(@view e3[3, :])

C = [RGB(c[1], c[2], c[3]) for c in eachcol(e3)]
X, Y = view(e2, 1, :), view(e2, 2, :)

scatter(X, Y; color=C, fmt=:png, alpha=0.15, size=(700, 700),
        ma=0.2, ms=2, msw=0, label="",
        yticks=nothing, xticks=nothing, xaxis=false, yaxis=false,
        title="Wikipedia ES — 256d LSI projected with UMAP")

# Annotate ~80 random tokens
for _ in 1:80
    j = rand(1:sample_size)
    annotate!(X[j], Y[j], text(vocab_es[sample_idx[j]], :blue, :right, 5, "sans-serif"))
end
plot!()

Concluding remarks

This demonstration shows how LSI embeddings derived from Wikipedia capture semantic structure: words associated through document co-occurrence naturally cluster in vector space.

Key differences from GloVe/word2vec: - LSI captures full-document co-occurrence rather than local sliding windows, making it particularly strong for broad topical affinities. - Analogies of the form king - man + woman ≈ queen work well when concepts are topically distinct in Wikipedia (countries, capitals, scientific domains). - Morphological or syntactic relations (e.g., plurals, verb tenses) are less emphasized than topical semantics.

Environment and dependencies

Julia Version 1.12.7
Commit 6d172b025e4 (2026-08-15 08:05 UTC)
Build Info:
  Official https://julialang.org release
Platform Info:
  OS: Linux (x86_64-linux-gnu)
  CPU: 64 × Intel(R) Xeon(R) Silver 4216 CPU @ 2.10GHz
  WORD_SIZE: 64
  LLVM: libLLVM-18.1.7 (ORCJIT, cascadelake)
  GC: Built with stock GC
Threads: 64 default, 1 interactive, 64 GC (on 64 virtual cores)
Environment:
  JULIA_PROJECT = @.
  JULIA_NUM_THREADS = auto
  JULIA_LOAD_PATH = @:@stdlib
Status `~/Research/SimilaritySearchDemos/Project.toml`
  [aaaa29a8] Clustering v0.15.8
  [944b1d66] CodecZlib v0.7.9
 [5ae59095] Colors v0.12.11
  [a93c6f00] DataFrames v1.8.2
  [f67ccb44] HDF5 v0.17.3
  [0f8b85d8] JSON3 v1.14.3
  [23fbe1c1] Latexify v0.16.12
  [eb30cadb] MLDatasets v0.7.21
  [06eb3307] ManifoldLearning v0.9.0
 [ca7969ec] PlotlyLight v0.11.1
  [91a5bcdd] Plots v1.41.7
  [27ebfcd6] Primes v0.5.7
  [92933f4c] ProgressMeter v1.11.0
  [ca7ab67e] SimSearchManifoldLearning v0.4.0 `~/Research/SimSearchManifoldLearning.jl`
  [053f045d] SimilaritySearch v1.2.0 `~/Research/SimilaritySearch.jl`
 [2913bbd2] StatsBase v0.33.21
  [7f6f6c8a] TextSearch v1.1.1 `~/Research/TextSearch.jl`
Info Packages marked with  have new versions available but compatibility constraints restrict them from upgrading. To see why use `status --outdated`