views:

195

answers:

2

Hello. I'm currently working on implementing a fuzzy search for a terminology web service and I'm looking for suggestions on how I might improve the current implementation. It's too much code to share, but I think an explanation might suffice to prompt thoughtful suggestions. I realize it's a lot to read but I'd appreciate any help.

First, the terminology is basically just a number of names (or terms). For each word, we split it into tokens by space and then iterate through each character to add it to the trie. On a terminal node (such as when the character y in strawberry is reached) we store in a list an index to the master term list. So a terminal node can have multiple indices (since the terminal node for strawberry will match 'strawberry' and 'allergy to strawberry').

As for the actual search, the search query is also broken up into tokens by space. The search algorithm is run for each token. The first character of the search token must be a match (so traw will never match strawberry). After that, we go through children of each successive node. If there is child with a character that matches, we continue the search with the next character of the search token. If a child does not match the given character, we look at the children using the current character of the search token (so not advancing it). This is the fuzziness part, so 'stwb' will match 'strawberry'.

When we reach the end of the search token, we will search through the rest of the trie structure at that node to get all potential matches (since the indexes to the master term list are only on the terminal nodes). We call this the roll up. We store the indices by setting their value on a BitSet. Then, we simply and the BitSets from the results of each search token result. We then take, say, the first 1000 or 5000 indices from the anded BitSets and find the actual terms they correspond to. We use Levenshtein to score each term and then sort by score to get our final results.

This works fairly well and is pretty fast. There are over 390k nodes in the tree and over 1.1 million actual term names. However, there are problems with this as it stands.

For example, searching for 'car cat' will return Catheterization, when we don't want it to (since the search query is two words, the result should be at least two). That would be easy enough to check, but it doesn't take care of a situation like Catheterization Procedure, since it is two words. Ideally, we'd want it to match something like Cardiac Catheterization.

Based on the need to correct this, we came up with some changes. For one, we go through the trie in a mixed depth/breadth search. Essentially we go depth first as long as a character matches. Those child nodes that didn't match get added to a priority queue. The priority queue is ordered by edit distance, which can be calculated while searching the trie (since if there's a character match, distance remains the same and if not, it increases by 1). By doing this, we get the edit distance for each word. We are no longer using the BitSet. Instead, it's a map of the index to a Terminfo object. This object stores the index of the query phrase and the term phrase and the score. So if the search is "car cat" and a term matched is "Catheterization procedure" the term phrase indices will be 1 as will the query phrase indices. For "Cardiac Catheterization" the term phrase indices will be 1,2 as will the query phrase indices. As you can see, it's very simple afterward to look at the count of term phrase indices and query phrase indices and if they aren't at least equal to the search word count, they can be discarded.

After that, we add up the edit distances of the words, remove the words from the term that match the term phrase index, and count the remaining letters to get the true edit distance. For example, if you matched the term "allergy to strawberries" and your search query was "straw" you would have a score of 7 from strawberries, then you'd use the term phrase index to discard strawberries from the term, and just count "allergy to" (minus the spaces) to get the score of 16.

This gets us the accurate results we expect. However, it is far too slow. Where before we could get 25-40 ms on one word search, now it could be as much as half a second. It's largely from things like instantiating TermInfo objects, using .add() operations, .put() operations and the fact that we have to return a large number of matches. We could limit each search to only return 1000 matches, but there's no guarantee that the first 1000 results for "car" would match any of the first 1000 matches for "cat" (remember, there are over 1.1. million terms).

Even for a single query word, like cat, we still need a large number of matches. This is because if we search for 'cat' the search is going to match car and roll up all the terminal nodes below it (which will be a lot). However, if we limited the number of results, it would place too heavy an emphasis on words that begin with the query and not the edit distance. Thus, words like catheterization would be more likely to be included than something like coat.

So, basically, are there any thoughts on how we could handle the problems that the second implementation fixed, but without as much of the speed slow down that it introduced? I can include some selected code if it might make things clearer but I didn't want to post a giant wall of code.

A: 

I did a number of iterations of a spelling corrector ages ago, and here's a recent description of the basic method. Basically the dictionary of correct words is in a trie, and the search is a simple branch-and-bound. I used repeated depth-first trie walk, bounded by lev. distance because, since each additional increment of distance results in much more of the trie being walked, the cost, for small distance, is basically exponential in the distance, so going to a combined depth/breadth search doesn't save much but makes it a lot more complicated.

(Aside: You'd be amazed how many ways physicians can try to spell "acetylsalicylic acid".)

I'm surprised at the size of your trie. A basic dictionary of acceptable words is maybe a few thousand. Then there are common prefixes and suffixes. Since the structure is a trie, you can connect together sub-tries and save a lot of space. Like the trie of basic prefixes can connect to the main dictionary, and then the terminal nodes of the main dictionary can connect to the trie of common suffixes (which can in fact contain cycles). In other words, the trie can be generalized into a finite state machine. That gives you a lot of flexibility.

REGARDLESS of all that, you have a performance problem. The nice thing about performance problems is, the worse they are, the easier they are to find. I've been a real pest on StackOverflow pointing this out. This link explains how to do it, links to a detailed example, and tries to dispel some popular myths. In a nutshell, the more time it is spending doing something that you could optimize, the more likely you will catch it doing that if you just pause it and take a look. My suspicion is that a lot of time is going into operations on overblown data structure, rather than just getting to the answer. That's a common situation, but don't fix anything until samples point you directly at the problem.

Mike Dunlavey
So, in your trie structure, what does the key for a node look like? Is it a character or characters/string? When you have multiple words, how does that work?
AHungerArtist
The performance issues are not hard to determine. It's the fact that instead of using BitSets, there's now a map that has an object as its value, and this object has two small sets in it. However, I don't know what else to use to hold the information to get the results I expect. Finding a way to make the trie smaller and keep the search results the same would be a huge help, but I don't really understand how.
AHungerArtist
@AHungerArtist: Just the dumbest possible thing, a character. I had source text, broken into words, and just look up each word. The lookup works as in that link I gave. Basically there's an outer loop that increments lev distance, first searching at 0 (for an exact match) and if none is found, it searches at distance 1. If none found, it goes to 2, etc. Each search walks the trie, but, for example, if the bound is 0, it is equivalent to a direct trie search. If 1, if finds all matches at distance 1, if any, etc.
Mike Dunlavey
@AHungerArtist: To record the results, I just had a string array, and since the trie walk is depth-first, it just keeps the current candidate match in a static character array, just pushing and popping characters as it walks the trie. Then when it hits a terminal match, it just "prints" it to the overall string array. Typically the number of matches at a given distance is 0 or 1. If more than that, then it's ambiguous, but you gotta deal with that anyway.
Mike Dunlavey
Okay, that's what I'm doing as well, at least for the searching. But, the thing is, the search query itself cannot byself return the answer. Like, if I put in "aple sce" to get apple sauce, merely returning "apple sauce" wouldn't help me. I need an index to ALL the terms that would contain "aple sce." Many of these term names are pretty long and for something like "car cat" it can be a high number.
AHungerArtist
@AHungerArtist: It sounds like we're more less on the same page. I hope my experience shed some light.
Mike Dunlavey
+2  A: 

Wow... tough one.

Well why don't you implement lucene? It is the best and current state of the art when it comes to problems like yours afaik.

However I want to share some thoughts...

Fuziness isnt something like straw* its rather the mis typing of some words. And every missing/wrong character adds 1 to the distance.

Its generally very, very hard to have partial matching (wildcards) and fuzziness at the same time!

Tokenizing is generally a good idea.

Everything also heavily depends on the data you get. Are there spelling mistakes in the source files or only in the search queries?

I have seen some pretty nice implementations using multi dimensional range trees.

But I really think if you want to accomplish all of the above you need a pretty neat combination of a graph set and a nice indexing algorithm.

You could for example use a semantic database like sesame and when importing your documents import every token and document as a node. Then depending on position in the document etc you can add a weighted relation.

Then you need the tokens in some structure where you can do efficient fuzzy matches such as bk-trees. I think you could index the tokens in a mysql database and do bit-wise comparision functions to get differences. Theres a function that returns all matching bits, if you translit your strings to ascii and group the bits you could achieve something pretty fast.

However if you matched the tokens to the string you can construct a hypothetical perfect match antity and query your semantic database for the nearest neighbours.

You would have to break the words apart into partial words when tokenizing to achieve partial matches.

However you can do also wildcard matches (prefix, suffix or both) but no fuzziness then.

You can also index the whole word or different concatenations of tokens.

However there may be special bk-tree implementations that support this but i have never seen one.

Joe Hopfgartner