/**
* @fileoverview Text embedding and semantic similarity utilities using
* HuggingFace Transformers (local MiniLM model) or the remote Inference API.
* Provides cosine-similarity-based document reranking against one or more queries.
*/
import grab from "../utils/grab";
/**
* Text embeddings convert words or phrases into numerical vectors in a high-dimensional
* space, where each dimension represents a semantic feature extracted by a model like
* MiniLM-L6-v2. In this concept space, words with similar meanings have vectors that
* are close together, allowing for quantitative comparisons of semantic similarity.
* These vector representations enable powerful applications in natural language processing,
* including semantic search, text classification, and clustering, by leveraging the
* geometric properties of the embedding space to capture and analyze the relationships
* between words and concepts.
* [Text Embeddings, Classification, and Semantic Search
* (Youtube)](https://www.youtube.com/watch?v=sNa_uiqSlJo&t=129s)
*
*
* @param {string} text - The text to embed.
* @param {Object} [options]
* @param {AutoTokenizer} options.pipeline
* - The pipeline to use for embedding.
* @param {number} options.precision default=4 - The number of decimal places to round to.
* @returns {Promise<{embeddingsDict: Object., embedding: number[]}>}
* @category Similarity
*/
export async function convertTextToEmbedding(
text: string | string[],
options: any = {},
) {
var { precision = 4, pipeline } = options;
if (!pipeline) pipeline = await getEmbeddingModel();
const embedding = await pipeline(text, { pooling: "mean", normalize: true });
// Check if input was an array to determine output format
if (Array.isArray(text)) {
return embedding
.tolist()
.map((vec) => vec.map((num) => parseFloat(num.toFixed(precision))));
} else {
const roundedEmbedding = Array.from((embedding as any).data).map(
(num: any) => parseFloat((num as any).toFixed(precision)),
);
return roundedEmbedding;
}
}
/**
* Calculate the semantic similarity between one text and a list of
* other sentences by comparing their embeddings.
* https://huggingface.co/docs/api-inference/detailed_parameters#sentence-similarity-task
*
*
* @param {string} source_sentence The string that you wish to
* compare the other strings with. This can be a phrase, sentence,
* or longer passage, depending on the model being used.
* @param {Array} sentences A list of strings which will be compared
* against the source_sentence.
* @param {Object} [options]
* @param {string} options.model default="sentence-transformers/all-MiniLM-L6-v2"
* @param {string} options.HF_API_KEY Required https://huggingface.co/settings/tokens
* @returns array of 0-1 similarity scores for each sentence
* @category Similarity
*/
export async function weighRelevanceConceptVectorAPI(
source_sentence,
sentences,
options = {},
) {
var { model = "sentence-transformers/all-MiniLM-L6-v2", HF_API_KEY = "" } =
options as any;
if (!HF_API_KEY) return { error: "No API key" };
const url = `https://api-inference.huggingface.co/models/${model}`;
try {
return await grab(url, {
method: "POST",
headers: {
Authorization: `Bearer ${HF_API_KEY}`,
},
body: JSON.stringify({
inputs: {
source_sentence,
sentences,
},
}),
});
} catch (error) {
console.error("API request failed:", error);
return null;
}
}
/**
* Initialize HuggingFace Transformers pipeline for embedding text.
*
*
* @param {Object} [options]
* @param {string} options.pipelineName default "feature-extraction",
* @param {string} options.modelName default="Xenova/all-MiniLM-L6-v2" -
* The name of the model to use
* @returns {Promise} The pipeline.
* @category Similarity
*/
export async function getEmbeddingModel(options: any = {}) {
const { pipeline } = await (import("@huggingface/transformers") as Promise);
const {
pipelineName = "feature-extraction",
modelName = "Xenova/all-MiniLM-L6-v2",
quantized = true,
gpu = false,
} = options;
return await pipeline(pipelineName, modelName, {
quantized,
dtype: "fp32",
device: gpu ? "webgpu" : "cpu",
} as any);
}
/**
* [Cosine similarity](https://en.wikipedia.org/wiki/Cosine_similarity) gets similarity of two
* vectors by whether they have the same direction (similar) or are poles apart. Cosine similarity
* is often used with text representations to compare how similar two documents or sentences
* are to each other. The output of cosine similarity ranges from -1 to 1, where -1 means the
* two vectors are completely dissimilar, and 1 indicates maximum similarity.
* @param {Array} vectorA
* @param {Array} vectorB
* @returns {number} -1 to 1 similarity score
*/
export function calculateCosineSimilarity(vectorA, vectorB) {
return (
vectorA.reduce((sum, a, i) => sum + a * vectorB[i], 0) /
(Math.sqrt(vectorA.reduce((sum, a) => sum + a * a, 0)) *
Math.sqrt(vectorB.reduce((sum, b) => sum + b * b, 0)))
);
}
///OLDER ======================
/**
* Rerank documents's chunks based on relevance to query,
* based on cosine similarity of their concept vectors generated
* by a 20MB MiniLM transformer model downloaded locally.
*
* [A Complete Overview of Word Embeddings](https://www.youtube.com/watch?v=5MaWmXwxFNQ&t=323s)
* @param {Array} documents
* @param {string} query
* @param {Object} [options]
* @returns {Promise>}
* @category Similarity
*/
export async function weighRelevanceConceptVector(
documents,
query,
options = {},
) {
const docEmbeddings = await convertTextToEmbedding(documents, options);
const queryEmbedding = await convertTextToEmbedding(query, options);
let sortedDocs = docEmbeddings
.map((docEmbedding, i) => ({
index: i,
similarity: calculateCosineSimilarity(queryEmbedding, docEmbedding),
}))
.sort((a, b) => b.similarity - a.similarity)
.map(({ index, similarity }) => ({
content: documents[index],
similarity,
}));
return sortedDocs;
}
/**
* Rerank documents's chunks based on relevance to multiple queries,
* optimizing by embedding documents only once.
*
* @param {Array} documents
* @param {Array} queries
* @param {Object} [options]
* @returns {Promise