Implement bag-of-words similarity search from scratch
Company: Apple
Role: Machine Learning Engineer
Category: Coding & Algorithms
Difficulty: medium
Interview Round: Technical Screen
Quick Answer: This question evaluates knowledge and implementation skills in text processing, vector-space models, and information retrieval, covering tokenization, term-frequency and TF–IDF weighting, cosine similarity, and ranking for similarity search within the Coding & Algorithms domain.
Part 1: Unicode Text Tokenization with Optional Stopword Removal and Stemming
Constraints
- 0 <= len(text) <= 100000
- stopwords is None or contains at most 10000 strings
- Tokens consist of consecutive Unicode alphanumeric characters as determined by str.isalnum()
- Stemming uses only the simple suffix rules defined in the statement
Examples
Input: ('Hello, WORLD! It\'s 2024.', None, False)
Expected Output: ['hello', 'world', 'it', 's', '2024']
Explanation: Punctuation splits tokens, casefolding lowercases words, and digits are kept.
Input: ('Café naïve — 東京! 😊', [], False)
Expected Output: ['café', 'naïve', '東京']
Explanation: Accented Latin letters and Japanese characters are Unicode alphanumeric; punctuation and emoji split or are ignored.
Hints
- Build tokens by scanning characters one by one; when you see a non-alphanumeric character, finish the current token.
- Use casefold() instead of lower() to handle more Unicode casing rules.
Part 2: Build Term-Frequency and TF-IDF Document Vectors
Constraints
- 0 <= len(doc_ids) == len(documents) <= 10000
- Each doc_id is unique
- Each token is a non-empty string
- Total number of tokens across all documents <= 200000
- TF-IDF values must be rounded to 6 decimal places
Examples
Input: (['d1', 'd2'], [['apple', 'banana', 'apple'], ['banana', 'carrot']], False)
Expected Output: {'d1': {'apple': 2, 'banana': 1}, 'd2': {'banana': 1, 'carrot': 1}}
Explanation: With raw term frequency, weights are just counts within each document.
Input: (['d1', 'd2'], [['apple', 'banana', 'apple'], ['banana', 'carrot']], True)
Expected Output: {'d1': {'apple': 2.81093, 'banana': 1.0}, 'd2': {'banana': 1.0, 'carrot': 1.405465}}
Explanation: For N=2, apple and carrot appear in one document, so their IDF is ln(3/2)+1 = 1.405465. Banana appears in both documents, so its IDF is 1.
Hints
- First count term frequencies per document, then compute document frequency from the set of terms in each document.
- Keep vectors sparse: do not store terms with frequency 0.
Part 3: Compute Cosine or Jaccard Similarity Between Sparse Vectors
Constraints
- 0 <= len(vector_a), len(vector_b) <= 100000
- Term keys are strings
- Weights are finite numbers
- metric is either 'cosine' or 'jaccard'
Examples
Input: ({'apple': 2, 'banana': 1}, {'apple': 1, 'carrot': 1}, 'cosine')
Expected Output: 0.632456
Explanation: The dot product is 2, and the norms are sqrt(5) and sqrt(2), so the cosine is 2/sqrt(10).
Input: ({'apple': 2, 'banana': 1}, {'apple': 1, 'carrot': 1}, 'jaccard')
Expected Output: 0.333333
Explanation: The nonzero term sets share only apple; the union has 3 terms.
Hints
- For cosine similarity, compute the dot product by iterating over the smaller dictionary and looking up matching terms in the larger one.
- For Jaccard similarity, convert nonzero-weight keys to sets and compare overlap with union size.
Part 4: Return Top-K Similar Documents for a Tokenized Query
Constraints
- 0 <= len(doc_ids) == len(documents) <= 10000
- Each doc_id is unique and comparable as a string
- Total number of tokens across all documents <= 200000
- 0 <= len(query_tokens) <= 10000
- 0 <= k <= len(doc_ids)
- Scores are rounded to 6 decimal places
Examples
Input: (['d1', 'd2', 'd3'], [['apple', 'banana', 'apple'], ['banana', 'carrot'], ['apple', 'carrot', 'carrot']], ['apple', 'banana'], 2)
Expected Output: [['d1', 0.948683], ['d2', 0.5]]
Explanation: All three terms have the same IDF. d1 best matches both query terms, d2 matches banana, and d3 matches apple with a lower cosine score. Only top 2 are returned.
Input: (['b', 'a', 'c'], [['x'], ['x'], ['y']], ['x'], 5)
Expected Output: [['a', 1.0], ['b', 1.0]]
Explanation: Documents a and b tie with score 1.0, so lexicographic doc_id order puts a before b. Document c has zero similarity and is omitted.
Hints
- After computing IDF from the corpus, vectorize the query using the same IDF values as the documents.
- A scalable version would use an inverted index so that only documents containing query terms need to be scored.