Sphinx для ASP.NET через jTemplates

в 10:16, , рубрики: ASP, ASP.NET, jquery, sphinx, метки: ,

Sphinx для ASP.NET через jTemplates
Есть у нас хобби — развивать интернет-магазин по продаже напитков и продуктов оптом.
Товары у нас появляются путем привлечения поставщиков и размещения их товаров в магазине.
Клиенты — владельцы ресторанов и кафе, которые заказывают товары оптом с доставкой на следующий день.
Когда количество позиций по товарам перевалило за 20 тыс., поиск через like в MS SQL стал слишком уж неверный, тем более когда поставщики загружали товар с ошибками или названия товаров были латиницей/кириллицей. После месяца различных ухищрений в процедуре поиска с конвертацией latin-cyrilic-latin, исправления грамматических ошибок, мы в конце концов осознали, что это тупиковый путь развития поиска.

Поиск решения

Нахмурив брови, мы решили подсмотреть, как же подобные проблемы решаются в других проектах, скажем на том же викимарте. К нашей зависти, поиск у них работал хорошо, даже исправлял наши ошибки в словах товаров. Например, по запросу «Коко кола», мы могли найти и «Кока-колы» и «Cocain». Что же за СУБД у них такая волшебная у них, воскликнули мы.
После недолгого поиска в интернетах технического решения, мы поняли, что нам нужен FullText Search Engine. Полетав в облаках, что мы сможем, наверное очень скоро, реализовать «поиск для людей», да еще и как бесплатный пирожок у нас могут появиться facets фильтры, мы стали искать на чем это реализовать.
И как оказалось, FullText Search есть в MS SQL 2008 Advanced Services уже встроенная в нашу СУБД! Поковырявшись в MS SQL с неделю и не найдя бесплатного пирожка в виде facets, мы набрели на статью о волшебных Lucene.NET, Solr, Sphinx.

Выбор движка

После небольших тестов движков выше, мы отобрали Sphinx по следующим критериям:

  1. Работает под Microsoft Windows
  2. Есть поддержка разработчиков и большой FAQ
  3. Работает с MS SQL
  4. Есть готовый адаптер для .NET для связи с движком
  5. Есть facets

К делу

Конфигурация Sphinx

Наш конфиг, в котором, собственно, ничего особенного.
Использование морфологий stem_enru, soundex, metaphone (что это такое, хорошо описано здесь, за что спасибо Puma).
Подключение к базе MS SQL и использование View по товарам, которые Sphinx периодически дергает. Но мы пошли немного дальше и расширили область Sphinx на поиск не только по товарам, но и по брендам, поставщикам и категориям.

Работа с Sphinx в ASP.NET

Для работы с Sphinx, мы используем опенсорсный Sphinx.Client.
Наш класс-помощник SphinxHelper для работы с Sphinx через Sphinx.Client.

SphinxHelper

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Sphinx.Client.Connections;
using Sphinx.Client.Commands.Search;
using System.Collections;
using Sphinx.Client.Commands.Collections;
using Sphinx.Client.Commands.Attributes.Filters;

namespace Project.Helpers
{
    public class SphinxHelper
    {
        private static ConnectionBase CreateConnection()
        {
            PersistentTcpConnection p_connection = new PersistentTcpConnection("127.0.0.1", 9312);
            p_connection.ConnectionTimeout = 10000;
            return p_connection;
        }

        public static IList<SearchQueryResult> Query(string queryText, string indexes, int limitPerIndex)
        {
            return Query("", queryText, indexes, null, limitPerIndex, 0, MatchMode.Extended2, MatchRankMode.WordCount, ResultsSortMode.Extended, "@weight DESC", "");
        }

        public static IList<SearchQueryResult> Query(string select, string match, string indexes, AttributeFilterList filters, int pageSize, int offset, MatchMode matchMode, MatchRankMode rankingMode, ResultsSortMode sortMode, string sortBy, string groupBy)
        {
            IEnumerable<string> p_idxArray = indexes.Split(',');
            SearchQuery p_query = null;
            pageSize = pageSize <= 0 ? 99999999 : pageSize;
            IList<SearchQueryResult> p_ret = new System.Collections.Generic.List<SearchQueryResult>();

            using (ConnectionBase connection = CreateConnection())
            {

                SearchCommand p_search = new SearchCommand(connection);
                foreach (string p_idx in p_idxArray)
                {

                    p_query = new SearchQuery(match, p_idx);
                    p_query.Select = select;


                    p_query.MatchMode = matchMode;
                    p_query.RankingMode =  rankingMode;
                    p_query.SortMode = sortMode;
                    p_query.SortBy = sortBy;
                    
                    if (!String.IsNullOrEmpty(groupBy))
                    {
                        p_query.GroupBy = groupBy;
                        p_query.GroupSort = sortBy;
                        p_query.GroupFunc = ResultsGroupFunction.Attribute;
                        if (!String.IsNullOrEmpty(sortBy))
                        {
                            p_query.SortBy = string.Empty;
                        }
                    }
                    

                    p_query.Limit = pageSize;
                    p_query.Offset = offset;

                    // Если есть фильтры, скопируем их
                    if (filters != null && filters.Count > 0)
                        foreach (AttributeFilterBase p_filter in filters)
                            p_query.AttributeFilters.Add(p_filter);
                    p_query.Select = select;
                    //Когда заработает переиндекс, надо будет делать мультиазапрос
                    //search.QueryList.Add(p_query);

                    p_search.QueryList.Clear();
                    p_search.QueryList.Add(p_query);
                    p_search.Execute();
                    foreach (SearchQueryResult p_result in p_search.Result.QueryResults)
                        p_ret.Add(p_result);
                }
                return p_ret;
            }
        }
    }
}

Из SearchQueryResult в браузер пользователя

При генерации страницы поиска с брендами и товарами, мы используем jTemplates, который получает данные от веб-сервиса, который в свою очередь дергает SphinxHelper.

Генерация страницы поиска

	// Отрисовка данных
	//brandId - фильтр по бренду
	//sellerId - фильтр по поставщику
	//categoryId - фильтр по категории
	//specIds - Фильтр facets
	//searchText - Фильтр по тексту
    this.GetGroups = function (brandId, sellerId, categoryId, specIds, searchText) {

        var waiter = $('#waiter_' + brandId);
        waiter.css({ visibility: 'visible' });

        var brandGroups = $('#brandGroups_' + brandId);
        brandGroups.attr('loaded', true);

        $.ajax({
            type: "POST",
            context: { brandId: brandId, sellerId: sellerId, categoryId: categoryId, specIds: specIds, searchText: searchText },
            url: currentHost() + "WebServices/Products.asmx/GetBrandGroups",
            data: "{brandID:'" + brandId + "',sellerID:'" + sellerId + "', categoryID:'" + categoryId + "', specIds:'" + specIds + "',searchText:'" + searchText + "'}",
            contentType: "application/json; charset=utf-8",
            dataType: "json",
            success: brandsInRow2.GroupCallSuccess,
            error: brandsInRow2.GroupCallError
        });


    }

    this.GroupCallError = function (request, status, error) {
        alert(request.responseText);
    }

    this.GroupCallSuccess = function (data, status) {
        var data_decoded = $.parseJSON(data.d);
        var brandGroups = $('#brandGroups_' + this.brandId);


        brandGroups.setTemplate($("#templateProducts").html());
        brandGroups.setParam('GetProductPriceActuality', brandsInRow2.GetProductPriceActuality);
        brandGroups.setParam('GetProductPriceActuality1', brandsInRow2.GetProductPriceActuality1);
        brandGroups.setParam('GetSpecDescription', brandsInRow2.GetSpecDescription);
        brandGroups.setParam('GetBrandPriceName', brandsInRow2.GetBrandPriceName);
        brandGroups.setParam('GetOrderProductFrameLink', brandsInRow2.GetOrderProductFrameLink);
        brandGroups.setParam('GetSellerInfoFrameLink', brandsInRow2.GetSellerInfoFrameLink);
        brandGroups.setParam('GetMessageSendFrameLink', brandsInRow2.GetMessageSendFrameLink);
        brandGroups.processTemplate(data_decoded);
        brandGroups.css({ display: 'block' });
        // активируем подсказки
        brandsInRow2.InitTips();

        var waiter = $('#waiter_' + this.brandId);
        waiter.css({ visibility: 'hidden' });
    }



    this.GetLinkOfferName = function (prodCnt, sellersCnt) {

        var p_offers = prodCnt + ' ' + formatToRussian1(prodCnt, "предложени");
        if (sellersCnt > 1)
            p_offers = p_offers + ' ' + formatToRussian(sellersCnt, "поставщик");

        return p_offers;
    }

    this.GetBrandCountName = function (brandsCnt) {
        return brandsCnt.toString() + ' ' + formatToRussian(brandsCnt, "бренд");
    }

    this.GetBrandPriceName = function (minPrice, maxPrice) {
        if (minPrice == maxPrice)
            return formatPrice(minPrice);
        return 'От ' + formatPrice(minPrice) + ' до ' + formatPrice(maxPrice)
    }

    this.GetProductPriceActuality = function (product) {
        return product.DaysUpdated > 30 ? "Цена может быть неактуальна на сегодняшний день, точную цену необходимо уточнить у дистрибьютора " + product.CompanyName + "." : "";
    }

    this.GetProductPriceActuality1 = function (product) {
        return product.DaysUpdated > 30 ? "" : "hidden";
    }


    this.GetSpecDescription = function (specs) {
        var escaped = specs;
        var findReplace = [[/&/g, "&"], [/</g, "<"], [/>/g, ">"], [/"/g, '"'], [/'/g, "'"]]
        for (var item in findReplace)
            escaped = escaped.replace(findReplace[item][0], findReplace[item][1]);
        return escaped;
    }

    this.GetOrderProductFrameLink = function (productId) {
        return currentHost() + "OrderProductFrame.aspx?ProductID=" + productId;
    }

    this.GetSellerInfoFrameLink = function (sellerId) {
        return currentHost() + "SellerShortInfoFrame.aspx?SellerID=" + sellerId;
    }

    this.GetMessageSendFrameLink = function (productId) {
        return currentHost() + "MessageSendFrame.aspx?ProductID=" + productId;
    }

Также было очень просто реализовать AutoComplete для строки поиска, которая тоже получает данные от веб-сервиса.

AutoComplete

function setAutoComplete(s) {

    var elem = $("#searchTextBox");
    elem.autocomplete({
        minLength: 2,
        source: function (request, response) {
            $.ajax({ type: "POST",
                url: currentHost() + "WebServices/Common.asmx/GetSearchComplete",
                data: "{searchTerm:'" + elem.val() + "'}",
                contentType: "application/json; charset=utf-8",
                success: function (msg) {
                    if (msg.d != "") response($.parseJSON(msg.d)); else response('')
                }
            });
        },
        select: function (event, ui) {
            elem.addClass('ui-autocomplete-loading');
            window.location.href = ui.item.linkUrl;
            elem.selected = ui.item;
            return false;
        },
        dataType: "json"
    })
    .data("autocomplete")._renderItem = function (ul, item) {
        return $("<li></li>")
				.data("item.autocomplete", item)
				.append("<a href='" + item.linkUrl + "'><ul class='search-complete'><li class='pict'><img src='" + item.pictUrl + "'/></li><li class='name'>" + item.label + "</li></ul></a>")
				.appendTo(ul);
    };

    elem.keydown(function (e) {
        if (e.keyCode == 13) {
            if (typeof (elem.selected) == 'undefined') {
                elem.addClass('ui-autocomplete-loading');
                SearchClick(); // Переход на страницу поиска
                e.preventDefault();
            }
        }
    });
}

От перехода на Sphinx, мы получили следующие пирожки:

  1. Человеческий поиск
  2. Генерация страниц поиска — на клиенте
  3. Разгрузка базы данных

Не могу привести точные замеры времени генерации страниц до и с помощью Sphinx, так как на радостях забыли записать что было до. Но скажу, что теперь у нас в базе более 20 тыс. продуктов и всё работает просто очень быстро (генерация менее секунды).

Для поддержки индекса продуктов в актуальном состоянии (а актуальные они в базе данных) мы будем использовать дельта-индекс, обновляемый каждые 10 минут. А пока, основной индекс у нас полностью перестраивается за 5 секунд каждые пол часа. Разгрузка MS SQL помогла избежать блокировок таблиц во время выполнения длительных запросов, которые происходят при импорте прайс-листа поставщиком.

P.S. Ссылки на проект, вроде бы, почистил, так как хабраэффект он не переживет.

Автор: andy_joyful

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js