Alike Tutorial
Mix.install([
{:alike, "~> 0.1.0"},
{:kino, "~> 0.14"}
])
# Use EXLA for faster inference
Nx.global_default_backend(EXLA.Backend)
Introduction
Alike is a semantic similarity testing library for Elixir. It lets you test if two sentences convey the same meaning using the expressive wave operator <~>.
assert "The cat is sleeping" <~> "A feline is taking a nap"
Under the hood, Alike uses two ML models:
- Sentence Embeddings (all-MiniLM-L12-v2) - Converts sentences to vectors
- NLI Model (nli-distilroberta-base) - Detects contradictions
Let’s explore what Alike can do!
1. The Wave Operator
The <~> operator is the primary way to test semantic similarity:
import Alike.WaveOperator
# Similar sentences return true
"The cat is sleeping" <~> "A feline is taking a nap"
import Alike.WaveOperator
# Different meanings return false
"The weather is nice today" <~> "I enjoy reading books"
import Alike.WaveOperator
# Contradictions also return false
"The sky is blue" <~> "The sky is red"
Try It Yourself
import Alike.WaveOperator
form =
Kino.Control.form(
[
sentence1: Kino.Input.textarea("Sentence 1", default: "Hello, how are you?"),
sentence2: Kino.Input.textarea("Sentence 2", default: "Hi there, how's it going?")
],
submit: "Check Similarity"
)
Kino.listen(form, fn %{data: %{sentence1: s1, sentence2: s2}} ->
{:ok, score} = Alike.similarity(s1, s2)
{:ok, nli} = Alike.classify(s1, s2)
result = s1 <~> s2
IO.puts("Sentence 1: #{s1}")
IO.puts("Sentence 2: #{s2}")
IO.puts("\nSimilarity score: #{Float.round(score, 3)}")
IO.puts("NLI classification: #{nli.label} (#{Float.round(nli.score, 3)})")
IO.puts("Are they alike? #{result}")
end)
form
2. Similarity Scores
For more control, get the raw similarity score (0.0 to 1.0):
# High similarity - same meaning, different words
{:ok, score} = Alike.similarity("The cat is sleeping", "A feline is taking a nap")
IO.puts("Similarity: #{Float.round(score, 3)}")
# Medium similarity - related topics
{:ok, score} = Alike.similarity("I love coffee", "Tea is my favorite drink")
IO.puts("Similarity: #{Float.round(score, 3)}")
# Low similarity - unrelated topics
{:ok, score} = Alike.similarity("The weather is nice", "Quantum physics is complex")
IO.puts("Similarity: #{Float.round(score, 3)}")
Comparing Multiple Sentences
reference = "The quick brown fox jumps over the lazy dog"
comparisons = [
"A fast auburn fox leaps over a sleeping canine",
"The fox jumped over the dog",
"A dog is chasing a fox",
"The weather is beautiful today",
"I need to buy groceries"
]
results =
Enum.map(comparisons, fn sentence ->
{:ok, score} = Alike.similarity(reference, sentence)
%{sentence: sentence, similarity: Float.round(score, 3)}
end)
|> Enum.sort_by(& &1.similarity, :desc)
Kino.DataTable.new(results)
3. NLI Classification
NLI (Natural Language Inference) classifies the logical relationship between sentences:
| Label | Meaning | Example |
|---|---|---|
entailment |
Second follows from first | “A cat sleeps” → “An animal rests” |
contradiction |
Cannot both be true | “Sky is blue” vs “Sky is red” |
neutral |
No logical relationship | “I like coffee” vs “It’s raining” |
# Entailment - the second sentence follows from the first
{:ok, result} = Alike.classify("A dog is running in the park", "An animal is moving")
IO.inspect(result)
# Contradiction - the sentences cannot both be true
{:ok, result} = Alike.classify("The restaurant is open", "The restaurant is closed")
IO.inspect(result)
# Neutral - no logical relationship
{:ok, result} = Alike.classify("She is reading a book", "The car is red")
IO.inspect(result)
Explore NLI Classifications
form =
Kino.Control.form(
[
premise: Kino.Input.textarea("Premise", default: "All birds can fly"),
hypothesis: Kino.Input.textarea("Hypothesis", default: "Penguins cannot fly")
],
submit: "Classify"
)
Kino.listen(form, fn %{data: %{premise: p, hypothesis: h}} ->
{:ok, result} = Alike.classify(p, h)
IO.puts("Premise: #{p}")
IO.puts("Hypothesis: #{h}")
IO.puts("\nClassification: #{result.label}")
IO.puts("Confidence: #{Float.round(result.score, 3)}")
end)
form
4. Testing LLM Outputs
One of Alike’s main use cases is testing LLM-generated content:
# Simulated LLM responses
defmodule MockLLM do
def generate("greeting") do
"Hello! Welcome to our platform. How can I assist you today?"
end
def generate("capital_france") do
"Paris is the capital city of France, known for the Eiffel Tower."
end
def generate("weather") do
"The forecast shows sunny skies with temperatures around 72°F."
end
end
import Alike.WaveOperator
# Test that the greeting is appropriate
response = MockLLM.generate("greeting")
test_cases = [
{"Hi there! How can I help you?", true},
{"Welcome! What can I do for you today?", true},
{"The weather is nice", false},
{"Error: System failure", false}
]
results =
Enum.map(test_cases, fn {expected, should_match} ->
actual_match = response <~> expected
passed = actual_match == should_match
%{
expected: expected,
should_match: should_match,
actual_match: actual_match,
passed: if(passed, do: "PASS", else: "FAIL")
}
end)
Kino.DataTable.new(results)
5. Contradiction Detection
Alike automatically detects contradictions, even when sentences are superficially similar:
import Alike.WaveOperator
contradictions = [
{"The product is in stock", "The product is out of stock"},
{"The meeting is at 9 AM", "The meeting is at 3 PM"},
{"I love this restaurant", "I hate this restaurant"},
{"The test passed", "The test failed"},
{"The door is open", "The door is closed"}
]
results =
Enum.map(contradictions, fn {s1, s2} ->
{:ok, sim_score} = Alike.similarity(s1, s2)
{:ok, nli} = Alike.classify(s1, s2)
alike = s1 <~> s2
%{
sentence1: s1,
sentence2: s2,
similarity: Float.round(sim_score, 3),
nli_label: nli.label,
alike?: alike
}
end)
Kino.DataTable.new(results)
Notice how some contradictions have high similarity scores (because they’re about the same topic), but Alike correctly identifies them as not alike thanks to NLI.
6. Custom Thresholds
You can adjust the similarity threshold for different use cases:
# Default threshold (0.45) - balanced
Alike.alike?("Hello world", "Hi there", threshold: 0.45)
# Stricter threshold - requires closer match
Alike.alike?("Hello world", "Hi there", threshold: 0.7)
# More permissive threshold - accepts looser matches
Alike.alike?("Hello world", "Hi there", threshold: 0.3)
Finding the Right Threshold
sentence1 = "I love drinking coffee in the morning"
sentence2 = "Tea is a popular beverage"
{:ok, score} = Alike.similarity(sentence1, sentence2)
IO.puts("Similarity score: #{Float.round(score, 3)}")
thresholds = [0.3, 0.4, 0.45, 0.5, 0.6, 0.7]
results =
Enum.map(thresholds, fn threshold ->
result = Alike.alike?(sentence1, sentence2, threshold: threshold, check_contradiction: false)
%{threshold: threshold, alike?: result}
end)
Kino.DataTable.new(results)
7. Performance Tips
Disable Contradiction Checking
If you only need similarity (not contradiction detection), disable NLI for faster results:
# Faster - only uses embedding model
{time_fast, result_fast} =
:timer.tc(fn ->
Alike.alike?("Hello world", "Hi there", check_contradiction: false)
end)
# Full check - uses both models
{time_full, result_full} =
:timer.tc(fn ->
Alike.alike?("Hello world", "Hi there")
end)
IO.puts("Without NLI: #{div(time_fast, 1000)}ms")
IO.puts("With NLI: #{div(time_full, 1000)}ms")
8. Real-World Example: Chatbot Testing
Let’s build a simple chatbot tester:
defmodule ChatbotTester do
import Alike.WaveOperator
def test_response(actual, expected_responses) when is_list(expected_responses) do
Enum.any?(expected_responses, fn expected ->
actual <~> expected
end)
end
def test_response(actual, expected) do
actual <~> expected
end
def test_not_contradicts(response, facts) when is_list(facts) do
Enum.all?(facts, fn fact ->
{:ok, %{label: label}} = Alike.classify(fact, response)
label != "contradiction"
end)
end
end
# Test cases for a customer service chatbot
test_cases = [
%{
name: "Greeting test",
response: "Hello! Thanks for reaching out. How can I help you today?",
expected: [
"Hi! How can I assist you?",
"Welcome! What can I do for you?",
"Hello! How may I help?"
]
},
%{
name: "Refund policy",
response: "You can request a refund within 30 days of purchase.",
expected: ["Refunds are available for 30 days after buying"]
},
%{
name: "Store hours",
response: "We're open Monday to Friday, 9 AM to 5 PM.",
expected: ["Business hours are weekdays from nine to five"]
}
]
results =
Enum.map(test_cases, fn tc ->
passed = ChatbotTester.test_response(tc.response, tc.expected)
%{test: tc.name, passed: if(passed, do: "PASS", else: "FAIL")}
end)
Kino.DataTable.new(results)
# Test that responses don't contradict known facts
known_facts = [
"The store is located in New York",
"Returns are accepted within 30 days",
"Customer service is available 24/7"
]
bot_response = "Our store in New York accepts returns for up to 30 days."
consistent = ChatbotTester.test_not_contradicts(bot_response, known_facts)
IO.puts("Response is consistent with facts: #{consistent}")
Summary
| Function | Use Case | Returns |
|---|---|---|
<~> (wave operator) |
Quick similarity check in tests |
true / false |
Alike.alike?/3 |
Similarity check with options |
true / false |
Alike.similarity/2 |
Get raw similarity score |
{:ok, 0.0..1.0} |
Alike.classify/2 |
NLI classification |
{:ok, %{label: _, score: _}} |
Options for alike?/3:
| Option | Default | Description |
|---|---|---|
:threshold |
0.45 |
Minimum similarity score |
:check_contradiction |
true |
Use NLI to detect contradictions |
:timeout |
30_000 |
Timeout in milliseconds |
Tips:
-
Use
<~>in ExUnit tests for clean assertions - Disable contradiction checking when speed matters
- Lower threshold (0.3-0.4) for permissive matching
- Higher threshold (0.6+) for strict matching
-
Cache Bumblebee models in CI (
~/.cache/bumblebee/)