- Introduction
- Data representation
- What is "similarity", how are items considered similar
- Similarity search techniques
Finding similar items
- Popularly used in many domains & system
- Example features:
- "Users who viewed this items also viewed:"
- "Similar items:"
- Examples of how they work:
- Retrieves recommendations based on user's preferred items
- Retrieves recommendations based on other similar users
- Determine the level of similarity between a document and an existing one
- Lexical similarity: simple exact/near exact text matching
- Structure/syntactic similarity: structure/ordering of words, sentences or paragraphs
- Sematic similarity: meaning of words and sentences
- Find photos/images similar to input one
- Remove noises
- Measure similarity by color, shapes, layout, objects, etc
real-world data are almost never of primitive types (simple numeric, categorical, etc) => Need to represent data in a format so that similarity measurement & searching techniques can be applied
- Numeric vector/array, eg,.
(0.12, -0.37, 0.84, 0.09, ...) - Most common
- Obtained through a function
f(input) -> output in R^d, where:f: embedding modeld: embedding dimension of the output vectors
- Applicable pairwise similarity mesures: Euclidean, Manhattan, or Lp-norm in general, also Cosine and dot product
- Real-world application: vector databases store data as vector embeddings for fast nearest neighbors lookup
- Minhashing
- A characteristic matrix is a representation of a collection of sets
- each column corresponds to a set
- each row corresponds to an element in the "universal set", which is the set of all elements from all sets
- the value of position (r, c) is 1 if the element in row r is a member of the set in column c; else the value is 0
- for this example: set
$S_1$ has elements a and d; set$S_2$ has element c only;... . {a, b, c, d, e} is the universal set
- for this example: set
- To minhash a set from the matrix, first we pick a permutation of the rows.
- The minhash value of the set is the element of the first row where the column has a 1
- The minhash of the set
$S_1$ for this row permutation (b, e, a, d, c) would be$h(S_1) = a$
- The minhash of the set
- The probability that the minhash (for a permutation of rows) produces the same values for two sets is equal to the Jaccard similarity of those sets
- The chance of minhash picking the same row for both set (in the characteristic matrix) is equal to their Jaccard probability
- Proof:
- Consider the columns for two sets
$S_1$ and$S_2$ . The rows can be divided into 3 types:- Type X rows have 1 in both columns
- Type Y rows have 1 in only one column
- Type Z rows have 0 in both columns
- Let there be
$x$ rows of type X and$y$ rows of type Y.- The Jaccard similarity of the two sets would be:
$SIM(S_1, S_2) = x/(x + y)$ - Now we want to know chance of Minhash picking the same row for both set, or the chance of
$h(S_1) = h(S_2)$ - Consider a random permutation of the rows, we proceed from the top to find
$h(S_1)$ and$h(S_2)$ - The chance of
$h(S_1) = h(S_2)$ is the chance that the first row which is not a Z row, is an X row - And that chance is
$x/(x +y)$
- Consider a random permutation of the rows, we proceed from the top to find
- The Jaccard similarity of the two sets would be:
- Illustration:
- Consider the two set
$S_1$ and$S_4$ .- We can drop every Z row, which leaves us with 3 rows (
$a, d$ and$c$ ), two X row and one Y row - Imagine a random permutation of the rows. The chance that the first row of the new table is an X row would be
$2/3$ - This is the chance of
$h(S_1)$ being equal to$h(S_2)$ , and it is exactly the same as the Jaccard similarity of the two sets
- We can drop every Z row, which leaves us with 3 rows (
- Consider the two set
- Consider the columns for two sets
- Unfortunately, just permutating a large characteristic matrix explicitly is already time-consuming
- We need another way to compute the signature matrix
- Now, to construct a minhash signature for a set S, we use many (n) permutations of the rows:
$h_1, h_2,..., h_n$ - The minhash signature of set S is the vector [$h_1(S), h_2(S),..., h_n(S)$]
- We can form a signature matrix with each column being the minhash signature of a set
- We can simulate the effect of a random permutation by a random hash function that maps row numbers to as many buckets as there are rows (
$k$ buckets for$k$ rows)- Of course, there may be unfilled buckets (or buckets with more than 1 row), but that is insignificant, as long as
$k$ is large enough and there are not too many collisions
- Of course, there may be unfilled buckets (or buckets with more than 1 row), but that is insignificant, as long as
- The process:
- We pick
$n$ randomly chosen hash functions$h_1, h_2, ..., h_n$ on the rows (in place of$n$ permutations) - Let
$SIG(i, c)$ be the element of the signature matrix for the ith hash function and column c (as in, the element at position (i, c) of the signature matrix). Initilize all$SIG(i, c)$ to$\infty$ - For each row
$r$ - Compute
$h_1(r), h_2(r),..., h_n(r)$ - For each column
$c$ :- If
$c$ has 1 in row$r$ :- For i = 1, 2, ..., n:
$SIG(i, c) = min(SIG(i, c), h_i(r))$
- For i = 1, 2, ..., n:
- If
- Compute
- We pick
- Example:
- Loop over all entry in the dataset
- Calculate the distance to query entry
- Return
kbest entries - Time complexity:
O(n * d)
- Locality-sensitive hashing là một kỹ thuật tìm kiếm các phần tử có độ tương đồng với độ chính xác cao và thời gian tìm kiếm dưới tuyến tính. Kỹ thuật này có thể áp dụng cho nhiều loại dữ liệu khác nhau, đặc biệt là các loại dữ liệu có thể biểu diễn hoặc chuyển đổi về vector nhiều chiều hoặc tập hợp đặc trưng.
- Trước khi thực hiện tìm kiếm bằng LSH, ta sẽ cần tiền xử lí dữ liệu để đưa chúng về các vector để có thể áp dụng các hàm băm lên chúng.
Một họ hàm băm nhạy cục bộ (Locality-sensitive families) cần phải có đủ 3 tiêu chí sau để có thể hiệu quả trong việc phân biệt rõ ràng các cặp phần tử có độ tương đồng cao và thấp khác nhau:
- Tính nhạy với sự tương đồng: đây là thuộc tính cốt lõi của hàm băm để phân biệt được các cặp tương đồng hay không tương đồng:
- Nếu 2 phần tử có độ tương đồng cao, hàm băm phải có xác suất cao tạo ra cùng một kết quả.
- Nếu 2 phần tử có độ tương đồng thấp, hàm băm phải có xác suất thấp tạo ra cùng một kết quả.
- Tính độc lập về mặt thống kê: kết quả các hàm băm trong cùng một họ hàm không ảnh hưởng đến nhau. Điều này cho phép ta sử dụng quy tắc nhân xác suất để có thể ước lượng xác suất cho kết quả của hai hay nhiều hàm băm cùng họ.
- Tính hiệu quả, theo hai cách:
- Họ hàm băm phải có thể xác định được các cặp ứng viên trong thời gian nhanh hơn nhiều so với việc so sánh tất cả các cặp trong O(n2). Ví dụ: Kĩ thuật minhash đáp ứng được tiêu chí này vì việc tính toán minhash cho tất cả phần tử chỉ mất độ phức tạp tuyến tính O(n), sau đó những tập có gái trị tương đồng sẽ được gom lại trong một bucket. Từ đây việc kiểm tra chỉ cần thực hiện trong các bucket này thay vì cả tập hợp ban đầu.
- Họ hàm băm phải có khả năng kết hợp để cải thiện hiệu suất: có thể kết hợp nhiều hàm băm lại với nhau để tạo ra những hàm tốt hơn, phòng tránh các trường hợp dương tính giả hay âm tính giả (những cặp không giống nhau được gom chung vào một bucket và ngược lại).
Đây là một kĩ thuật sử dụng các hàm băm nhạy cục bộ (locality-sensitive functions) để băm các phần tử rồi gom các ứng viên (các phần tử có xác suất tương đồng cao) vào cùng một bucket. Bằng cách này, ta có thể giảm đáng kể khối lượng tính toán khi chỉ cần so sánh những phần tử trong cùng một bucket với nhau thay vì phải so sánh tất cả các cặp phần tử.
Cách truyền thống để thực hiện locality-sensitive-hash là sử dụng shingling, minhashing và banding.
Đầu tiên, ta cần chuyển dữ liệu dạng text sang các vector thưa sử dụng k-shingling, sau đó sử dụng minhash để tạo ra các chữ kí (signatures). Từ các chữ kí này, ta sử dụng kĩ thuật phân dải (banding) để lọc ra các cặp ứng viên.
- Tổng quan:
- Sau bước minhashing, ta có ma trận chữ kí với n hàng, ta chia n hàng này thành b dải, mỗi dải gồm r hàng. Với mỗi dải, ta sử dụng một hàm băm để băm các vector cột trong dải đó vào nhiều bucket khác nhau. Chúng ta có thể sử dụng một hàm băm cho tất cả các dải, nhưng ta sẽ dùng bộ bucket riêng cho mỗi dải, để những vector cột giống nhau ở các dải khác nhau không bị gom chung một bucket. Sau đó những cặp phần tử được băm vào cùng một bucket ở mỗi dải sẽ trở thành những cặp ứng viên để kiểm tra chính xác độ tương đồng.
- Phân tích:
- Giả sử một cặp phần tử có độ tương đồng Jaccard là s, xác suất minhash signatures của chúng trùng khớp nhau trong một hàng bất kì cũng là s:
- Xác suất signature của chúng trùng khớp trong mọi hàng của một dải: sr
- Xác suất signature của chúng không trùng khớp trong ít nhất một hàng của một dải: 1 - sr
- Xác suất signature của chúng không trùng khớp trong ít nhất một hàng của mỗi dải: (1 - sr)^b
- Xác suất signature của chúng trùng khớp trong mọi hàng của ít nhất một dải và do đó trở thành cặp ứng viên: 1 - (1 - sr)^b
- Khi ta vẽ đồ thị của công thức trên bất kể b, r thế nào và s chạy từ 0 đến 1, ta nhận được một đường cong hình chữ S rất đặc trưng với 3 phần:
- Phần bên trái thấp, gấn 0.
- Phần giữa tăng vọt đột ngột.
- Phần bên phải cao, gần 1.
- Phần dốc ở giữa giúp ta lọc dữ liệu đúng như mong muốn. Điểm mà đường cong bắt đầu dốc lên xấp xỉ bằng (1/b)(1/r). Bằng cách điều chỉnh b và r, ta có thể di chuyển ngưỡng này cho phù hợp với bài toán.
1. Tạo tập Shingle:
Function K_SHINGLING(document, k):
// Convert document to set of k-character shingles
// Preprocess text
text = to_lowercase(document)
text = remove_extra_whitespace(text)
// Create shingle set
shingles = empty_set()
For i from 0 to (length(text) - k):
shingle = text[i : i+k]
shingles.add(shingle)
Return shingles
Function CREATE_VOCABULARY(document_list, k):
// Create vocabulary of all shingles from corpus
vocabulary = empty_set()
For each document in document_list:
shingles = K_SHINGLING(document, k)
vocabulary = vocabulary ∪ shingles
Return vocabulary
Function CONVERT_TO_VECTOR(shingles, vocabulary):
// Convert shingle set to one-hot encoded vector
vector = zero_array(length(vocabulary))
For each shingle in shingles:
If shingle in vocabulary:
index = vocabulary.find_index(shingle)
vector[index] = 1
Return vector
2. Tạo chữ kí (Minhashing):
Function CREATE_MINHASH_FUNCTIONS(num_hash_functions, vocab_size):
// Create n hash functions using random permutations
hash_functions = []
For i from 1 to num_hash_functions:
permutation = random_permutation([0, 1, 2, ..., vocab_size-1])
hash_functions.append(permutation)
Return hash_functions
Function COMPUTE_MINHASH_SIGNATURE(sparse_vector, hash_functions):
// Create MinHash signature from sparse vector
signature = []
For each permutation in hash_functions:
min_hash_value = INFINITY
For position from 0 to length(permutation)-1:
permuted_index = permutation[position]
If sparse_vector[permuted_index] == 1:
min_hash_value = permuted_index
Break
signature.append(min_hash_value)
Return signature
Function BUILD_SIGNATURE_MATRIX(vector_list, hash_functions):
// Create signature matrix for all documents
signature_matrix = []
For each vector in vector_list:
signature = COMPUTE_MINHASH_SIGNATURE(vector, hash_functions)
signature_matrix.append(signature)
Return signature_matrix
3. Tìm các cặp ứng viên với kĩ thuật phân dải:
Function SPLIT_INTO_BANDS(signature, num_bands):
// Divide signature into b bands
signature_length = length(signature)
If signature_length % num_bands ≠ 0:
Error "Signature cannot be evenly divided into b bands"
rows_per_band = signature_length / num_bands
bands = []
For i from 0 to num_bands-1:
start_idx = i × rows_per_band
end_idx = start_idx + rows_per_band
band = signature[start_idx : end_idx]
bands.append(band)
Return bands
Function BUILD_LSH_INDEX(signature_matrix, num_bands, document_ids):
// Build LSH index using banding technique
buckets = {} // Dictionary: (band_id, hash_value) → document list
For i from 0 to length(signature_matrix)-1:
signature = signature_matrix[i]
doc_id = document_ids[i]
// Split signature into bands
bands = SPLIT_INTO_BANDS(signature, num_bands)
// Hash each band
For band_idx from 0 to length(bands)-1:
band = bands[band_idx]
// Compute hash of band
hash_value = hash(tuple(band))
// Create bucket key
bucket_key = (band_idx, hash_value)
// Add document to bucket
If bucket_key not in buckets:
buckets[bucket_key] = []
buckets[bucket_key].append(doc_id)
Return buckets
Function FIND_CANDIDATE_PAIRS(buckets):
// Find candidate pairs from buckets
candidate_pairs = empty_set()
For each bucket_key in buckets:
docs_in_bucket = buckets[bucket_key]
If length(docs_in_bucket) >= 2:
For i from 0 to length(docs_in_bucket)-2:
For j from i+1 to length(docs_in_bucket)-1:
doc1 = docs_in_bucket[i]
doc2 = docs_in_bucket[j]
pair = (min(doc1, doc2), max(doc1, doc2))
candidate_pairs.add(pair)
Return candidate_pairs
4. Xác minh các cặp ứng viên:
Function COMPUTE_JACCARD_SIMILARITY(shingles1, shingles2):
// Calculate actual Jaccard similarity
intersection = shingles1 ∩ shingles2
union = shingles1 ∪ shingles2
If length(union) == 0:
Return 0
jaccard = length(intersection) / length(union)
Return jaccard
Function FILTER_SIMILAR_PAIRS(candidate_pairs, shingles_map, threshold):
// Verify candidate pairs and filter by threshold
similar_pairs = []
For each (doc1, doc2) in candidate_pairs:
shingles1 = shingles_map[doc1]
shingles2 = shingles_map[doc2]
similarity = COMPUTE_JACCARD_SIMILARITY(shingles1, shingles2)
If similarity >= threshold:
similar_pairs.append((doc1, doc2, similarity))
Return similar_pairs
- Mentioned/introduced in:
- As its name suggest, a KNNG is a data structure that represents the dataset as a graph, with each data entry as a node, and each node has edges connecting it with a number of nodes closest to it, for simplicity, this number is represented by a fixed parameter
kfor every nodes - Example K-NNG with
10nodes and Euclidian distance: - Once we know that a KNNG has that characteristic, we have the ability to design many algorithms to perform similarity search
- The problem then becomes "How can we construct a KNNG"
- Brute force:
O(n^2 * d)wherenis the number of nodes in the datasetdis the time complexity of the distance function- This approach is obviously not practical
for every node u <- O(n)
for every other node v <- O(n)
compute d(u, v) <- O(d)
find v-k which is the k-th nearest node to u <- O(n) average (quickselect)
find every node v closer to u than v-k <- O(n)
add them to u's neighbor list <- O(k)
- Introduced in Efficient K-Nearest Neighbor Graph Construction for Generic Similarity Measures
- An algorithm to efficiently construct a approximate K-NNG (K-Nearest Neighbors Graph)
- Approximate: the result is almost correct but not completely
- Why approximate: sacrifice accuracy to gain speed
- Based on the assumption: A neighbor of a neighbor is likely also a direct neighbor
- Assumption -> Basically heuristic, thats why the result is approximate, not exact
- Basically, the algorithm works as follows:
- Construct a random graph (random means that the the the graph has the correct number of nodes and edges, but the edges dont connect the correct pairs of nodes)
- Gradually improve the graph through many iterations, in each iteration, the algorithm checks every nodes and improve it based on its current neighbors
- The algorithm stops when no further improvements can be made
for every node u
randomly or heuristically pick k other nodes
loop
for every node u
for every v neighboring u
for every p neighboring v other than u
compute d(u, p)
find k closest nodes to u, update u's neighbor list
if number of updates is less than threshold
stop loop
- Time complexity:
O(m * n * k^2 * d)- The time complexity basically depends on the number of iterations
- In reality, it is much better than brute-force
- When experimenting on normal dataset, authors got the time complexity of
O(n^1.14 * d)
- Advantages:
- Scalable thanks to the aforementioned time complexity
- Space efficient: constructs the K-NNG only once and improves it in-place, aside from that the algorithm doesnt store any extra data
- Accurate: High accuracy (compared to brute-force) for real dataset, according to authors, the accuracy is
>90%and out-perform Recursive Lanczos Partitioning and LSH - Easy to implement
- Essentially, searching algorithms on KNNG are heuristic/meta-heuristic algorithms
- Specifically, can imagine the graph as the solution space, a node is a specific solution, and the heuristic algorithm moves in the direction of the nodes closest to query nodes to find global optima
- If youve understood the idea, we will skip the algorithm details, you can check out the Optimization course to understand heuristic better
- The important thing is that most algorithms are pretty accurate and have far better time complexity than brute-force
randomly pick starting_nodes
for every u in starting_nodes
compute d(q, u)
add u to min_heap and explored_nodes
loop
u = min_heap.pop
update best_discovered_node
for every v neighboring u
if v not in explored_nodes
compute d(q, v)
add v to min_heap and explored_nodes
if stopping conditions met
stop loop
pick k best nodes from explored_nodes
- K-NNG doesn't naturally support updates so we have choose a workaround
- Local update: When inserting a node, only adjust nearby nodes, or even more simply flag a node in case of deletion
- As this is workaround, the quality of the graph can slowly decrease
- To mitigate, we can perform periodical rebuild
- Periodical rebuild: after a specific period of time, rebuild the entire graph so that the quality of the graph is always above a specific threshold
- Pros:
- Biggest pro of KNNG is that it represents the local structure of the dataset
- Cons:
- But that is also its biggest con, because it only represents the local structure and doesnt guarantee any global characteristic
- Because of that, the quality of the graph heavily depends on the quality of the dataset
- As you can see from the example, if the dataset is fragmented, then the graph is also fragmented
- Because the nodes are not connected, it is hard to design an algorithm to perform similarity search
- Because of the aforementioned pros and cons, KNNG graph is best fit for:
- Static dataset: No updates, graph doesn't degrade, no need to rebuild
- Moderately-sized dataset: so that performance is acceptable
- Dense dataset: The graph is more connected, algorithms have better accuracy
- Small world phenomenon: appear when individuals in a very large social networks are connected through very short chains of acquaintances
- More familiar is the 6 degrees of separation rule, stating that everyone in the world are connected to eachother through at most 6 people in between, this is the inspiration for Mark Zucc's Facebook
- People found out that a K-NNG can gain small world when it gains a number of long-ranged edges
- When that happen, the KNNG is called a small world graph
- The result:
- Clusters in the graph are connected
- Node pairs that are previously not connected, are now connected
- Node pairs that are previously connected, now have shorter path
- If the small world characteristic is replicated well, the shortest path between nodes are very short, so this improves performance and accuracy of algorithms tremendously
- Historically, SWG was mentioned around 2012-2014, and was quickly developed into HNSWG, introduced in 2016, so we'll jump to the that section immediately
- Evolution from NSWG
- When talking about the jump from KNNG to SWG, people consider it revolutionary
- The jump from SWG to HNSWG isnt as significant, but is still significant in aspects like scalability, performance, accuracy
- This data structure, in reality, is the foundation for the most modern algorithms (state of the art) like FAISS and modern vector databases
- As mentioned, HNSWG evolved from SWG, so it also has small world characteristic thanks to long ranged edges
- The difference is that SWG jams both normal edges and long-ranged edges into the same data structure, HNSWG separates edges into layers depending on its length
- See following example
- When examined separately, each layer by itself is a KNNG
- But their density is different:
- The 0th layer has every node from the entire dataset,
- The 1th layer only has a subset of the dataset
- Upper layers have even less nodes
- So in the context of the entire dataset, only the 0th layer is a KNNG, the further up we go, the longer the edges become
- Basically the same as KNNG
- The main idea is that we use search results of upper layers as entry points of lower layers
- Specifically:
- Start on the top-most layer
- Perform search on that layer
- Descent onto the next layer, use the obtain nodes to start searching again
- Repeat until the lowest layer
- Elaborate on search process
- From the heuristic standpoint, algorith has 2 phases:
- Searching on upper layers: exploration: traverse solution space for a region that looks good
- Descending down to lower layers: switching from exploration to exploitation: exploiting said region for specific global optimal solutions
- Unlike KNNG, HNSWG can be constructed incrementally by sequentially inserting nodes
- When inserting a node, we need to decide which layers to insert it into
- This process is usually decided by probability
- Specifically:
- A node has 100% chance of being inserted into layer 0
- Going up to layer 1, it has probability of being inserted
p < 1 - If it is inserted into layer 1, it has probability of being inserted into layer 2
p ^ 2 - Continue until one layer where the probability fail
- After that process, the distribution of nodes across layers is similar to an exponentially decreasing function
- Suppose node
uis decided to be inserted into layers 0 to L, on each layer, we find its neighbor using aforementioned search algorithm
- Like KNNG, does not support deletion well
- Flag nodes as deleted, later searches ignore these flagged
- Periodical rebuild









