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
45 changes: 44 additions & 1 deletion app/Commands/KnowledgeSearchCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ class KnowledgeSearchCommand extends Command
{--semantic : Use semantic search if available}
{--include-superseded : Include superseded entries in results}
{--project= : Override project namespace}
{--global : Search across all projects}';
{--global : Search across all projects}
{--collection= : Search a raw Qdrant collection directly (bypasses knowledge_ prefix)}';

/**
* @var string
Expand Down Expand Up @@ -69,6 +70,12 @@ public function handle(QdrantService $qdrant, EntryMetadataService $metadata): i
// Use project-aware search
$searchQuery = is_string($query) ? $query : '';

// Raw collection search — bypasses knowledge_ prefix and metadata formatting
$rawCollection = $this->option('collection');
if (is_string($rawCollection) && $rawCollection !== '') {
return $this->searchRawCollection($qdrant, $rawCollection, $searchQuery, $limit);
}

if ($this->isGlobal()) {
$collections = $qdrant->listCollections();
$results = collect();
Expand Down Expand Up @@ -147,4 +154,40 @@ public function handle(QdrantService $qdrant, EntryMetadataService $metadata): i

return self::SUCCESS;
}

private function searchRawCollection(QdrantService $qdrant, string $collection, string $query, int $limit): int
{
$results = $qdrant->searchRawCollection($collection, $query, $limit);

if ($results->isEmpty()) {
$this->line('No results found.');

return self::SUCCESS;
}

$this->info("Found {$results->count()} ".str('result')->plural($results->count())." in {$collection}");
$this->newLine();

foreach ($results as $result) {
$score = $result['score'] ?? 0.0;
$payload = $result['payload'] ?? [];

$this->line('<fg=yellow>'.number_format($score, 3).'</> | '.($payload['description'] ?? json_encode($payload)));

// Show key payload fields
$fields = collect($payload)
->except(['description', 'vector'])
->filter(fn ($v): bool => $v !== null && $v !== '')
->map(fn ($v, $k): string => "<fg=gray>{$k}:</> {$v}")
->implode(' | ');
Comment on lines +175 to +182
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In PHP 8.x, what happens when an array is interpolated into a double-quoted string (e.g., "{$value}")?

💡 Result:

In PHP 8.x, when an array $value is interpolated into a double-quoted string using "{$value}", the array is converted to the string "Array" and inserted into the string. This behavior occurs because PHP automatically converts arrays to the string "Array" in string contexts, such as interpolation in double-quoted strings. For example: $value = ['a' => 'hello', 'b' => 'world']; echo "{$value}"; // Outputs: Array To interpolate a specific array element, use array access syntax within the curly braces, such as "{$value['key']}", which evaluates the expression and inserts the element's string representation. Simple syntax like "$value[key]" works only for unquoted keys in basic interpolation, but complex access requires curly braces. This has been consistent across PHP versions, including 8.x, with no changes specific to array interpolation in double-quoted strings (PHP 8 introduced dereferencing for interpolated strings like "foo$bar"[0], but not for whole arrays).

Citations:


🏁 Script executed:

cat -n app/Commands/KnowledgeSearchCommand.php | sed -n '170,190p'

Repository: conduit-ui/knowledge

Length of output: 912


🏁 Script executed:

rg -n '\$payload' app/Commands/KnowledgeSearchCommand.php -B 2 -A 2

Repository: conduit-ui/knowledge

Length of output: 564


🏁 Script executed:

head -30 app/Commands/KnowledgeSearchCommand.php

Repository: conduit-ui/knowledge

Length of output: 1173


🏁 Script executed:

rg -n '\$payload\s*=' app/Commands/KnowledgeSearchCommand.php -B 5 -A 1 | head -40

Repository: conduit-ui/knowledge

Length of output: 366


🏁 Script executed:

rg -n 'strict_types' app/ -i

Repository: conduit-ui/knowledge

Length of output: 6235


🏁 Script executed:

rg -n 'class QdrantService' app/Services/QdrantService.php -A 30 | head -50

Repository: conduit-ui/knowledge

Length of output: 1247


🏁 Script executed:

rg -n 'search\(' app/Services/QdrantService.php -A 20 | head -60

Repository: conduit-ui/knowledge

Length of output: 2106


🏁 Script executed:

rg -n 'payload.*=>' app/Services/QdrantService.php -B 3 -A 3

Repository: conduit-ui/knowledge

Length of output: 636


🏁 Script executed:

rg -n '"payload"' app/Services/QdrantService.php -B 2 -A 2

Repository: conduit-ui/knowledge

Length of output: 46


🏁 Script executed:

rg -n '\$payload\s*\[' app/Services/QdrantService.php -B 2 -A 2 | head -80

Repository: conduit-ui/knowledge

Length of output: 2246


🏁 Script executed:

sed -n '150,210p' app/Services/QdrantService.php

Repository: conduit-ui/knowledge

Length of output: 2840


🏁 Script executed:

rg -n "description" app/Services/QdrantService.php

Repository: conduit-ui/knowledge

Length of output: 46


🏁 Script executed:

rg -n "description" app/Commands/KnowledgeSearchCommand.php -B 2 -A 2

Repository: conduit-ui/knowledge

Length of output: 764


Safely serialize non-scalar payload values before displaying.

Array and object fields in $payload (like tags or evidence) will display as the literal string "Array" when directly interpolated, producing unreadable output. Line 181's map closure accepts any non-null, non-empty value and interpolates it directly without type checking.

Additionally, line 175's json_encode($payload) fallback should include JSON flags to handle edge cases like unescaped unicode and invalid UTF-8 sequences.

Suggested fix
-            $this->line('<fg=yellow>'.number_format($score, 3).'</> | '.($payload['description'] ?? json_encode($payload)));
+            $description = $payload['description'] ?? json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
+            $this->line('<fg=yellow>'.number_format($score, 3).'</> | '.($description ?: '[payload unavailable]'));
@@
-                ->map(fn ($v, $k): string => "<fg=gray>{$k}:</> {$v}")
+                ->map(function ($v, $k): string {
+                    $formatted = is_scalar($v)
+                        ? (string) $v
+                        : (json_encode($v, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE) ?: '[unserializable]');
+
+                    return "<fg=gray>{$k}:</> {$formatted}";
+                })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
$this->line('<fg=yellow>'.number_format($score, 3).'</> | '.($payload['description'] ?? json_encode($payload)));
// Show key payload fields
$fields = collect($payload)
->except(['description', 'vector'])
->filter(fn ($v): bool => $v !== null && $v !== '')
->map(fn ($v, $k): string => "<fg=gray>{$k}:</> {$v}")
->implode(' | ');
$description = $payload['description'] ?? json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
$this->line('<fg=yellow>'.number_format($score, 3).'</> | '.($description ?: '[payload unavailable]'));
// Show key payload fields
$fields = collect($payload)
->except(['description', 'vector'])
->filter(fn ($v): bool => $v !== null && $v !== '')
->map(function ($v, $k): string {
$formatted = is_scalar($v)
? (string) $v
: (json_encode($v, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE) ?: '[unserializable]');
return "<fg=gray>{$k}:</> {$formatted}";
})
->implode(' | ');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Commands/KnowledgeSearchCommand.php` around lines 175 - 182, The output
shows raw "Array" or "Object" when non-scalar payload values are interpolated;
update KnowledgeSearchCommand to safely serialize non-scalar values before
display: in the map closure that builds $fields, check each value's type and
leave scalars as-is but replace arrays/objects by json_encode($v,
JSON_UNESCAPED_UNICODE|JSON_INVALID_UTF8_SUBSTITUTE) (or a similar safe
serializer), and update the fallback on the score line to use
json_encode($payload, JSON_UNESCAPED_UNICODE|JSON_INVALID_UTF8_SUBSTITUTE) so
Unicode and invalid UTF-8 are handled; target the map closure and the
score/description print logic to implement these changes.


if ($fields !== '') {
$this->line(" {$fields}");
}

$this->newLine();
}

return self::SUCCESS;
}
}
28 changes: 28 additions & 0 deletions app/Services/QdrantService.php
Original file line number Diff line number Diff line change
Expand Up @@ -977,4 +977,32 @@ public function getCollectionName(string $project): string
{
return 'knowledge_'.str_replace(['/', '\\', ' '], '_', $project);
}

/**
* Search any Qdrant collection by name — no knowledge_ prefix, no metadata mapping.
*
* @return Collection<int, array{id: string|int, score: float, payload: array<string, mixed>}>
*/
public function searchRawCollection(string $collection, string $query, int $limit = 10): Collection
{
$queryVector = $this->getCachedEmbedding($query);

if ($queryVector === []) {
return collect();
}

$response = $this->connector->send(
new SearchPoints($collection, $queryVector, $limit, 0.0)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

For Qdrant /collections/{name}/points/search, what is the effect of setting score_thresholdto0.0compared with a positive threshold like0.7?

💡 Result:

Setting score_threshold to 0.0 in Qdrant's /collections/{name}/points/search endpoint effectively disables the score filtering, returning up to the specified limit of the top closest points regardless of their similarity score (even scores as low as 0 or negative, depending on the distance metric). In contrast, a positive threshold like 0.7 filters out results below that score, potentially returning fewer than limit points if insufficient matches exceed the threshold. This behavior holds for similarity metrics where higher scores indicate better matches (e.g., Cosine: 0-1, Dot: -∞ to ∞), ensuring all returned results meet the minimum quality while 0.0 prioritizes quantity up to the limit.

Citations:


🏁 Script executed:

# First, let's examine the QdrantService.php file around line 995
sed -n '985,1005p' app/Services/QdrantService.php

Repository: conduit-ui/knowledge

Length of output: 729


🏁 Script executed:

# Search for scoreThreshold property definition in the file
rg 'scoreThreshold' app/Services/QdrantService.php -n

Repository: conduit-ui/knowledge

Length of output: 163


🏁 Script executed:

# Check the constructor and property initialization
sed -n '1,100p' app/Services/QdrantService.php | grep -n 'class\|function __construct\|scoreThreshold\|private\|protected\|public'

Repository: conduit-ui/knowledge

Length of output: 905


🏁 Script executed:

# Check context around line 445 where scoreThreshold is used
sed -n '440,450p' app/Services/QdrantService.php

Repository: conduit-ui/knowledge

Length of output: 379


🏁 Script executed:

# Search for all SearchPoints instantiations to see the pattern
rg 'new SearchPoints' app/Services/QdrantService.php -n -A 1 -B 1

Repository: conduit-ui/knowledge

Length of output: 481


Use configured relevance threshold instead of hard-coding 0.0.

Line 995 hard-codes 0.0 in SearchPoints, which disables semantic filtering and returns all closest points regardless of similarity score—potentially including low-quality results. Other methods in this class (searchByProject at line 268 and hybridSearch at line 440) correctly use $this->scoreThreshold (default 0.7). For consistency and to apply the configured relevance threshold, replace 0.0 with $this->scoreThreshold.

Suggested fix
-        $response = $this->connector->send(
-            new SearchPoints($collection, $queryVector, $limit, 0.0)
-        );
+        $response = $this->connector->send(
+            new SearchPoints($collection, $queryVector, $limit, $this->scoreThreshold)
+        );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
new SearchPoints($collection, $queryVector, $limit, 0.0)
new SearchPoints($collection, $queryVector, $limit, $this->scoreThreshold)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Services/QdrantService.php` at line 995, The SearchPoints call is
hard-coding the relevance threshold as 0.0 which bypasses semantic filtering;
update the constructor call new SearchPoints($collection, $queryVector, $limit,
0.0) to use the configured threshold $this->scoreThreshold so it respects the
service-level relevance setting (same behavior as searchByProject and
hybridSearch). Locate the call in QdrantService (the new SearchPoints
invocation) and replace the literal 0.0 with $this->scoreThreshold, ensuring the
instance property is available in the method.

);

if (! $response->successful()) {
return collect();
}

return collect($response->json('result') ?? [])->map(fn (array $r): array => [
'id' => $r['id'],
'score' => $r['score'] ?? 0.0,
'payload' => $r['payload'] ?? [],
]);
}
}
5 changes: 3 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,19 @@
"illuminate/database": "^12.17",
"laravel-zero/framework": "^12.0.2",
"laravel/mcp": "^0.6.0",
"saloonphp/saloon": "^3.14",
"saloonphp/saloon": "^4.0",
"symfony/uid": "^8.0"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/pint": "^1.25.1",
"mockery/mockery": "^1.6.12",
"pestphp/pest": "^3.8.4|^4.1.2",
"pestphp/pest": "4.3.2",
"phpstan/extension-installer": "*",
"phpstan/phpstan": "^1.0",
"phpstan/phpstan-deprecation-rules": "^1.2",
"phpstan/phpstan-strict-rules": "^1.6",
"phpunit/phpunit": "12.5.8",
"rector/rector": "^1.2"
},
"autoload": {
Expand Down
Loading
Loading