С# Поиск соответствующих фрагментов документа для отображения результатов поиска

При разработке поиска сайта, который я создаю, я решил пойти дешевым и быстрым способом и использовать Microsoft Sql Server для полнотекстового поиска, а не нечто более надежное, как Lucene.Net.

Одна из функций, которые я хотел бы иметь, - это соответствующие фрагменты документа, относящиеся к google-esque. Я быстро обнаружил, что определение "релевантных" фрагментов сложнее, чем я понял.

Я хочу выбрать фрагменты, основанные на плотности поискового запроса в найденном тексте. Поэтому, по сути, мне нужно найти самый поисковый термин в тексте. Где прохождение - какое-то произвольное количество символов (скажем 200, но это действительно неважно).

Моя первая мысль - использовать .IndexOf() в цикле и построить массив терминальных расстояний (вычесть индекс найденного термина из ранее найденного термина), то... что? Добавьте любые два, три, четыре, пять, последовательные элементы массива и используйте ту, которая имеет наименьшую сумму (следовательно, наименьшее расстояние между поисковыми запросами).

Это кажется грязным.

Есть ли установленный, лучший или более очевидный способ сделать это, чем то, что я придумал?

Ответ 2

Я знаю, что эта тема устарела, но на прошлой неделе я попробовал попробовать, и это было болью на обратной стороне. Это далеко не идеально, но это то, что я придумал.

Генератор фрагментов:

public static string SelectKeywordSnippets(string StringToSnip, string[] Keywords, int SnippetLength)
    {
        string snippedString = "";
        List<int> keywordLocations = new List<int>();

        //Get the locations of all keywords
        for (int i = 0; i < Keywords.Count(); i++)
            keywordLocations.AddRange(SharedTools.IndexOfAll(StringToSnip, Keywords[i], StringComparison.CurrentCultureIgnoreCase));

        //Sort locations
        keywordLocations.Sort();

        //Remove locations which are closer to each other than the SnippetLength
        if (keywordLocations.Count > 1)
        {
            bool found = true;
            while (found)
            {
                found = false;
                for (int i = keywordLocations.Count - 1; i > 0; i--)
                    if (keywordLocations[i] - keywordLocations[i - 1] < SnippetLength / 2)
                    {
                        keywordLocations[i - 1] = (keywordLocations[i] + keywordLocations[i - 1]) / 2;

                        keywordLocations.RemoveAt(i);

                        found = true;
                    }
            }
        }

        //Make the snippets
        if (keywordLocations.Count > 0 && keywordLocations[0] - SnippetLength / 2 > 0)
            snippedString = "... ";
        foreach (int i in keywordLocations)
        {
            int stringStart = Math.Max(0, i - SnippetLength / 2);
            int stringEnd = Math.Min(i + SnippetLength / 2, StringToSnip.Length);
            int stringLength = Math.Min(stringEnd - stringStart, StringToSnip.Length - stringStart);
            snippedString += StringToSnip.Substring(stringStart, stringLength);
            if (stringEnd < StringToSnip.Length) snippedString += " ... ";
            if (snippedString.Length > 200) break;
        }

        return snippedString;

    }

Функция, которая найдет индекс всех ключевых слов в тексте примера

 private static List<int> IndexOfAll(string haystack, string needle, StringComparison Comparison)
    {
        int pos;
        int offset = 0;
        int length = needle.Length;
        List<int> positions = new List<int>();
        while ((pos = haystack.IndexOf(needle, offset, Comparison)) != -1)
        {
            positions.Add(pos);
            offset = pos + length;
        }
        return positions;
    }

Это немного неуклюже в его исполнении. То, как он работает, - это найти позицию всех ключевых слов в строке. Затем, проверяя, что ключевые слова не ближе друг к другу, чем требуемая длина фрагмента, так что фрагменты не будут перекрываться (что там, где это бит iffy...). И затем захватывает подстроки нужной длины, центрированные вокруг положения ключевых слов, и скрепляет все это вместе.

Я знаю, что это уже много лет, но выставляю на всякий случай, что это может помочь кому-то решить этот вопрос.

Ответ 3

Это хорошая проблема:)

Я думаю, что создаю индексный вектор: для каждого слова создайте запись 1 в случае поиска или иначе 0. Затем найдите я так, чтобы сумма (indexvector [i: я + maxlength]) была максимизирована.

Это можно сделать довольно эффективно. Начните с количества поисковых запросов в первых словах maxlength. затем, когда вы двигаетесь дальше, уменьшите свой счетчик, если indexvector [i] = 1 (т.е. вы потеряете этот поисковый запрос при увеличении i) и увеличьте его, если indexvector [i + maxlength + 1] = 1. По мере того, как вы идете, отслеживайте я с наивысшим значением счетчика.

Как только вы получите свой любимый i, вы все равно можете делать финализацию, например, можете ли вы уменьшить фактический размер без ущерба для вашего счетчика, например. чтобы найти границы предложений или что-то еще. Или как выбор правильного числа я из числа с эквивалентными значениями счетчика.

Не уверен, что это лучший подход, чем ваш - он другой.

Вы также можете просмотреть этот документ по этой теме, который содержит еще одну базовую линию: http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.72.4357&rep=rep1&type=pdf

Ответ 4

public class Highlighter
{        
    private class Packet
    {
        public string Sentence;
        public double Density;
        public int Offset;
    }

    public static string FindSnippet(string text, string query, int maxLength)
    {
        if (maxLength < 0)
        {
            throw new ArgumentException("maxLength");
        }
        var words = query.Split(' ').Where(w => !string.IsNullOrWhiteSpace(w)).Select(word => word.ToLower()).ToLookup(s => s);             
        var sentences = text.Split('.');
        var i = 0;
        var packets = sentences.Select(sentence => new Packet 
        { 
            Sentence = sentence, 
            Density = ComputeDensity(words, sentence),
            Offset = i++
        }).OrderByDescending(packet => packet.Density);
        var list = new SortedList<int, string>();            
        int length = 0;                
        foreach (var packet in packets)
        {
            if (length >= maxLength || packet.Density == 0)
            {
                break;
            }
            string sentence = packet.Sentence;
            list.Add(packet.Offset, sentence.Substring(0, Math.Min(sentence.Length, maxLength - length)));
            length += packet.Sentence.Length;
        }
        var sb = new List<string>();
        int previous = -1;
        foreach (var item in list)
        {
            var offset = item.Key;
            var sentence = item.Value;
            if (previous != -1 && offset - previous != 1)
            {
                sb.Add(".");
            }
            previous = offset;             
            sb.Add(Highlight(sentence, words));                
        }
        return String.Join(".", sb);
    }

    private static string Highlight(string sentence, ILookup<string, string> words)
    {
        var sb = new List<string>();
        var ff = true;
        foreach (var word in sentence.Split(' '))
        {
            var token = word.ToLower();
            if (ff && words.Contains(token))
            {
                sb.Add("[[HIGHLIGHT]]");
                ff = !ff;
            }
            if (!ff && !string.IsNullOrWhiteSpace(token) && !words.Contains(token))
            {
                sb.Add("[[ENDHIGHLIGHT]]");
                ff = !ff;
            }
            sb.Add(word);
        }
        if (!ff)
        {
            sb.Add("[[ENDHIGHLIGHT]]");
        }
        return String.Join(" ", sb);
    }

    private static double ComputeDensity(ILookup<string, string> words, string sentence)
    {            
        if (string.IsNullOrEmpty(sentence) || words.Count == 0)
        {
            return 0;
        }
        int numerator = 0;
        int denominator = 0;
        foreach(var word in sentence.Split(' ').Select(w => w.ToLower()))
        {
            if (words.Contains(word))
            {
                numerator++;
            }
            denominator++;
        }
        if (denominator != 0)
        {
            return (double)numerator / denominator;
        }
        else
        {
            return 0;
        }
    }
}

Пример:

выделить "Оптический поток определяется как изменение структурированного света в изображении, например, на сетчатке или датчике камеры, из-за относительного движения между глазным яблоком или камерой и сценой. Другие определения из литературы показывают различные свойства оптического потока" "оптический поток"

Вывод:

[[HIGHLIGHT]] Оптический поток [[ENDHIGHLIGHT]] определяется как изменение структурированного  свет в изображении, e... Дальнейшие определения из литературы подчеркивают diff [[HIGHLIGHT]] оптический поток [[ENDHIGHLIGHT]]

Ответ 5

Ну, вот взломанная версия, которую я сделал, используя алгоритм, описанный выше. Я не думаю, что все это здорово. Он использует три (count em, three!), Которые объединяют массив и два списка. Но, ну, это лучше, чем ничего. Я также жестко запрограммировал максимальную длину вместо того, чтобы превращать ее в параметр.

private static string FindRelevantSnippets(string infoText, string[] searchTerms)
    {
        List<int> termLocations = new List<int>();
        foreach (string term in searchTerms)
        {
            int termStart = infoText.IndexOf(term);
            while (termStart > 0)
            {
                termLocations.Add(termStart);
                termStart = infoText.IndexOf(term, termStart + 1);
            }
        }

        if (termLocations.Count == 0)
        {
            if (infoText.Length > 250)
                return infoText.Substring(0, 250);
            else
                return infoText;
        }

        termLocations.Sort();

        List<int> termDistances = new List<int>();
        for (int i = 0; i < termLocations.Count; i++)
        {
            if (i == 0)
            {
                termDistances.Add(0);
                continue;
            }
            termDistances.Add(termLocations[i] - termLocations[i - 1]);
        }

        int smallestSum = int.MaxValue;
        int smallestSumIndex = 0;
        for (int i = 0; i < termDistances.Count; i++)
        {
            int sum = termDistances.Skip(i).Take(5).Sum();
            if (sum < smallestSum)
            {
                smallestSum = sum;
                smallestSumIndex = i;
            }
        }
        int start = Math.Max(termLocations[smallestSumIndex] - 128, 0);
        int len = Math.Min(smallestSum, infoText.Length - start);
        len = Math.Min(len, 250);
        return infoText.Substring(start, len);
    }

Некоторые улучшения, о которых я мог подумать, состоят в том, чтобы возвращать несколько "фрагментов" с более короткой длиной, которая суммируется с большей длиной - таким образом можно отбирать несколько частей документа.

Ответ 6

Если вы используете CONTAINSTABLE, вы получите RANK назад, это по сути значение плотности - выше значение RANK, тем выше плотность. Таким образом, вы просто запускаете запрос для получения желаемых результатов и не должны приводить к массированию данных при их возврате.

Ответ 7

Написал функцию, чтобы сделать это сейчас. Вы хотите перейти:

Входы:

Текст документа
Это полный текст документа, из которого вы снимаете фрагмент. Скорее всего, вам захочется вычеркнуть любой BBCode/HTML из этого документа.

Исходный запрос
Строка, введенная пользователем в качестве поиска

Длина фрагмента
Длина фрагмента, который вы хотите отобразить.

Возвращаемое значение:

Начните индекс текста документа, чтобы извлечь фрагмент. Чтобы получить фрагмент, просто выполните documentText.Substring(returnValue, snippetLength). Это имеет то преимущество, что вы знаете, если фрагмент берется с начала/конца/середины, поэтому вы можете добавить некоторое украшение, например ..., если хотите, чтобы начало/конец фрагмента.

Производительность

A resolution, установленный в 1, найдет лучший фрагмент, но одновременно перемещает окно по 1 char. Установите это значение выше, чтобы ускорить выполнение.

Tweaks

Вы можете работать score, но вы хотите. В этом примере я сделал Math.pow(wordLength, 2) для поддержки более длинных слов.

private static int GetSnippetStartPoint(string documentText, string originalQuery, int snippetLength)
{
    // Normalise document text
    documentText = documentText.Trim();
    if (string.IsNullOrWhiteSpace(documentText)) return 0;

    // Return 0 if entire doc fits in snippet
    if (documentText.Length <= snippetLength) return 0;

    // Break query down into words
    var wordsInQuery = new HashSet<string>();
    {
        var queryWords = originalQuery.Split(' ');
        foreach (var word in queryWords)
        {
            var normalisedWord = word.Trim().ToLower();
            if (string.IsNullOrWhiteSpace(normalisedWord)) continue;
            if (wordsInQuery.Contains(normalisedWord)) continue;
            wordsInQuery.Add(normalisedWord);
        }
    }

    // Create moving window to get maximum trues
    var windowStart = 0;
    double maxScore = 0;
    var maxWindowStart = 0;

    // Higher number less accurate but faster
    const int resolution = 5;

    while (true)
    {
        var text = documentText.Substring(windowStart, snippetLength);

        // Get score of this chunk
        // This isn't perfect, as window moves in steps of resolution first and last words will be partial.
        // Could probably be improved to iterate words and not characters.
        var words = text.Split(' ').Select(c => c.Trim().ToLower());
        double score = 0;
        foreach (var word in words)
        {
            if (wordsInQuery.Contains(word))
            {
                // The longer the word, the more important.
                // Can simply replace with score += 1 for simpler model.
                score += Math.Pow(word.Length, 2);
            }                   
        }
        if (score > maxScore)
        {
            maxScore = score;
            maxWindowStart = windowStart;
        }

        // Setup next iteration
        windowStart += resolution;

        // Window end passed document end
        if (windowStart + snippetLength >= documentText.Length)
        {
            break;
        }
    }

    return maxWindowStart;
}

Скорее всего, вы можете добавить к этому, например, вместо сравнения точных слов, возможно, вы захотите сравнить сравнение SOUNDEX, где весовой звук весит меньше точных совпадений.

Ответ 8

Я выбрал другой подход, возможно, он кому-нибудь поможет...

Сначала он ищет, если это слово появляется в моем случае с IgnoreCase (вы, конечно, измените это самостоятельно). Затем я создаю список совпадений регулярных выражений для каждого разделителя и ищу первое вхождение слова (позволяя частичное совпадение без учета регистра). Из этого индекса я получаю 10 совпадений впереди и позади слова, которое составляет фрагмент.

public static string GetSnippet(string text, string word)
{
    if (text.IndexOf(word, StringComparison.InvariantCultureIgnoreCase) == -1)
    {
        return "";
    }

    var matches = new Regex(@"\b(\S+)\s?", RegexOptions.Singleline | RegexOptions.Compiled).Matches(text);

    var p = -1;
    for (var i = 0; i < matches.Count; i++)
    {
        if (matches[i].Value.IndexOf(word, StringComparison.InvariantCultureIgnoreCase) != -1)
        {
            p = i;
            break;
        }
    }

    if (p == -1) return "";
    var snippet = "";
    for (var x = Math.Max(p - 10, 0); x < p + 10; x++)
    {
        snippet += matches[x].Value + " ";
    }
    return snippet;
}