Problem parowania produktów to jedno z ciekawszych wyzwań w eCommerce. Jak sprawdzić, korzystając z nazwy i opisu produktu, że dwa produkty to to samo? Każda porównywarka cenowa musi sobie poradzić z takim problemem.

My w Divante zabraliśmy się za niego dwukrotnie – raz w przypadku serwisu z recenzjami produktów Znam.to oraz później w przypadku naszego oprogramowania MultiChannel – w celu sprawdzania cen produktów ze sklepów – na Allegro.

Podchodziliśmy do tematu dwukrotnie – w znam.to zastosowaliśmy prostą implementację bazującą na porównywaniu tokenów z napisu za pomocą algorytmu Levenstheina oraz prawdopodobieństwa. Efekty były takie sobie – dla wielu produktów się to sprawdzało, a że nie zależało nam bardzo na dokładności implementacja spełniała nasze oczekiwania.

Drugi raz – w przypadku oprogramowania MultiChannel potrzebowaliśmy czegoś więcej – aby porównać ceny produktów ze sklepów, z cenami na Allegro.

W przypadku korzystania z Allegro problem był dodatkowo skomplikowany ze względu na bardzo dużą ilość produktów do porównania oraz to, że produkty na Allegro – można przyjąć, że nigdy – nie mają przypisanych identyfikatorów producentów, kodów EAN czy innych wspomagaczy – których np. wymagają porównywarki. Pozostało nam bazować na nazwie produktu i ew. opisie.

Dodatkowo trzeba mieć na uwadze, że na Allegro ludzie mają fantazję i opisy produktów potrafią być naprawdę ciekawe:

  • SAMSUNG GALAXY MINI*S5570*24M GW*BEZ SIMLOCKA
  • NOKIA E72 navi BLACK IGŁA z gratisem wys. w 24H
A to tylko branża elektronika-komunikacja – która, ze względu na dużą ilość parametrów technicznych w opisach – jest dosyć łatwa do parowania. Gorzej z oświetleniem z którym zmagaliśmy się w przypadku sprawdzania cen dla naszego DomSwiatla.pl Poniższe opisy i przykłady mogą zawierać kawałki dotyczące tej konkretnej branży 😉

Algorytm w skrócie wygląda tak:

  • Mapujemy kategorie sklepu na kategorie Allegro – to jest ważne, aby zmniejszyć prawdopodobieństwo totalnie złego sparowania produktów,
  • Pobieramy dane z Allegro za pomocą API – per kategoria i zapisujemy w naszej bazie (MongoDB dla wydajności),
  • Następnie dla wszystkich pobranych produktów tworzymy odwrotny indeks tokenów – które nazywamy u nas UPI (unikalny identyfikator produktu) – wyrzucamy tokeny < 3 znaki, wyrzucamy spójniki (korzystając ze słownika języka polskiego), normalizujemy wielkość znaków oraz sortujemy tokeny wg. alfabetu,
  • Dodatkowo pobieramy słownik języka polskiego – a tak naprawdę listę najczęściej powtarzających się słów – aby móc je wyrzucać z opisów aukcji – na pewno nie są to kluczowe wyrażenia dla sparowania produktu,
  • Dodatkowo pobieramy słowa kluczowe/branżowe dla danej kategorii – np. mogą to być producenci, oznaczniki itd. jeśli takie słowo pojawi się w tytule aukcji i produktu to należy takiej zgodności nadać większą wagę,
  • Dodatkowo trzeba wyżej oceniać zgodność tokenów zawierających liczby – ponieważ to najczęściej są parametry produktu, które muszą się zgadzać (np. Nokia C60, Nokia C90 – różnica tylko 1 znaku, ale mega kluczowego, nie mówiąc o produktach typu „Pokrowiec – Nokia C60” a „Nokia C60” – tutaj częściowo problem rozwiązuje mapowanie kategorii i rozpatrywanie per kategoria)
  • Taki sam proces prowadzimy dla nazw produktów ze sklepu (również w bazie mongo)
  • Na końcu trzeba przejechać wszystkie produkty ze sklepu – ale dzięki odwrotnemu indeksowi tokenów z Allegro nie jest to operacje o złożoności kwadratowej tylko liniowej.
  • Podczas rozpatrywania produktów stosujemy algorytm który zlicza score (punktację) dla danego produktu: ile tokenów jest takich samych, które z nich to „słowa branżowe”.
Teraz jak wygląda to w kodzie?
1. Pobranie listy produktów z Allegro i wygenerowanie tokenów jest stosunkowo proste. Pomijam samo łączenie się przez WebAPI i pobieranie produktów – bo to każdy może zrobić, albo też może pobrać produkty z innego miejsca. Ważniejsze jest wygenerowanie odwrotnego indeksu z tokenów.
[php]
public function generateUpi() {
$input = $this->getInput();

$input = trim(mb_strtolower($input, ‚utf-8’));

$input = text::remove_accents($input);

// replace chars that arent alphanum/whitespace/special (,.-) into spaces
$sanitized = preg_replace(‚/[^A-Za-z0-9′.Upi::$specialCharsEsc.’ tnr]/’, ‚ ‚, $input);

// split words separated by special chars if these words don’t contain numbers
// (numbers may signify an important parameter, while tokens of type abc.def is likely
// just a typo or intended to save space in the document name
$sanitized = preg_replace(‚/([^0-9 ]+)[‚.Upi::$specialCharsEsc.’]([^0-9 ]+)/’, ‚$1 $2’, $sanitized);

// enforce 1 space between tokens
$sanitized = preg_replace(‚/[ tnr]+/’, ‚ ‚, $sanitized);

$tokens = explode(‚ ‚, $sanitized);

// trim special chars for each token
foreach ($tokens as $key=>$token) {
$stripped = trim($token, Upi::$specialChars);
if (strlen($stripped) == 0) {
unset($tokens[$key]);
} else {
$tokens[$key] = $stripped;
}
}

// remove stopwords
$tokens = array_diff($tokens, $this->blacklist);

// remove duplicates
$tokens = array_unique($tokens, SORT_STRING);

sort($tokens);

$this->tokens = $tokens;
$this->upi = implode(‚ ‚, $tokens);
}[/php]

2. Tak wygenerowane tokeny najpierw zapisujemy sobie w tabelce z pobranymi aukcjami a w drugim kroku tworzymy z nich indeks odwrócony. Tak, żeby można błyskawicznie per dany token – sprawdzić do których aukcji jest on przypisany.
Tabelka indeksu odwrotnego tokenów wygląda następująco (kod modelu)
[php]
use DoctrineODMMongoDBMappingAnnotations as ODM;

/** @ODMDocument(collection="tokens") */
class Token extends AbstractDocument
{
/** @ODMString */
private $v;

/** @ODMHash */
private $o = array();

/** @ODMString */
private $f;

public function getCoolName(){
return $this->getValue();
}

public function getCoolTypeName($case = 0) {
switch($case) {
case 0: return _(‚Token’);
case 1: return _(‚Tokeny’);
case 1: return _(‚Tokena’);
}
}

public function getValue()
{
return $this->v;
}

public function setValue($value)
{
$this->v = $value;
}

public function getOffers()
{
return $this->o;
}

public function setOffers($offers)
{
$this->o = $offers;
}

public function getFrom()
{
return $this->f;
}

public function setFrom($from)
{
$this->f = $from;
}
}[/php]

[php]
public function generateAction() {

$from = $this->getCliParam(‚from’, true);

$acOffset = 0;
$pageSize = 100;

$processProducts = true;
$tokens = array();
$processOffers = true;
$this->getOutput()->log(sprintf(_(‚Starting …’)), Zend_Log::INFO);

while($processOffers) {

$offers = $this->getDb()->getRepository(‚modelsOffer’)
->createQueryBuilder()->find()
->field(‚from’)->equals($from)
->skip($acOffset)->limit($pageSize)->getQuery()->execute();

$this->getOutput()->log(sprintf(_(‚Offset: %s’), $acOffset), Zend_Log::INFO);

$index = 0;
foreach ($offers as $offer) {

$this->getOutput()->log(sprintf(_(‚Processing offer %s’), $offer->getName()), Zend_Log::INFO);

$offerFrom = $offer->getFrom();
foreach ($offer->getData(‚upitokens’) as $token) {

if (!isset($tokens[$offerFrom])) {
$tokens[$offerFrom] = array();
}

if(!isset($tokens[$offerFrom][$token])) {

$tokens[$offerFrom][$token] = array();
}

$tokens[$offerFrom][$token][] = new MongoID($offer->getId());

}
$this->getOutput()->log(sprintf(_(‚Tokens: %s’), implode(‚ ‚, $offer->getData(‚upitokens’))), Zend_Log::INFO);

$index ++;
}

if($index == 0)
break;

// break; // TODO: remove
$acOffset += $pageSize;
}

$dbFlushCounter = 0;
$flushEvery = 1000;

foreach($tokens as $offerFrom => $tokenlist) {
foreach ($tokenlist as $token => $offers) {
$this->getOutput()->log(sprintf(_(‚Saving: %s’), $token), Zend_Log::INFO);
$tk = new modelsToken();
$tk->setFrom($offerFrom);
$tk->setValue($token);
$tk->setOffers($offers);
$this->getDb()->persist($tk);

$dbFlushCounter++;
if (($dbFlushCounter % $flushEvery) == 0) {
$this->getDb()->flush();
}
}
}

$this->getDb()->flush();
}
[/php]

Warto zwrócić uwagę na linię
[php]
$offer->getData(‚upitokens’);
[/php]

W tym polu, jako tablica, są zachowane tokeny wygenerowane wcześniejszą funkcją. Jak widać powyższa metoda generowania tokenów nie jest zbyt wydajna – ma charakter raczej dydaktyczny. W praktyce można skorzystać z algorytmu zapisanego jako Map-Reduce – czyli wykorzystać mechanizmy natywne MongoDB.
[php]
public function generatemapreduceAction() {

$from = $this->getCliParam(‚from’, true);

// Part 1: define map / reduce functions
$m = ‚function () {
for (t in this.attributes.upitokens) {
var token = this.attributes.upitokens[t];
emit({v:token, f:this.from}, {o:[this._id]});
}
};’;

$r = ‚function (key, values) {
var results = new Array();
for (v in values) {
results = results.concat(values[v].o);
}
return {o:results};
};’;

$sanitizedFrom = str_replace(‚-‚,’_’, text::permalink(trim($from), true));
$tempCollection = sprintf(‚temp_tokens_%s’, $sanitizedFrom);

$this->getOutput()->log(sprintf(_(‚Temporary output collection for map/reduce: %s’), $tempCollection), Zend_Log::INFO);

// what parts of offers collection to work on
$config = array(
‚query’ => array(‚from’=> $from),
‚outcollection’ => array( ‚replace’ => $tempCollection )
);

// Part 2: run the map/reduce generating temp collections with tokens

// IMPORTANT:
// increase timeout (in milliseconds) to allow long mapreduce to run
MongoCursor::$timeout = 1000 * 3600;

$qb = $this->getDb()->createQueryBuilder(‚modelsOffer’);
foreach ($config[‚query’] as $field => $value) {
$qb = $qb->field($field)->equals($value);
}
$qb = $qb->map($m)->reduce($r)->out($config[‚outcollection’]);
$query = $qb->getQuery();

$this->getOutput()->log(sprintf(_(‚Running mapreduce to generate tokens for offers from %s…’), $from), Zend_Log::INFO);

$query->execute();

$this->getOutput()->log(sprintf(_(‚Finished mapreduce’)), Zend_Log::INFO);

// Part 3: define finalization & cleanup code

$code_convert_prep ="f = function (d) {
var doc = {};
doc._id = new ObjectId();
doc.f = d._id.f;
doc.v = d._id.v;
doc.o = d.value.o;
return doc;
};

convertCopy = function (from, to, convert) {
db[from].find().forEach(function (d) { db[to].save(convert(d)); });
};";
$code_cleartokens = sprintf("db.tokens.remove({‚f’: ‚%s’});", $from);
$code_convert = sprintf("convertCopy(‚%s’, ‚tokens’, f);", $tempCollection);
$code_cleanup = sprintf(‚db.%s.drop();’, $tempCollection);

// Part 4: execute JS code on MongoDB to merge temp token collections
// into final collection and perform cleanup

// HACK running a query so DocumentManager initializes Mongo connection
// otherwise ..->execute() wouldn’t work
$w = $this->getDb()->getRepository(‚modelsOffer’)->findOneBy(array(‚_none_’ => ‚_none_’));
$mongo = $this->getDb()->getConnection()->getMongo();
$db = $mongo->selectDB(‚my_database’);

$mongo->selectDB(‚my_database’)->execute($code_cleartokens);
$mongo->selectDB(‚my_database’)->execute($code_convert_prep);
$mongo->selectDB(‚my_database’)->execute($code_convert);
$mongo->selectDB(‚my_database’)->execute($code_cleanup);

$this->getOutput()->log(sprintf(_(‚Generowanie tokenow dla ofert from="%s" zakonczone’), $from), Zend_Log::INFO);
}
[/php]

3. W momencie gdy mamy już wygenerowany indeks odwrotny możemy wygenerować rekordy skoringowe naszych produktów. Nazywamy je „Similexy” :) Ponieważ określają prawdopodobieństwo na ile dany produkt jest podobny do danej aukcji/innego produktu.
Similex wygląda następująco:
[php]
<span style="font-family: Consolas, Monaco, monospace;"><span style="line-height: 18px;">
</span></span>/** @ODMDocument(repositoryClass="modelsRepositoriesSimilexRepository", collection="similexes") */
class Similex extends modelsAbstractDocument
{

/** @ODMFloat */
private $r;

/** @ODMObjectId */
private $pId;

/** @ODMObjectId */
private $oId;

/** @ODMString */
private $f;

/** @ODMHash */
private $rn;

public function getCoolName(){
return $this->getUpi();
}

public function getCoolTypeName($case = 0) {
switch($case) {
case 0: return _(‚Similex’);
case 1: return _(‚Similex’);
case 2: return _(‚Similex’);
}
}

public function getResult()
{
return $this->r;
}

public function setResult($result)
{
$this->r = $result;
}

public function getProductId()
{
return $this->pId;
}

public function setProductId($product)
{
$this->pId = $product;
}

public function getOfferId()
{
return $this->oId;
}

public function setOfferId($offer)
{
$this->oId = $offer;
}

public function getRank()
{
return $this->rn;
}

public function setRank($rn)
{
$this->rn = $rn;
}

public function getFrom()
{
return $this->f;
}

public function setFrom($from)
{
$this->f = $from;
}
}
[/php]

W funkcji generującej Similexy jest zawarty najważniejszy algorytm dokonujący porównań. Poniżej kod tego algorytmu.
[php]

public function generateAction() {

$prOffset = 0;
$pageSize = 100;

$from = $this->getCliParam(‚from’, true);

$this->getOutput()->log(sprintf(_(‚Starting …’)), Zend_Log::INFO);
$tokenCache = array();

$processProducts = true;

$this->getOutput()->log(sprintf(_(‚Starting …’)), Zend_Log::INFO);

while($processProducts) {

$products = $this->getDb()->getRepository(‚modelsProduct’)
->createQueryBuilder()->find()->skip($prOffset)->limit($pageSize)->getQuery()->execute();

$this->getOutput()->log(sprintf(_(‚Offset: %s’), $prOffset), Zend_Log::INFO);

$strategyName = ‚simple’;

$index = 0;
foreach ($products as $product) {

$this->getOutput()->log(sprintf(_(‚%d: Processing product %s’), $prOffset, $product->getName()), Zend_Log::INFO);

$offerHisto = array();
$offerRanks = array();

// UPDATE 22.10.2011 – wyszukuje tylko słowa z listy najczęstszych słów (50 tyś.) – po wyłączeniu warunku na count będzie wyszukiwało wszystkie słowa
$tokensInDictionary = $this->getDb()->getRepository(‚modelsUpisWord’)
->createQueryBuilder()->field(‚c’)->gt(0)->field(‚t’)->in($product->getData(‚upitokens’))->getQuery()->execute();

$dictLookup = array();
$producerLookup = array();
$keywordLookup = array();

foreach ($tokensInDictionary as $removeToken) {
$dictLookup[$removeToken->getToken()] = true;
}
//TODO: uwzględniać TradeCode – w producentach i słowach kluczowych!!! Do tego potrzeban obsługa modelu Trade i deskryptorów branży przypisanych do danej firmy

$tokensInProducers = $this->getDb()->getRepository(‚modelsProducer’)
->createQueryBuilder()->find()->field(‚subtokens’)->in($product->getData(‚upitokens’))->getQuery()->execute();

foreach ($tokensInProducers as $producerToken) {
$producerLookup[$producerToken->getToken()] = true;
}

$tokensInKeywords = $this->getDb()->getRepository(‚modelsKeyword’)
->createQueryBuilder()->find()->field(‚t’)->in($product->getData(‚upitokens’))->getQuery()->execute();

foreach ($tokensInKeywords as $keywordToken) {
$keywordLookup[$keywordToken->getToken()] = true;
}

$this->getOutput()->log(sprintf(_(‚Producer tokens: [%s] of [%s]’), implode(‚ ‚, array_keys($producerLookup)), implode(‚ ‚, $product->getData(‚upitokens’))), Zend_Log::INFO);
$this->getOutput()->log(sprintf(_(‚Keyword tokens: [%s] of [%s]’), implode(‚ ‚, array_keys($keywordLookup)), implode(‚ ‚, $product->getData(‚upitokens’))), Zend_Log::INFO);
$this->getOutput()->log(sprintf(_(‚Dictionary tokens: [%s] of [%s]’), implode(‚ ‚, array_keys($dictLookup)), implode(‚ ‚, $product->getData(‚upitokens’))), Zend_Log::INFO);

$nnT = 0;
$nT = 0;
foreach($product->getData(‚upitokens’) as $t){
if(intval(str2int($t)) <= 0){
$nnT ++; // not numeric tokens
} else
$nT ++;
}

$this->getOutput()->log(sprintf(‚nt: %d, nnt: %d’, $nT, $nnT), Zend_Log::INFO);
$tms = 0;

$producerIsInProduct = false;
$keywordIsInProduct = false;
$tmIsInProduct = false;

$producerIsInAuction = array();
$keywordIsInAuction = array();

foreach($product->getData(‚upitokens’) as $value) {

if(!isset($tokenCache[$value]))
{
$tokenCache[$value] = $this->getRepository(‚modelsToken’)->findOneBy(array(‚v’ => $value, ‚f’ => $from));
}
$token = $tokenCache[$value];

if(@$producerLookup[$value] && (strlen($value) > 3)){
$producerIsInProduct = true;

}
if(@$keywordLookup[$value] && (strlen($value) > 3)){
$keywordIsInProduct = true;

}
if(!(@$dictLookup[$value]) && (strlen($value) > 3)){
$tmIsInProduct = true;

}

if($token) {

$this->getOutput()->log($value.’=>’.str2int($value), Zend_Log::INFO);

$hasParams = false;
$hasTM = false;
$hasProducer = false;
$hasKeyword = false;

$score = 0.08; // słownikowe słowa lub inne – maks

//TODO: Wyciągnąć stałe liczbowe jako definiowalne per sklep – bo zależą od opisów stosowanych w tym sklepie

if(@$producerLookup[$value] && (strlen($value) > 3)){ // nazwa producenta

$score += 0.9; // ważne ale chyba mniej ważne niż parametry liczbowe
$hasProducer = true;

} elseif(@$keywordLookup[$value] && (strlen($value) > 3)) {

$score += 0.15; // ważne bo to słowo kluczowe wg. branży
$hasKeyword = true;

} elseif(intval(str2int($value)) > 0) { // prawdopodobnie parametr produktu
$score += ($nnT >= 2 && intval(str2int($value))>= 4 /* jeśli wartość to pojedyńcze cyfry to raczej nie jest znaczący parametr – ale np. w laptopach będzie 😀 – TODO: to powinno być zależne od branży */) ? ( $hasProducer ? 0.7 : 0.52) /** (1 / (max(1, $nT)*/ /* jak wszystkie tokeny numeryczne wystąpią to dostanie 0.92 */ : ( count($product->getData(‚upitokens’)) < 3 ? 0.003 : 0.05) /* jak jest za mało opisu produktu to parametry nie sparują dobrze */;
$hasParams = true;
} else {

if(!(@$dictLookup[$value])){ // nazwa własna lub słowo nie występujące w słowniku

if(strlen($value) > 3) {

if($nnT > 2)
$score += 0.5;
else
// nierozróżnalość typu towaru – czy lampa, czy plafon czy cokolwiek
$score += 0.1;

$hasTM = true;
$tms ++;
} else
$score = 0.04;

}

}

//TODO: Warto byłoby dodać sprawdzenie w treści aukcji i w treści opisu produktu czy jest zdefiniowany producent – jeśli jest i się nei zgadza – obniż ranking takiemu połączeiu, ponieważ prawdopodobnie zmapowany produkt o takich samych parametrach ale różnych producentów!
// TODO: Jeśli w nazwie produktu jest słowo kluczowe lub producent, ale nie występuje ono w aukcji to połączenie powinno mieć obniżony ranking

foreach($token->getOffers() as $offer) {
// $this->getOutput()->log(sprintf(_(‚Processing offer %s’), $offer->{‚$id’}), Zend_Log::INFO);

$aId = ((string)$offer->{‚$id’});

if(!isset($offerHisto[$aId])){
$offerHisto[$aId] = (float)0;
}

$producerIsInAuction[$aId] = $hasProducer;
$keywordIsInAuction[$aId] = $hasKeyword;

$offerHisto[$aId] += (float)$score;
$offerRanks[$aId] = array(‚t’ => $hasTM, ‚p’ => $hasParams, ‚pr’ => $hasProducer, ‚kw’ => $hasKeyword);
}

// if($hasParams) die(print_r($offerRanks, true));//
}
}
arsort($offerHisto); //die(print_r($auctionHisto, true));

$topCount = 0;
foreach($offerHisto as $offerId => $count) {

if($topCount > 9)
break;

$similex = new modelsUpisSimilex();
$similex->setOfferId(new MongoID($offerId));
$similex->setProductId($product->getId());
$similex->setFrom($from);
$mn = count($product->getData(‚upitokens’));

if($mn <=2)
$mn *= 2.5; // utnij za krótkie opisy z porównania
else {
$mn = $tms + (0.7 * ($mn – $tms)); // zwiększenie współczynnika względem zagęszczenia słów niesłownikowych

}

$totalScore = (float)$count / (float)$mn;
$penalty = 0;

// wszystkie powyższe warunki sprawdzają to co jest wspólne .. a poniższe sprawdzają to czego zabrakło :)
if($producerIsInProduct && !$offerRanks[$offerId][‚pr’])
$penalty = -0.3; // to duże przewinienie że w sklepie był producent a w aukcji nie!

if($tmIsInProduct && !$offerRanks[$offerId][‚tm’])
$penalty = -0.2; // to mniejsze przeiwnienie że w produkcie było słowo kluczowe a w aukcji już nie

if($keywordIsInProduct && !$offerRanks[$offerId][‚kw’])
$penalty = – 0.2; // to mniejsze przeiwnienie że w produkcie było słowo kluczowe a w aukcji już nie

$totalScore -= $penalty;

$similex->setResult($totalScore);
$similex->setRank($offerRanks[$offerId]);
$this->getDb()->persist($similex);

$this->getOutput()->log(sprintf(_(‚Processing similex %s -> %f, params: %d, tm: %d, kw: %d, pr: %d’), $offerId, $count, $offerRanks[$offerId][‚p’], $offerRanks[$offerId][‚t’], $offerRanks[$offerId][‚kw’], $offerRanks[$offerId][‚pr’]), Zend_Log::INFO);

$topCount ++;
}

$index ++;
}

if($index == 0)
break;

// break; // TODO: remove
$prOffset += $pageSize;

$this->getDb()->flush();
$this->getDb()->clear();

}
[/php]

Powyższy algorytm sprawdza tokeny występujące w nazwach produktów w sklepie z tokenami z aukcji. Nie musi iterować po aukcjach (których mogą być setki tysięcy!) – ponieważ ma indeks odwrotny, wygenerowany we wcześniejszym kroku. Sprawdzenie czy dany token wystepuje w określonych aukcjach za pomocą MongoDB i operatora „in” staje się więc trywialne:
[php]
$dictLookup = array();
$producerLookup = array();
$keywordLookup = array();

foreach ($tokensInDictionary as $removeToken) {
$dictLookup[$removeToken->getToken()] = true;
}
//TODO: uwzględniać TradeCode – w producentach i słowach kluczowych!!! Do tego potrzeban obsługa modelu Trade i deskryptorów branży przypisanych do danej firmy

$tokensInProducers = $this->getDb()->getRepository(‚modelsProducer’)
->createQueryBuilder()->find()->field(‚subtokens’)->in($product->getData(‚upitokens’))->getQuery()->execute();

foreach ($tokensInProducers as $producerToken) {
$producerLookup[$producerToken->getToken()] = true;
}

$tokensInKeywords = $this->getDb()->getRepository(‚modelsKeyword’)
->createQueryBuilder()->find()->field(‚t’)->in($product->getData(‚upitokens’))->getQuery()->execute();

foreach ($tokensInKeywords as $keywordToken) {
$keywordLookup[$keywordToken->getToken()] = true;
}
[/php]

Dzięki powyższemu mamy od razu stworzone lookupy z informacją które tokeny to słowa kluczowe dla branży, które to nazwy producentów a które to słowa ze słownika (w bazie danych – wciągnięty słownik 50 000 najpopularniejszych słów w języku polskim – dostępny np. na stronie: http://invokeit.wordpress.com/frequency-word-lists/
Po powyższych krokach mamy bazę pełną rekordów typu Similex, z których po wartości punktacji możemy określić podobieństwo między produktami. Na podstawie przyjętych powyżej wag byliśmy w stanie w miarę stanowczo stwierdzić dobre sparowanie produktów w przypadku wag >= 0.6.
Jeśli ktoś ma bardziej szczegółowe pytania lub chciałby wykorzystać nasz algorytm w swoim przedsięwzięciu – prośba o kontakt :)