Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 34 additions & 7 deletions app/Commands/KnowledgeSearchCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@

namespace App\Commands;

use App\Enums\SearchTier;
use App\Services\EntryMetadataService;
use App\Services\QdrantService;
use App\Services\TieredSearchService;
use LaravelZero\Framework\Commands\Command;

class KnowledgeSearchCommand extends Command
Expand All @@ -22,14 +23,15 @@ class KnowledgeSearchCommand extends Command
{--status= : Filter by status}
{--limit=20 : Maximum number of results}
{--semantic : Use semantic search if available}
{--include-superseded : Include superseded entries in results}';
{--include-superseded : Include superseded entries in results}
{--tier= : Force searching a specific tier (working, recent, structured, archive)}';

/**
* @var string
*/
protected $description = 'Search knowledge entries by keyword, tag, or category';

public function handle(QdrantService $qdrant, EntryMetadataService $metadata): int
public function handle(TieredSearchService $tieredSearch, EntryMetadataService $metadata): int
{
$query = $this->argument('query');
$tag = $this->option('tag');
Expand All @@ -40,6 +42,7 @@ public function handle(QdrantService $qdrant, EntryMetadataService $metadata): i
$limit = (int) $this->option('limit');
$this->option('semantic');
$includeSuperseded = (bool) $this->option('include-superseded');
$tierOption = $this->option('tier');

// Require at least one search parameter for entries
if ($query === null && $tag === null && $category === null && $module === null && $priority === null && $status === null) {
Expand All @@ -48,7 +51,19 @@ public function handle(QdrantService $qdrant, EntryMetadataService $metadata): i
return self::FAILURE;
}

// Build filters for Qdrant search
// Validate tier option
$forceTier = null;
if (is_string($tierOption) && $tierOption !== '') {
$forceTier = SearchTier::tryFrom($tierOption);
if ($forceTier === null) {
$validTiers = implode(', ', array_map(fn (SearchTier $t): string => $t->value, SearchTier::cases()));
$this->error("Invalid tier '{$tierOption}'. Valid tiers: {$validTiers}");

return self::FAILURE;
}
}

// Build filters for search
$filters = array_filter([
'tag' => is_string($tag) ? $tag : null,
'category' => is_string($category) ? $category : null,
Expand All @@ -61,9 +76,9 @@ public function handle(QdrantService $qdrant, EntryMetadataService $metadata): i
$filters['include_superseded'] = true;
}

// Use Qdrant for semantic search (always)
// Use tiered search
$searchQuery = is_string($query) ? $query : '';
$results = $qdrant->search($searchQuery, $filters, $limit);
$results = $tieredSearch->search($searchQuery, $filters, $limit, $forceTier);

if ($results->isEmpty()) {
$this->line('No entries found.');
Expand All @@ -85,12 +100,24 @@ public function handle(QdrantService $qdrant, EntryMetadataService $metadata): i
$content = $entry['content'] ?? '';
$score = $entry['score'] ?? 0.0;
$supersededBy = $entry['superseded_by'] ?? null;
$tierLabel = $entry['tier_label'] ?? null;
$tieredScore = $entry['tiered_score'] ?? null;

$isStale = $metadata->isStale($entry);
$effectiveConfidence = $metadata->calculateEffectiveConfidence($entry);
$confidenceLevel = $metadata->confidenceLevel($effectiveConfidence);

$titleLine = "<fg=cyan>[{$id}]</> <fg=green>{$title}</> <fg=yellow>(score: ".number_format($score, 2).')</>';
$scoreDisplay = 'score: '.number_format($score, 2);
if ($tieredScore !== null) {
$scoreDisplay .= ' | ranked: '.number_format((float) $tieredScore, 2);
}

$tierDisplay = '';
if (is_string($tierLabel)) {
$tierDisplay = " <fg=magenta>[{$tierLabel}]</>";
}

$titleLine = "<fg=cyan>[{$id}]</> <fg=green>{$title}</> <fg=yellow>({$scoreDisplay})</>{$tierDisplay}";
if ($supersededBy !== null) {
$titleLine .= ' <fg=red>[SUPERSEDED]</>';
}
Expand Down
36 changes: 36 additions & 0 deletions app/Enums/SearchTier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace App\Enums;

enum SearchTier: string
{
case Working = 'working';
case Recent = 'recent';
case Structured = 'structured';
case Archive = 'archive';

public function label(): string
{
return match ($this) {
self::Working => 'Working Context',
self::Recent => 'Recent (14 days)',
self::Structured => 'Structured Storage',
self::Archive => 'Archive',
};
}

/**
* @return array<self>
*/
public static function searchOrder(): array
{
return [
self::Working,
self::Recent,
self::Structured,
self::Archive,
];
}
}
8 changes: 8 additions & 0 deletions app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use App\Services\DailyLogService;
use App\Services\DeletionTracker;
use App\Services\EnhancementQueueService;
use App\Services\EntryMetadataService;
use App\Services\HealthCheckService;
use App\Services\KnowledgeCacheService;
use App\Services\KnowledgePathService;
Expand All @@ -15,6 +16,7 @@
use App\Services\QdrantService;
use App\Services\RuntimeEnvironment;
use App\Services\StubEmbeddingService;
use App\Services\TieredSearchService;
use App\Services\WriteGateService;
use Illuminate\Support\ServiceProvider;

Expand Down Expand Up @@ -155,6 +157,12 @@ public function register(): void
cacheService: $app->make(KnowledgeCacheService::class)
));

// Tiered search service
$this->app->singleton(TieredSearchService::class, fn ($app): \App\Services\TieredSearchService => new TieredSearchService(
$app->make(QdrantService::class),
$app->make(EntryMetadataService::class),
));

// Odin sync service
$this->app->singleton(OdinSyncService::class, fn ($app): \App\Services\OdinSyncService => new OdinSyncService(
$app->make(KnowledgePathService::class)
Expand Down
222 changes: 222 additions & 0 deletions app/Services/TieredSearchService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
<?php

declare(strict_types=1);

namespace App\Services;

use App\Enums\SearchTier;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;

class TieredSearchService
{
private const CONFIDENCE_THRESHOLD = 0.75;

private const RECENT_DAYS = 14;

private const FRESHNESS_HALF_LIFE_DAYS = 30.0;

public function __construct(
private readonly QdrantService $qdrantService,
private readonly EntryMetadataService $metadataService,
) {}

/**
* Search with tiered narrow-to-wide retrieval.
*
* Returns early if confident matches are found at a tier.
*
* @param array<string, string> $filters
* @return Collection<int, array<string, mixed>>
*/
public function search(
string $query,
array $filters = [],
int $limit = 20,
?SearchTier $forceTier = null,
string $project = 'default',
): Collection {
if ($forceTier !== null) {
return $this->searchTier($query, $filters, $limit, $forceTier, $project);
}

foreach (SearchTier::searchOrder() as $tier) {
$results = $this->searchTier($query, $filters, $limit, $tier, $project);

if ($this->hasConfidentMatches($results)) {
return $results;
}
}

// No confident matches at any tier - return all results merged and ranked
return $this->searchAllTiers($query, $filters, $limit, $project);
}

/**
* Search a specific tier and return results with tier labels.
*
* @param array<string, string> $filters
* @return Collection<int, array<string, mixed>>
*/
public function searchTier(
string $query,
array $filters,
int $limit,
SearchTier $tier,
string $project = 'default',
): Collection {
$tierFilters = $this->buildTierFilters($filters, $tier);
$results = $this->qdrantService->search($query, $tierFilters, $limit, $project);

return $this->rankAndLabel($results, $tier);
}

/**
* Calculate tiered search score: relevance * confidence_weight * freshness_decay.
*
* @param array<string, mixed> $entry
*/
public function calculateScore(array $entry): float
{
$relevance = (float) ($entry['score'] ?? 0.0);
$confidenceWeight = $this->calculateConfidenceWeight($entry);
$freshnessDecay = $this->calculateFreshnessDecay($entry);

return $relevance * $confidenceWeight * $freshnessDecay;
}

/**
* Calculate confidence weight as a 0-1 multiplier.
*
* @param array<string, mixed> $entry
*/
public function calculateConfidenceWeight(array $entry): float
{
$effectiveConfidence = $this->metadataService->calculateEffectiveConfidence($entry);

return $effectiveConfidence / 100.0;
}

/**
* Calculate freshness decay using exponential decay with half-life.
*
* @param array<string, mixed> $entry
*/
public function calculateFreshnessDecay(array $entry): float
{
$updatedAt = $entry['updated_at'] ?? $entry['created_at'] ?? null;

if (! is_string($updatedAt) || $updatedAt === '') {
return 0.5;
}

$daysSince = Carbon::parse($updatedAt)->diffInDays(now());

return pow(0.5, $daysSince / self::FRESHNESS_HALF_LIFE_DAYS);
}

/**
* Check if results contain confident matches above threshold.
*
* @param Collection<int, array<string, mixed>> $results
*/
private function hasConfidentMatches(Collection $results): bool
{
if ($results->isEmpty()) {
return false;
}

return $results->contains(fn (array $entry): bool => ($entry['tiered_score'] ?? 0.0) >= self::CONFIDENCE_THRESHOLD);
}

/**
* Search all tiers and merge/deduplicate results.
*
* @param array<string, string> $filters
* @return Collection<int, array<string, mixed>>
*/
private function searchAllTiers(
string $query,
array $filters,
int $limit,
string $project,
): Collection {
$allResults = collect();

foreach (SearchTier::searchOrder() as $tier) {
$results = $this->searchTier($query, $filters, $limit, $tier, $project);
$allResults = $allResults->merge($results);
}

// Deduplicate by ID, keeping the highest scored version
return $allResults
->groupBy('id')
->map(function (Collection $group): array {
$first = $group->sortByDesc('tiered_score')->first();

return is_array($first) ? $first : [];
})
->filter(fn (array $entry): bool => count($entry) > 0)
->values()
->sortByDesc('tiered_score')
->take($limit)
->values();
}

/**
* Build Qdrant filters for a specific tier.
*
* @param array<string, string> $filters
* @return array<string, string>
*/
private function buildTierFilters(array $filters, SearchTier $tier): array
{
return match ($tier) {
SearchTier::Working => array_merge($filters, ['status' => 'draft']),
SearchTier::Recent => $filters,
SearchTier::Structured => array_merge($filters, ['status' => 'validated']),
SearchTier::Archive => array_merge($filters, ['status' => 'deprecated']),
};
}

/**
* Apply ranking formula and add tier labels to results.
*
* @param Collection<int, array<string, mixed>> $results
* @return Collection<int, array<string, mixed>>
*/
private function rankAndLabel(Collection $results, SearchTier $tier): Collection
{
return $results
->filter(fn (array $entry): bool => $this->entryMatchesTierTimeConstraint($entry, $tier))
->map(function (array $entry) use ($tier): array {
$entry['tier'] = $tier->value;
$entry['tier_label'] = $tier->label();
$entry['tiered_score'] = $this->calculateScore($entry);

return $entry;
})
->sortByDesc('tiered_score')
->values();
}

/**
* Check if an entry matches the time constraint for a tier.
*
* @param array<string, mixed> $entry
*/
private function entryMatchesTierTimeConstraint(array $entry, SearchTier $tier): bool
{
if ($tier !== SearchTier::Recent) {
return true;
}

$updatedAt = $entry['updated_at'] ?? $entry['created_at'] ?? null;

if (! is_string($updatedAt) || $updatedAt === '') {
return false;
}

return Carbon::parse($updatedAt)->diffInDays(now()) <= self::RECENT_DAYS;
}
}
Loading
Loading