optimize data retrieve by customfields und enricher
This commit is contained in:
@@ -410,3 +410,8 @@ span.think {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.bubble img {
|
||||
max-width: 50px;
|
||||
max-height: 50px;
|
||||
}
|
||||
@@ -18,7 +18,8 @@
|
||||
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>mitho® KI-Agent</h1>
|
||||
<h1>RetrieX KI-Agent</h1>
|
||||
<div class="small">powered by mitho®</div>
|
||||
<div class="spacer"></div>
|
||||
<button id="clear" class="btn btn-trans">Diesen Chat löschen</button>
|
||||
</div>
|
||||
|
||||
@@ -58,7 +58,7 @@ final readonly class AgentRunner
|
||||
//$includeFullContext = false;
|
||||
if ($includeFullContext) {
|
||||
|
||||
yield $this->systemMsg("Ich analyse deine Anfrage...", "think");
|
||||
yield $this->systemMsg("Ich analysiere deine Anfrage...", "think");
|
||||
|
||||
$promptSwagSearch = '
|
||||
Erzeuge aus dem folgenden Nutzereingabetext einen kurzen Suchtext für die Shopware-6-Suche.
|
||||
@@ -80,6 +80,7 @@ final readonly class AgentRunner
|
||||
';
|
||||
|
||||
$this->thinkSuppressor->reset();
|
||||
|
||||
foreach ($this->ollamaClient->stream($promptSwagSearch) as $swagToken) {
|
||||
|
||||
if (!is_string($swagToken)) {
|
||||
@@ -138,7 +139,6 @@ final readonly class AgentRunner
|
||||
|
||||
yield $this->systemMsg("Denke nach...", "think");
|
||||
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 5) Build final prompt
|
||||
// ---------------------------------------------------------
|
||||
|
||||
@@ -75,6 +75,7 @@ final readonly class PromptBuilder
|
||||
// 3) LIVE SHOP RESULTS (AUTHORITATIVE FOR PRODUCTS)
|
||||
// ------------------------------------------------------------
|
||||
$shopBlock = '';
|
||||
$isDetailed = !(count($shopResults) > 5);
|
||||
|
||||
if ($shopResults !== []) {
|
||||
$lines = [];
|
||||
@@ -113,6 +114,18 @@ final readonly class PromptBuilder
|
||||
$parts[] = "URL: " . $product->url;
|
||||
}
|
||||
|
||||
if ($product->productImage) {
|
||||
$parts[] = "productImage: " . $product->productImage;
|
||||
}
|
||||
|
||||
if ($isDetailed && $product->description) {
|
||||
$parts[] = "description: " . $product->description;
|
||||
}
|
||||
|
||||
if ($product->customFields) {
|
||||
$parts[] = "Meta-Informationen: " . $product->customFields;
|
||||
}
|
||||
|
||||
$lines[] = implode("\n", $parts);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,15 +10,18 @@ final readonly class ShopProductResult
|
||||
* @param string[] $highlights
|
||||
*/
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public string $name,
|
||||
public string $id,
|
||||
public string $name,
|
||||
public ?string $productNumber = null,
|
||||
public ?string $manufacturer = null,
|
||||
public ?string $price = null,
|
||||
public ?bool $available = null,
|
||||
public ?bool $available = null,
|
||||
public ?string $url = null,
|
||||
public array $highlights = [],
|
||||
public ?string $description = null
|
||||
) {
|
||||
public array $highlights = [],
|
||||
public ?string $description = null,
|
||||
public ?string $productImage = null,
|
||||
public ?string $customFields = null,
|
||||
)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,11 @@ namespace App\Commerce;
|
||||
use App\Commerce\Dto\ShopProductResult;
|
||||
use App\Shopware\ShopwareCriteriaBuilder;
|
||||
use App\Shopware\StoreApiClient;
|
||||
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
|
||||
|
||||
final readonly class ShopSearchService
|
||||
{
|
||||
@@ -29,12 +34,18 @@ final readonly class ShopSearchService
|
||||
if (!$this->enabled) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$response = [];
|
||||
$query = $this->queryParser->parse($originalPrompt, $commerceIntent);
|
||||
$criteria = $this->criteriaBuilder->build($query, $this->maxResults);
|
||||
$response = $this->storeApiClient->searchProducts($criteria);
|
||||
try {
|
||||
$response = $this->storeApiClient->searchProducts($criteria);
|
||||
} catch (ClientExceptionInterface|DecodingExceptionInterface|RedirectionExceptionInterface|ServerExceptionInterface|TransportExceptionInterface $e) {
|
||||
|
||||
return $this->mapProducts($response);
|
||||
}
|
||||
|
||||
$result = $this->mapProducts($response);;
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,6 +74,8 @@ final readonly class ShopSearchService
|
||||
url: $this->baseUrl . $this->extractUrl($row),
|
||||
highlights: $this->extractHighlights($row),
|
||||
description: $this->cleanUpDescription($row),
|
||||
productImage: $row['cover']['media']['thumbnails'][0]['url'] ?? 'no-image',
|
||||
customFields: $this->getRelevantCustomFields($row['customFields'])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,6 +85,15 @@ final readonly class ShopSearchService
|
||||
));
|
||||
}
|
||||
|
||||
private function getRelevantCustomFields($customField): string
|
||||
{
|
||||
$result = ($customField['migration_Backup_product_attr1'] ?? '') . ': ' . ($customField['migration_Backup_product_attr2'] ?? '');
|
||||
$result .= ' | Einsatzgebiete: ' . ($customField['migration_Backup_product_attr4'] ?? '');
|
||||
$result .= ' | Sprachen: ' . ($customField['migration_Backup_product_attr5'] ?? '');
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function cleanUpDescription($description): string
|
||||
{
|
||||
if (isset($description['translated']['description'])) {
|
||||
|
||||
@@ -41,8 +41,11 @@ final class NdjsonHybridRetriever implements RetrieverInterface
|
||||
private readonly SalesIntentLite $salesIntentLite,
|
||||
private readonly CatalogIntentLite $catalogIntent,
|
||||
private readonly IntentRouteResolver $routeResolver,
|
||||
private readonly EntityCatalogService $entityCatalogService
|
||||
) {}
|
||||
private readonly EntityCatalogService $entityCatalogService,
|
||||
private readonly QueryEnricher $queryEnricher,
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// PUBLIC API
|
||||
@@ -126,10 +129,11 @@ final class NdjsonHybridRetriever implements RetrieverInterface
|
||||
// =========================================================
|
||||
|
||||
private function execute(
|
||||
string $prompt,
|
||||
string $prompt,
|
||||
ModelGenerationConfig $config,
|
||||
bool $withScores
|
||||
): array {
|
||||
bool $withScores
|
||||
): array
|
||||
{
|
||||
|
||||
$entityLabel = $this->catalogIntent->detect($prompt);
|
||||
$salesIntent = $this->detectSalesIntent($prompt);
|
||||
@@ -195,11 +199,12 @@ final class NdjsonHybridRetriever implements RetrieverInterface
|
||||
// =========================================================
|
||||
|
||||
private function runCore(
|
||||
string $prompt,
|
||||
string $prompt,
|
||||
ModelGenerationConfig $config,
|
||||
bool $withScores,
|
||||
string $salesIntent
|
||||
): array {
|
||||
bool $withScores,
|
||||
string $salesIntent
|
||||
): array
|
||||
{
|
||||
|
||||
$limit = max(1, min($config->getRetrievalMaxChunks(), self::HARD_MAX_CHUNKS));
|
||||
$vectorTopKBase = max(1, min($config->getRetrievalVectorTopK(), self::HARD_MAX_VECTORK));
|
||||
@@ -207,6 +212,7 @@ final class NdjsonHybridRetriever implements RetrieverInterface
|
||||
$isListQuery = $this->intentLite->isListQuery($prompt);
|
||||
|
||||
$cleanQuery = $this->queryCleaner->clean($prompt);
|
||||
$cleanQuery = $this->queryEnricher->enrichPrompt($cleanQuery);
|
||||
|
||||
if ($cleanQuery === '') {
|
||||
return [
|
||||
@@ -316,9 +322,10 @@ final class NdjsonHybridRetriever implements RetrieverInterface
|
||||
|
||||
private function computeThresholdAndTopK(
|
||||
string $salesIntent,
|
||||
bool $isListQuery,
|
||||
int $vectorTopKBase
|
||||
): array {
|
||||
bool $isListQuery,
|
||||
int $vectorTopKBase
|
||||
): array
|
||||
{
|
||||
|
||||
$threshold = self::VECTOR_SCORE_THRESHOLD;
|
||||
$topK = $vectorTopKBase;
|
||||
@@ -344,9 +351,10 @@ final class NdjsonHybridRetriever implements RetrieverInterface
|
||||
array $globalHits,
|
||||
array $scopedHits,
|
||||
float $threshold,
|
||||
bool $boostScoped,
|
||||
bool $captureRaw
|
||||
): array {
|
||||
bool $boostScoped,
|
||||
bool $captureRaw
|
||||
): array
|
||||
{
|
||||
|
||||
$rrfScores = [];
|
||||
$rawScores = [];
|
||||
|
||||
128
src/Knowledge/Retrieval/QueryEnricher.php
Normal file
128
src/Knowledge/Retrieval/QueryEnricher.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Knowledge\Retrieval;
|
||||
|
||||
final class QueryEnricher
|
||||
{
|
||||
public function enrichPrompt(string $query): string
|
||||
{
|
||||
// Return early if the input is empty or contains only whitespace.
|
||||
if (trim($query) === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Keep the original query untouched for the final output.
|
||||
$originalQuery = $query;
|
||||
|
||||
// Normalize the query for case-insensitive matching.
|
||||
$normalizedQuery = $this->normalize($query);
|
||||
|
||||
// Expect an associative array like:
|
||||
// [
|
||||
// 'hose' => 'jeans',
|
||||
// 'jacke' => 'mantel',
|
||||
// ]
|
||||
$mapping = $this->enrichQueryList();
|
||||
|
||||
// Build a bidirectional lookup table:
|
||||
// key -> value
|
||||
// value -> key
|
||||
$lookup = $this->buildBidirectionalLookup($mapping);
|
||||
|
||||
// Split the query into searchable words/tokens.
|
||||
$tokens = $this->tokenize($normalizedQuery);
|
||||
|
||||
$matches = [];
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
// If the token exists in the lookup table, add the mapped counterpart.
|
||||
if (isset($lookup[$token])) {
|
||||
$matches[] = $lookup[$token];
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates while preserving order.
|
||||
$matches = array_values(array_unique($matches));
|
||||
|
||||
// If nothing was found, return the original query unchanged.
|
||||
if ($matches === []) {
|
||||
return $originalQuery;
|
||||
}
|
||||
|
||||
// Append the matched counterpart terms to the original prompt.
|
||||
return $originalQuery . " | Pseudonyme: " . implode(', ', $matches);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a string for case-insensitive comparison.
|
||||
*/
|
||||
private function normalize(string $value): string
|
||||
{
|
||||
return mb_strtolower(trim($value), 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize the query into words.
|
||||
* Splits on everything that is not a letter or number.
|
||||
*/
|
||||
private function tokenize(string $value): array
|
||||
{
|
||||
return preg_split('/[^\p{L}\p{N}]+/u', $value, -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a lookup table that works in both directions.
|
||||
*
|
||||
* Example:
|
||||
* [
|
||||
* 'hose' => 'jeans',
|
||||
* 'jacke' => 'mantel',
|
||||
* ]
|
||||
*
|
||||
* becomes:
|
||||
* [
|
||||
* 'hose' => 'jeans',
|
||||
* 'jeans' => 'hose',
|
||||
* 'jacke' => 'mantel',
|
||||
* 'mantel' => 'jacke',
|
||||
* ]
|
||||
*/
|
||||
private function buildBidirectionalLookup(array $mapping): array
|
||||
{
|
||||
$lookup = [];
|
||||
|
||||
foreach ($mapping as $key => $value) {
|
||||
$key = trim((string)$key);
|
||||
$value = trim((string)$value);
|
||||
|
||||
// Skip incomplete pairs.
|
||||
if ($key === '' || $value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedKey = $this->normalize($key);
|
||||
$normalizedValue = $this->normalize($value);
|
||||
|
||||
// If the key is found in the query, return the value.
|
||||
$lookup[$normalizedKey] = $value;
|
||||
|
||||
// If the value is found in the query, return the key.
|
||||
$lookup[$normalizedValue] = $key;
|
||||
}
|
||||
|
||||
return $lookup;
|
||||
}
|
||||
|
||||
public function enrichQueryList(): array
|
||||
{
|
||||
return [
|
||||
'Wasserhärte' => "Resthärte",
|
||||
'Gerät' => 'Modell',
|
||||
'Indikator' => 'Chemie',
|
||||
'Wasserhärte-Grenzwert'=>'Resthärte',
|
||||
'Resthärte-Grenzwert'=>'Wasserhärte'
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,9 @@ final class ShopwareCriteriaBuilder
|
||||
'calculatedPrice',
|
||||
'seoUrls',
|
||||
'manufacturer',
|
||||
'translated.name'
|
||||
'translated.name',
|
||||
'cover',
|
||||
'customFields'
|
||||
],
|
||||
'product_manufacturer' => [
|
||||
'name',
|
||||
@@ -43,10 +45,30 @@ final class ShopwareCriteriaBuilder
|
||||
'seo_url' => [
|
||||
'seoPathInfo',
|
||||
],
|
||||
'product_media' => [
|
||||
'id',
|
||||
'media'
|
||||
],
|
||||
'media' => [
|
||||
'id',
|
||||
'url',
|
||||
'thumbnails',
|
||||
'alt',
|
||||
'title'
|
||||
]
|
||||
],
|
||||
'associations' => [
|
||||
'manufacturer' => new \stdClass(),
|
||||
'seoUrls' => new \stdClass(),
|
||||
'cover' => [
|
||||
'associations' => [
|
||||
'media' => [
|
||||
'associations' => [
|
||||
"thumbnails" => new \stdClass()
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
],
|
||||
'sort' => [
|
||||
[
|
||||
|
||||
@@ -20,10 +20,10 @@
|
||||
{# ============================= #}
|
||||
|
||||
<nav class="navbar navbar-dark bg-black border-bottom border-secondary px-3">
|
||||
<span class="navbar-brand fw-semibold text-info">
|
||||
mitho® RAG-System
|
||||
</span>
|
||||
|
||||
<div class="navbar-brand fw-semibold text-info">
|
||||
RetrieX RAG-System
|
||||
</div>
|
||||
<div class="small">powered by mitho®</div>
|
||||
<div class="ms-auto d-flex align-items-center gap-3">
|
||||
{% if app.user %}
|
||||
<span class="small text-light">
|
||||
|
||||
Reference in New Issue
Block a user