Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/georgeguimaraes/arcana/llms.txt

Use this file to discover all available pages before exploring further.

Arcana supports three search modes across two vector store backends. This guide explains how each algorithm works under the hood.

Search Modes Overview

ModePurposeMemory BackendPgVector Backend
:semanticFind similar meaningHNSWLib cosine similaritypgvector HNSW index
:fulltextFind keyword matchesTF-IDF-like scoringPostgreSQL tsvector
:hybridCombine bothTwo queries + RRFSingle-query with weights
Both backends use cosine similarity to find semantically similar content.

Memory Backend (HNSWLib)

Uses Hierarchical Navigable Small World graphs for approximate nearest neighbor search.
Query embedding → HNSWLib.Index.knn_query → Top-k neighbors by cosine distance
Score calculation:
score = 1.0 - cosine_distance
Where cosine_distance = 1 - cosine_similarity. A score of 1.0 means identical vectors. Complexity: O(log n) average case for k-NN queries.

PgVector Backend

Uses PostgreSQL’s pgvector extension with HNSW indexing.
SELECT *, 1 - (embedding <=> query_embedding) AS score
FROM arcana_chunks
ORDER BY embedding <=> query_embedding
LIMIT 10
The <=> operator computes cosine distance. The HNSW index makes this efficient even for millions of vectors.

Memory Backend: TF-IDF-like Scoring

A simplified term-matching algorithm inspired by TF-IDF:
def calculate_text_score(query_terms, document_text) do
  doc_terms = tokenize(document_text)
  matching = count_matching_terms(query_terms, doc_terms)

  # What fraction of query terms appear in the document
  term_ratio = matching / length(query_terms)

  # Penalize long documents (they match more by chance)
  length_factor = 1.0 / :math.log(length(doc_terms) + 1)

  term_ratio * length_factor
end
Example: Query: "elixir pattern matching" (3 terms)
DocumentMatchesTerm RatioLengthLength FactorScore
”Pattern matching in Elixir is powerful”31.060.510.51
”Elixir is great”10.3330.720.24
”A very long document about many topics including elixir…“10.33500.260.09
Why “TF-IDF-like” not actual TF-IDF:
FeatureReal TF-IDFMemory Backend
Term frequencyCounts occurrencesBinary (present/absent)
Inverse document frequencyCorpus-wide statisticsNo corpus index
Document length normalizationYesYes (via log factor)
The simplification avoids maintaining a persistent term index, which would add complexity to an in-memory store.
Uses PostgreSQL’s battle-tested full-text search with tsvector and tsquery:
SELECT *,
  ts_rank(to_tsvector('english', text), to_tsquery('english', 'elixir & pattern & matching')) AS score
FROM arcana_chunks
WHERE to_tsvector('english', text) @@ to_tsquery('english', 'elixir & pattern & matching')
ORDER BY score DESC
1

to_tsvector

Converts text to a searchable vector of lexemes (normalized word forms):
  • “running” → “run”
  • “patterns” → “pattern”
  • Removes stop words (“the”, “is”, “a”)
2

to_tsquery

Converts query to search terms joined with & (AND):
  • "elixir pattern matching"'elixir' & 'pattern' & 'match'
3

@@ operator

Returns true if document matches query
4

ts_rank

Scores documents by:
  • Term frequency in document
  • Inverse document frequency (rarity)
  • Term proximity (how close terms appear)
Advantages over Memory backend:
  • Stemming (matches “running” when searching “run”)
  • Stop word removal
  • Proximity scoring
  • Language-aware processing
Hybrid mode combines semantic and fulltext search. The implementation differs by backend:
BackendApproachAdvantages
PgVectorSingle-query weighted combinationBetter coverage, configurable weights
MemoryTwo queries + RRFSimple, rank-based fusion

PgVector Backend: Single-Query Hybrid

The pgvector backend uses a single SQL query that combines both scores:
WITH base_scores AS (
  SELECT
    id, text, embedding,
    1 - (embedding <=> query_embedding) AS semantic_score,
    ts_rank(to_tsvector('english', text), query) AS fulltext_score
  FROM arcana_chunks
),
normalized AS (
  SELECT *,
    (fulltext_score - MIN(fulltext_score) OVER ()) /
    NULLIF(MAX(fulltext_score) OVER () - MIN(fulltext_score) OVER (), 0)
    AS fulltext_normalized
  FROM base_scores
)
SELECT *,
  (semantic_weight * semantic_score + fulltext_weight * fulltext_normalized) AS hybrid_score
FROM normalized
ORDER BY hybrid_score DESC
Why single-query is better:With separate queries, items ranking moderately in both lists might be missed. For example:
  • Semantic search fetches top 20
  • Fulltext search fetches top 20
  • An item ranking #15 in both could be highly relevant overall, but RRF only sees it at position 15
Single-query evaluates all chunks, ensuring nothing is missed.
Score normalization:
  • Semantic scores (cosine similarity) naturally range 0-1
  • Fulltext scores (ts_rank) vary widely based on document content
  • The query normalizes fulltext scores using min-max scaling within the result set
Configurable weights:
{:ok, results} = Arcana.search("query", repo: Repo, mode: :hybrid)
Results include individual scores for debugging:
%{
  id: "...",
  text: "...",
  score: 0.75,           # Combined hybrid score
  semantic_score: 0.82,  # Cosine similarity
  fulltext_score: 0.68   # Raw ts_rank score
}

Memory Backend: Reciprocal Rank Fusion (RRF)

For the memory backend, hybrid search uses Reciprocal Rank Fusion to combine results from separate queries. The Problem: Semantic and fulltext searches return scores on different scales:
  • Semantic: 0.0 to 1.0 (cosine similarity)
  • Fulltext: Unbounded (ts_rank or term matching)
Naively averaging scores would bias toward one method. The Solution: RRF RRF scores by rank position, not raw score:
def rrf_score(rank, k \\ 60) do
  1.0 / (k + rank)
end
Where k is a constant (default 60) that prevents top-ranked items from dominating. Algorithm:
def rrf_combine(semantic_results, fulltext_results, limit) do
  # Build rank maps
  semantic_ranks = build_rank_map(semantic_results)
  fulltext_ranks = build_rank_map(fulltext_results)

  # Combine all unique IDs
  all_ids = MapSet.union(Map.keys(semantic_ranks), Map.keys(fulltext_ranks))

  # Calculate RRF score for each
  all_ids
  |> Enum.map(fn id ->
    semantic_rank = Map.get(semantic_ranks, id, 1000)  # Default: low rank
    fulltext_rank = Map.get(fulltext_ranks, id, 1000)

    rrf_score = 1/(60 + semantic_rank) + 1/(60 + fulltext_rank)
    {id, rrf_score}
  end)
  |> Enum.sort_by(&elem(&1, 1), :desc)
  |> Enum.take(limit)
end
Example: Query: "BEAM virtual machine"
DocumentSemantic RankFulltext RankSemantic RRFFulltext RRFCombined
”Erlang runs on the BEAM VM”120.0160.0160.032
”The BEAM virtual machine”-10.0010.0160.017
”The BEAM is Erlang’s runtime”2-0.0160.0010.017
Documents appearing in both result sets get boosted to the top.

Choosing the Right Mode

Use CaseRecommended Mode
Conceptual questions (“How does X work?”):semantic
Exact terms, names, codes:fulltext
General search, unknown query type:hybrid
API/function lookup:fulltext
Finding related concepts:semantic

Backend Comparison

AspectMemoryPgVector
SetupNo database neededRequires PostgreSQL + pgvector
PersistenceLost on restartPersisted
Semantic searchHNSWLib (excellent)pgvector HNSW (excellent)
Fulltext searchBasic term matchingFull linguistic processing
StemmingNoYes
Stop wordsNoYes
Scale< 100K vectorsMillions of vectors
Best forTesting, small appsProduction

Next Steps

Basic Usage

Learn essential ingest, search, and ask patterns

Evaluation

Compare search modes with retrieval metrics

Re-ranking

Improve results with second-stage scoring

GraphRAG

Combine graph and vector search with fusion