Semantic Cache في Laravel: لماذا تدفع مرتين مقابل سؤالين لهما نفس المعنى؟
أربعة مستخدمين، في الساعة نفسها، يكتبون لمساعد خدمة العملاء أربع جمل مختلفة الشكل:
«كيف ألغي اشتراكي؟»
«أريد إيقاف الاشتراك.»
«كيف أعمل cancel للـ plan؟»
«بدي ألغي الـ subscription.»
بالنسبة لأي نظام يقارن النصوص حرفيًا، هذه أربعة أسئلة مختلفة. وبالنسبة للمستخدم، هي سؤال واحد بأربع صياغات. إذا أرسلت كل جملة إلى نموذج اللغة كطلب مستقل، فأنت تدفع أربع مرات، تكلفةً وزمنًا، مقابل إجابة أنتجها النظام قبل ثوانٍ.
هذا هو الفراغ الذي تسدّه فكرة Semantic Cache (الذاكرة المؤقتة الدلالية). بدل أن نسأل «هل النص مطابق حرفيًا لنص سابق؟» نسأل «هل المعنى قريب بما يكفي من سؤال أُجيب عنه سابقًا؟ وهل الإجابة السابقة ما زالت صالحة ومسموحًا بها لهذا المستخدم؟»
الشق الثاني من السؤال هو موضوع معظم هذا المقال، لأنه الفرق بين تحسين أداء مفيد وثغرة أمنية تنتظر من يكتشفها.
لمن هذا المقال؟
هذا مقال متقدم لمن تجاوز مرحلة «أول Agent» ويريد خفض تكلفة نظام AI قائم وتحسين أدائه. سيفيدك إذا كنت تدير مساعدًا يتلقى أسئلة متكررة دلاليًا (FAQ، دعم فني، مساعد معرفي)، أو لاحظت أن فاتورة الـ LLM تنمو أسرع من عدد المستخدمين، أو تعمل على نظام متعدد الـ Tenants لا يجوز فيه أن تتسرب إجابة من عميل لآخر عبر أي طبقة تخزين مؤقت.
المتطلبات المسبقة
- خبرة عملية بـ Laravel AI SDK، خصوصًا الـ Agents والـ Embeddings.
- معرفة أساسية بـ PostgreSQL ومفهوم الـ vector embeddings.
- خبرة بـ Laravel Cache وQueues وLocks.
- Laravel 13.x مع PostgreSQL وامتداد
pgvector(يُفضّل الإصدار 0.8 أو أحدث لأسباب سنشرحها لاحقًا).
ملاحظة: Laravel AI SDK حزمة حديثة وقد تتغيّر بعض واجهاتها. راجع التوثيق الرسمي قبل اعتماد أي توقيع دالة في الإنتاج.
الجزء الأول: المفهوم
1. لماذا لا يفهم الـ Cache التقليدي المعنى؟
الـ Cache التقليدي في Laravel يعتمد على مفتاح مشتق من النص الحرفي:
Cache::remember(
'ai:question:' . hash('sha256', $question),
now()->addHour(),
fn () => $this->askLLM($question)
);إذا تكرر السؤال حرفيًا حصلنا على Cache Hit. أما «أريد إيقاف الاشتراك» فتُنتج مفتاحًا مختلفًا تمامًا عن «كيف ألغي اشتراكي؟»، فيذهب كل منهما إلى الـ LLM رغم أنهما سؤال واحد.
الـ Semantic Cache يحل ذلك بتخزين Embedding لكل سؤال: متجه رقمي عالي الأبعاد يمثل معناه. الجمل المتقاربة في المعنى تميل إلى أن تكون متقاربة في فضاء المتجهات، فنبحث عن أقرب سؤال مخزّن بدل البحث عن تطابق حرفي.
2. ثلاث طبقات، لا طبقة واحدة
أكثر ما يسبب الالتباس في هذا الموضوع أن ثلاثة أشياء مختلفة تحمل أسماء متشابهة:
| الطبقة | السؤال الذي تجيب عنه | من يوفرها |
|---|---|---|
| Exact Cache | هل رأيت هذا النص بالضبط في السياق نفسه؟ | Laravel Cache (Redis مثلًا) |
| Embedding Cache | هل حسبت متجه هذا النص من قبل؟ | Laravel AI SDK مباشرة |
| Semantic Response Cache | هل لدي إجابة صالحة لسؤال يحمل المعنى نفسه؟ | أنت، فوق الـ SDK وpgvector |
Embedding Cache يعني: لا تطلب embedding للنص نفسه مرتين.
Semantic Response Cache يعني: لا تشغّل الـ LLM عندما يكون لديك جواب سابق صالح لسؤال بالمعنى نفسه.
الطبقات الثلاث تعمل متتالية، وكل واحدة أرخص وأسرع من التي تليها:
User Question
│
▼
Validate / Normalize
│
▼
Exact Cache (Redis) ──── HIT ──► Return
│
MISS
│
▼
Embedding (with Embedding Cache)
│
▼
Vector Search + Scope + Version + Freshness
│
┌───────────┴───────────┐
│ │
Valid Hit No Match
│ │
▼ ▼
Return Lock → Re-check → LLM
│
▼
Store (if policy allows)
│
▼
Returnلاحظ أن الفحص الأمني وفحص الحداثة جزء من خطوة البحث نفسها، لا خطوة لاحقة. سنعود لسبب ذلك في الجزء الثالث.
3. متى يناسبك الـ Semantic Cache ومتى لا يناسبك؟
قبل كتابة أي سطر كود، القرار الأهم هو تحديد ما يستحق التخزين أصلًا. القاعدة العامة: الـ Semantic Cache مناسب للمعرفة التي يكون توليد إجابتها مكلفًا ومعدل تغيّرها بطيئًا، وغير مناسب للبيانات اللحظية أو الشخصية.
| نوع الاستخدام | القرار | TTL مقترح كنقطة بداية |
|---|---|---|
| FAQ ودعم عام | Cache بنطاق عام | ساعات إلى أيام |
| التوثيق وشرح المنتج | Cache بنطاق عام | أيام |
| سياسات خاصة بكل عميل (Tenant) | Cache بنطاق الـ Tenant | ساعات إلى أيام |
| الأسعار والعروض | Cache بحذر | دقائق |
| حالة الطلب، الرصيد، المدفوعات، المخزون | لا تستخدمه | — |
| توصيات شخصية مبنية على بيانات متغيرة | لا تستخدمه | — |
وفي حالة مثل «أين طلبي الآن؟» لا تحتاج إلى LLM أصلًا، فضلًا عن الـ cache. الإجابة موجودة في قاعدة بياناتك، وأي إجابة مخزّنة ستكون قديمة بالتعريف.
الجزء الثاني: التنفيذ
4. التثبيت
composer require laravel/ai
php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider"
php artisan migrateنستخدم PostgreSQL مع pgvector بدل قاعدة بيانات متجهات منفصلة، لأن ذلك يبقي الـ embeddings بجوار بقية بياناتك، ويتيح لك فلترة الصلاحيات والإصدارات في الاستعلام نفسه دون مزامنة بين نظامين.
5. تصميم قاعدة البيانات
الخطأ الأكثر شيوعًا هو بناء الجدول على ثلاثة أعمدة فقط: السؤال والمتجه والإجابة. الإجابة لا تكون صحيحة إلا في سياق معين: لعميل معين، بلغة معينة، بإصدار معين من الـ prompt ومن قاعدة المعرفة ومن نموذج الـ embedding. لذلك يحمل كل سجل هذا السياق كاملًا.
ونحتاج جدولًا ثانيًا لتسجيل المطابقات. درجة التشابه ليست خاصية للسجل المخزّن، بل لكل عملية مطابقة بين سؤال وارد وسجل. السجل الواحد قد يُطابَق مئات المرات بدرجات مختلفة، وهذا السجل هو ما ستعتمد عليه لاحقًا في ضبط عتبة التشابه.
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::ensureVectorExtensionExists();
Schema::create('semantic_cache_entries', function (Blueprint $table) {
$table->id();
$table->text('question');
$table->string('question_hash', 64)->unique();
$table->vector('embedding', dimensions: 1536)->index();
$table->longText('answer');
// سياق الإجابة
$table->string('scope');
$table->string('locale', 10);
$table->string('embedding_version');
$table->string('llm_model');
$table->string('prompt_version');
$table->string('knowledge_version');
$table->timestamp('expires_at')->nullable()->index();
$table->timestamps();
$table->index(
['scope', 'locale', 'embedding_version', 'prompt_version', 'knowledge_version'],
'sce_context_idx'
);
});
Schema::create('semantic_cache_matches', function (Blueprint $table) {
$table->id();
$table->foreignId('entry_id')
->constrained('semantic_cache_entries')
->cascadeOnDelete();
$table->text('incoming_question');
$table->decimal('similarity', 6, 5);
$table->string('scope');
// للمراجعة البشرية: هل كانت المطابقة صحيحة؟
$table->boolean('is_correct')->nullable();
$table->timestamp('created_at')->useCurrent();
});
}
};بعض التفاصيل المهمة في هذا التصميم:
- عدد الأبعاد (1536 هنا، وهو حجم
text-embedding-3-small) يجب أن يطابق نموذج الـ embedding تمامًا. - استدعاء
index()على عمود الـ vector ينشئ فهرس HNSW بمسافة cosine. - العمود
question_hashفريد ويُحسب من السياق الكامل مع النص المطبَّع، فيمنع تكرار السجل نفسه عند الطلبات المتزامنة. - العمود
scopeنص مثلglobalأوtenant:42أوtenant:42:user:817، وهو حد الصلاحية الذي لا يجوز عبوره.
6. الـ Models
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\MassPrunable;
use Illuminate\Database\Eloquent\Model;
class SemanticCacheEntry extends Model
{
use MassPrunable;
protected $fillable = [
'question', 'question_hash', 'embedding', 'answer',
'scope', 'locale', 'embedding_version', 'llm_model',
'prompt_version', 'knowledge_version', 'expires_at',
];
protected function casts(): array
{
return [
'embedding' => 'array',
'expires_at' => 'datetime',
];
}
public function prunable(): Builder
{
return static::query()
->whereNotNull('expires_at')
->where('expires_at', '<', now()->subDay());
}
}namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SemanticCacheMatch extends Model
{
public const UPDATED_AT = null;
protected $fillable = [
'entry_id', 'incoming_question', 'similarity', 'scope', 'is_correct',
];
}الـ cast إلى array يجعل Laravel يحوّل المتجه تلقائيًا بين PHP array وصيغة الـ vector في قاعدة البيانات. أما MassPrunable فسنستخدمه للتنظيف الدوري في الجزء الرابع.
7. سياق الـ Cache كقيمة واحدة
بدل تمرير ستة معاملات منفصلة بين الدوال، وهو ما يؤدي حتمًا إلى نسيان أحدها في مكان ما، نجمع السياق في كائن واحد غير قابل للتعديل:
namespace App\SemanticCache;
final readonly class CacheContext
{
public function __construct(
public string $scope,
public string $locale,
public string $embeddingVersion,
public string $llmModel,
public string $promptVersion,
public string $knowledgeVersion,
) {}
public function fingerprint(): string
{
return implode('|', [
$this->scope,
$this->locale,
$this->embeddingVersion,
$this->llmModel,
$this->promptVersion,
$this->knowledgeVersion,
]);
}
}ونعرّف سياسة لكل نوع استخدام، لأن قرار التخزين والعتبة والمدة يتبع طبيعة الـ Agent لا نص السؤال:
namespace App\SemanticCache;
final readonly class CachePolicy
{
public function __construct(
public bool $cacheable,
public float $threshold,
public int $ttlMinutes,
) {}
public static function faq(): self
{
return new self(cacheable: true, threshold: 0.95, ttlMinutes: 1440);
}
public static function pricing(): self
{
return new self(cacheable: true, threshold: 0.97, ttlMinutes: 10);
}
public static function account(): self
{
return new self(cacheable: false, threshold: 1.0, ttlMinutes: 0);
}
}8. تطبيع السؤال
لا ينبغي أن يحتاج «كيف ألغي اشتراكي؟» ونسخته بمسافات زائدة إلى متجهين مختلفين. لكن في العربية تحديدًا يجب التفريق بين Normalization وText Rewriting.
إزالة التشكيل والتطويل (الكشيدة) وتوحيد المسافات آمنة عمومًا. أما توحيد الهمزات (أ، إ، آ ← ا) أو التاء المربوطة والهاء فقد يغيّر المعنى في بعض الكلمات، فلا تطبّقه إلا بعد اختباره على بياناتك.
namespace App\SemanticCache;
final class QuestionNormalizer
{
public function normalize(string $question): string
{
// إزالة التشكيل وعلامة الألف الخنجرية والتطويل
$text = preg_replace('/[\x{064B}-\x{065F}\x{0670}\x{0640}]/u', '', $question);
// توحيد المسافات
$text = preg_replace('/\s+/u', ' ', $text);
return trim(mb_strtolower($text));
}
}9. SemanticCacheService
هذه الخدمة مسؤولة عن ثلاثة أشياء فقط: توليد المتجه، والبحث عن مرشح صالح، والتخزين. كل فلتر سياقي جزء من الاستعلام نفسه.
نستخدم هنا عامل المسافة <=> من pgvector مباشرة بدل whereVectorSimilarTo()، لسببين: نحتاج قيمة التشابه الفعلية لتسجيلها، ونريد ضمان أن الترتيب حسب المسافة هو ما يُفعّل فهرس HNSW. إذا كانت دالة الـ SDK تعيد الدرجة وترتب النتائج في إصدارك، يمكنك استخدامها بدلًا من ذلك.
namespace App\SemanticCache;
use App\Models\SemanticCacheEntry;
use App\Models\SemanticCacheMatch;
use DateTimeInterface;
use Illuminate\Support\Str;
final class SemanticCacheService
{
public function embed(string $normalizedQuestion): array
{
return Str::of($normalizedQuestion)->toEmbeddings(cache: true);
}
public function lookup(
array $embedding,
CacheContext $context,
float $threshold,
): ?SemanticCacheEntry {
$vector = '[' . implode(',', $embedding) . ']';
return SemanticCacheEntry::query()
->select('*')
->selectRaw('1 - (embedding <=> ?::vector) AS similarity', [$vector])
// حدود الصلاحية والسياق: داخل الاستعلام، لا بعده
->where('scope', $context->scope)
->where('locale', $context->locale)
->where('embedding_version', $context->embeddingVersion)
->where('llm_model', $context->llmModel)
->where('prompt_version', $context->promptVersion)
->where('knowledge_version', $context->knowledgeVersion)
// الحداثة
->where(fn ($q) => $q
->whereNull('expires_at')
->orWhere('expires_at', '>', now()))
// التشابه
->whereRaw('1 - (embedding <=> ?::vector) >= ?', [$vector, $threshold])
->orderByRaw('embedding <=> ?::vector', [$vector])
->first();
}
public function store(
string $normalizedQuestion,
array $embedding,
string $answer,
CacheContext $context,
DateTimeInterface $expiresAt,
): SemanticCacheEntry {
return SemanticCacheEntry::updateOrCreate(
['question_hash' => $this->hash($normalizedQuestion, $context)],
[
'question' => $normalizedQuestion,
'embedding' => $embedding,
'answer' => $answer,
'scope' => $context->scope,
'locale' => $context->locale,
'embedding_version' => $context->embeddingVersion,
'llm_model' => $context->llmModel,
'prompt_version' => $context->promptVersion,
'knowledge_version' => $context->knowledgeVersion,
'expires_at' => $expiresAt,
],
);
}
public function recordMatch(
string $incomingQuestion,
SemanticCacheEntry $entry,
): void {
SemanticCacheMatch::create([
'entry_id' => $entry->id,
'incoming_question' => $incomingQuestion,
'similarity' => $entry->similarity,
'scope' => $entry->scope,
]);
}
public function hash(string $normalizedQuestion, CacheContext $context): string
{
return hash('sha256', $context->fingerprint() . '|' . $normalizedQuestion);
}
}10. الـ Orchestration: من السؤال إلى الإجابة
الخدمة التالية تربط الطبقات الثلاث، وتعالج التزامن بقفل يُعاد بعده فحص الـ cache، ولا تخزّن إلا ما تسمح به السياسة:
namespace App\Services;
use App\SemanticCache\CacheContext;
use App\SemanticCache\CachePolicy;
use App\SemanticCache\QuestionNormalizer;
use App\SemanticCache\SemanticCacheService;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Support\Facades\Cache;
final class AIQuestionService
{
public function __construct(
private QuestionNormalizer $normalizer,
private SemanticCacheService $semanticCache,
private CustomerSupportService $llm,
) {}
public function answer(
string $question,
CacheContext $context,
CachePolicy $policy,
): array {
if (! $policy->cacheable) {
return $this->fromLlm($question);
}
$normalized = $this->normalizer->normalize($question);
$exactKey = 'ai:exact:' . $this->semanticCache->hash($normalized, $context);
// الطبقة 1: Exact Cache
if ($answer = Cache::get($exactKey)) {
return ['answer' => $answer, 'source' => 'exact_cache'];
}
// الطبقة 2 و3: Embedding ثم Vector Search
$embedding = $this->semanticCache->embed($normalized);
if ($hit = $this->semanticCache->lookup($embedding, $context, $policy->threshold)) {
return $this->fromHit($normalized, $hit, $exactKey, $policy);
}
// الطبقة 4: LLM مع حماية من الـ Stampede
try {
return Cache::lock('ai:generate:' . $exactKey, 30)->block(15, function () use (
$question, $normalized, $embedding, $exactKey, $context, $policy
) {
// أعد الفحص: ربما أنشأ طلب آخر الإجابة أثناء انتظارنا
if ($hit = $this->semanticCache->lookup($embedding, $context, $policy->threshold)) {
return $this->fromHit($normalized, $hit, $exactKey, $policy);
}
$answer = $this->llm->answer($question);
if ($this->isSafeToStore($answer)) {
$expiresAt = now()->addMinutes($policy->ttlMinutes);
$this->semanticCache->store($normalized, $embedding, $answer, $context, $expiresAt);
Cache::put($exactKey, $answer, $expiresAt);
}
return ['answer' => $answer, 'source' => 'llm'];
});
} catch (LockTimeoutException) {
// لم نحصل على القفل في الوقت المحدد: نجيب دون تخزين
return $this->fromLlm($question);
}
}
private function fromHit($normalized, $hit, string $exactKey, CachePolicy $policy): array
{
$this->semanticCache->recordMatch($normalized, $hit);
// لا يجوز أن يعيش الـ Exact Cache أطول من السجل الأصلي
$ttl = $hit->expires_at ?? now()->addMinutes($policy->ttlMinutes);
Cache::put($exactKey, $hit->answer, $ttl);
return ['answer' => $hit->answer, 'source' => 'semantic_cache'];
}
private function fromLlm(string $question): array
{
return ['answer' => $this->llm->answer($question), 'source' => 'llm'];
}
private function isSafeToStore(string $answer): bool
{
// ضع هنا فحوصك الفعلية: إجابة فارغة، رفض، بيانات شخصية، إلخ.
return trim($answer) !== '';
}
}والـ Controller يصبح بسيطًا، ومسؤوليته الوحيدة بناء السياق الصحيح:
public function ask(Request $request, AIQuestionService $service)
{
$validated = $request->validate([
'question' => ['required', 'string', 'min:3', 'max:2000'],
]);
$context = new CacheContext(
scope: 'global',
locale: app()->getLocale(),
embeddingVersion: 'openai:text-embedding-3-small:v1',
llmModel: config('ai.models.text.default'),
promptVersion: 'customer-support-v4',
knowledgeVersion: config('knowledge.version'),
);
$result = $service->answer($validated['question'], $context, CachePolicy::faq());
return response()->json(['answer' => $result['answer']]);
}لاحظ أننا لا نعيد للمستخدم مصدر الإجابة ولا درجة التشابه. هذه بيانات telemetry داخلية تُسجَّل في السجلات والمقاييس، لا في الاستجابة.
الجزء الثالث: الصحة والأمان
11. التشابه لا يعني الصحة
لنفترض أن السؤال «ما سعر الاشتراك؟» أُجيب عنه قبل شهر بـ«10$ شهريًا»، ثم تغيّر السعر إلى 15$. سؤال جديد بدرجة تشابه 0.99 سيعيد الإجابة القديمة بثقة تامة.
هذا نوع مختلف من الـ hallucination: النظام لا يخترع الإجابة، لكنه يعيد إجابة كانت صحيحة في سياق لم تعد صحيحة فيه.
لذلك فإن التشابه يحدد فقط أن لدينا مرشحًا. صلاحية المرشح تحددها أربعة شروط مجتمعة: Similarity وScope وFreshness وVersion. هذا ما يفعله استعلام lookup() أعلاه في خطوة واحدة.
12. حدود الصلاحية
تخيّل تطبيق CRM يسأل فيه مستخدم «ما رصيد العميل أحمد؟» فيُجاب بـ«18,500$». إذا كان الـ cache بلا scope، فأي مستخدم آخر يسأل سؤالًا مشابهًا قد يحصل على رقم لا يملك صلاحية رؤيته. هذا ليس خللًا بسيطًا، بل Data Leakage قد يعرّضك لمساءلة قانونية.
Never let semantic similarity cross an authorization boundary.
والقاعدة التطبيقية: فلتر الصلاحية يكون جزءًا من الاستعلام، لا فحصًا لاحقًا بعد استخراج النتيجة. الفحص اللاحق عرضة للنسيان في أول refactoring، أما الفلتر داخل الاستعلام فيجعل الوصول إلى سجل خارج النطاق مستحيلًا بنيويًا.
اختر النطاق حسب طبيعة البيانات:
- «ما سياسة الإرجاع؟» لمنتج واحد للجميع:
global. - «ما سياسة الإرجاع في شركتي؟» في نظام SaaS:
tenant:123. - «كم دفعت هذا الشهر؟»: لا تستخدم Response Cache أصلًا، واجلب البيانات من مصدرها.
13. الفخ الخفي: فهرس HNSW مع الفلترة
هذه نقطة يغفل عنها معظم من يبني Semantic Cache متعدد الـ Tenants، ونتيجتها صامتة: النظام لا يخطئ، لكنه يفشل في إيجاد إجابات موجودة.
عند الجمع بين البحث المتجهي وشروط WHERE، يعيد فهرس HNSW في pgvector عددًا محدودًا من أقرب المرشحين (القيمة الافتراضية لـ hnsw.ef_search هي 40)، ثم تُطبَّق الفلاتر على هؤلاء المرشحين فقط. فإذا كان الجدول يضم آلاف الـ Tenants، قد يكون أقرب 40 متجهًا كلها لـ Tenants آخرين، فتحصل على Cache Miss دائم رغم وجود سجل مطابق تمامًا في نطاقك.
لديك ثلاثة حلول، ويمكن الجمع بينها:
تفعيل Iterative Index Scans (pgvector 0.8+)
يجعل الفهرس يواصل البحث حتى يجد نتائج كافية تحقق الفلاتر:
DB::transaction(function () use (...) {
DB::statement("SET LOCAL hnsw.iterative_scan = relaxed_order");
DB::statement("SET LOCAL hnsw.ef_search = 100");
$hit = $this->semanticCache->lookup($embedding, $context, $threshold);
});Partial Index للـ Tenants الكبار
CREATE INDEX sce_tenant_42_hnsw
ON semantic_cache_entries
USING hnsw (embedding vector_cosine_ops)
WHERE scope = 'tenant:42';Partitioning حسب النطاق
عندما يكبر الجدول كثيرًا، يصبح تقسيمه حسب scope أو نوعه (عام، Tenant) خيارًا أنظف، فكل قسم يملك فهرسه الخاص.
وكيف تعرف أنك تعاني من هذه المشكلة؟ راقب نسبة الـ Miss لكل Tenant. إذا كانت أعلى بشكل واضح لدى الـ Tenants الصغار مقارنة بالكبار، فهذا عرَضها المميز.
14. سياق المحادثة
كل ما سبق يفترض أن كل سؤال مستقل بذاته. لكن في محادثة حقيقية:
المستخدم: ما الفرق بين الخطة الأساسية والاحترافية؟
المساعد: ...
المستخدم: وكم سعرها؟«وكم سعرها؟» لا معنى لها دون ما قبلها. إذا خُزّنت ثم طوبقت مع «وكم سعرها؟» في محادثة أخرى عن منتج مختلف، فالإجابة خاطئة حتمًا. أمامك ثلاثة خيارات، من الأبسط إلى الأكثر دقة:
- فعّل الـ Semantic Cache على الرسالة الأولى من المحادثة فقط، وهي غالبًا الأكثر تكرارًا.
- أعد صياغة السؤال إلى صيغة مستقلة (Standalone Question) بنموذج صغير رخيص قبل توليد الـ embedding، فتصبح «كم سعر الخطة الاحترافية؟».
- أدخل hash لموضوع المحادثة في الـ scope.
$policy = $conversation->hasPriorTurns()
? CachePolicy::account() // غير قابل للتخزين
: CachePolicy::faq();15. تسميم الـ Cache وماذا يُسمح بتخزينه
في النطاق العام، أول مستخدم يسأل يحدد الإجابة التي سيراها كل من بعده. وهذا يفتح بابين للخطر.
الأول متعمَّد: مهاجم يصوغ سؤالًا قريبًا دلاليًا من سؤال شائع، مع تعليمات خفية تدفع النموذج لإجابة مضللة أو تكشف معلومات داخلية. إذا خُزّنت الإجابة، انتشرت على كل من يسأل سؤالًا مشابهًا.
الثاني غير مقصود: مستخدم يكتب «أنا محمد، طلبي رقم 5832 لم يصل، كيف أسترد أموالي؟» فتتضمن الإجابة اسمه ورقم طلبه، ثم تُقدَّم لمستخدم آخر يسأل عن الاسترداد.
الحل الأنضج للنطاق العام أن يُبنى الـ cache من مصادر موثوقة لا من مدخلات المستخدمين: أسئلة FAQ معتمدة تُجهَّز مسبقًا (انظر القسم 20)، أو إجابات مُراجَعة. وإن سمحت بالتخزين من مدخلات المستخدمين، فاجعل isSafeToStore() فحصًا حقيقيًا يرفض الإجابات التي تحتوي بيانات شخصية أو تبدو رفضًا أو خروجًا عن الموضوع.
تحذير: لا تبنِ قرار التخزين على «درجة ثقة» يعيدها الـ LLM. نماذج اللغة لا تعيد درجة ثقة معايرة يمكن الاعتماد عليها كعتبة. استخدم إشارات حقيقية: هل نجحت أدوات الاسترجاع؟ هل أعاد الـ Agent إجابة بنيوية مكتملة؟ هل اجتازت فحص بيانات شخصية؟
16. اختيار عتبة التشابه بشكل علمي
القيمة 0.95 ليست رقمًا سحريًا. توزيع درجات التشابه يختلف بين نماذج الـ embedding وبين اللغات وبين المجالات. والخطأ في الاتجاهين له ثمن:
- عتبة منخفضة جدًا تنتج Wrong Hit: «كيف ألغي اشتراكي؟» تُطابَق مع «كيف أغيّر خطة الاشتراك؟» فيحصل المستخدم على إجابة لسؤال لم يسأله.
- عتبة مرتفعة جدًا تنتج False Miss: «كيف ألغي اشتراكي؟» و«ما طريقة إلغاء الاشتراك؟» لا تتطابقان، فتدفع ثمن LLM بلا داعٍ.
والخطأ الأول أسوأ بكثير من الثاني: الـ Miss يكلّفك مالًا، أما الـ Wrong Hit فيكلّفك ثقة المستخدم.
الطريقة الصحيحة أن تبني مجموعة تقييم صغيرة من أزواج أسئلة حقيقية، وتصنّف كل زوج يدويًا: هل لهما القصد نفسه؟
كيف ألغي اشتراكي؟ | ما طريقة إيقاف الاشتراك؟ | YES
كيف ألغي اشتراكي؟ | كيف أغيّر البريد الإلكتروني؟ | NO
كيف أسترد أموالي؟ | كيف أحصل على refund؟ | YES
كيف ألغي اشتراكي؟ | كيف أغيّر خطة الاشتراك؟ | NOثم احسب التشابه لكل زوج، وجرّب عتبات مختلفة:
function cosine(array $a, array $b): float
{
$dot = $na = $nb = 0.0;
foreach ($a as $i => $v) {
$dot += $v * $b[$i];
$na += $v * $v;
$nb += $b[$i] * $b[$i];
}
return $dot / (sqrt($na) * sqrt($nb));
}
foreach ([0.85, 0.88, 0.90, 0.92, 0.94, 0.96, 0.98] as $threshold) {
$tp = $fp = $fn = 0;
foreach ($pairs as $pair) {
$predicted = cosine($pair['a_vec'], $pair['b_vec']) >= $threshold;
match (true) {
$predicted && $pair['same'] => $tp++,
$predicted && ! $pair['same'] => $fp++,
! $predicted && $pair['same'] => $fn++,
default => null,
};
}
$precision = $tp / max($tp + $fp, 1);
$recall = $tp / max($tp + $fn, 1);
echo "{$threshold}: precision={$precision} recall={$recall}\n";
}اختر أدنى عتبة تحقق Precision مقبولًا لديك (مثلًا 99% في الدعم العام). ثم بعد الإطلاق، استخدم جدول semantic_cache_matches: خذ عينة أسبوعية من المطابقات، راجعها يدويًا واملأ عمود is_correct. ستكتشف مثلًا أن نطاق 0.95 إلى 0.96 ينتج أخطاء أكثر مما توقعت، بناءً على بيانات حقيقية لا تخمين.
الـ Reranker للأنظمة الحساسة
إذا كانت تكلفة الخطأ مرتفعة، يمكن جلب أقرب خمسة مرشحين ثم تمريرهم على نموذج Reranking يعيد ترتيبهم حسب الصلة الفعلية بالسؤال، وهي قدرة يوفرها Laravel AI SDK. هذا يضيف زمنًا وتكلفة، لكنه أدق بكثير من ترك العتبة وحدها مسؤولة عن القرار.
17. الإصدارات بدل الحذف
في الـ Semantic Cache أسباب كثيرة لانتهاء صلاحية الإجابة، وانتهاء الـ TTL واحد منها فقط: تغيّر الـ prompt، أو النموذج، أو قاعدة المعرفة، أو سياسة العميل. محاولة حذف السجلات المتأثرة يدويًا عند كل تغيير هشّة وبطيئة.
الحل أن يكون كل مكوّن مؤثر في الإجابة جزءًا من السياق. عند تغيير الـ system prompt، ترفع customer-support-v4 إلى v5. وعند تحديث سياسة الشركة، ترفع knowledge_version. في اللحظة نفسها تصبح كل السجلات القديمة غير قابلة للمطابقة منطقيًا، دون حذف سجل واحد، ثم يتولى التنظيف الدوري إزالتها لاحقًا.
ولأن مفتاح الـ Exact Cache مشتق من fingerprint() نفسها، فهو يُبطَل تلقائيًا بالآلية ذاتها.
تغيير نموذج الـ Embedding
هذه حالة خاصة تستحق انتباهًا. كل نموذج embedding ينتج فضاءً رياضيًا خاصًا به، ومقارنة متجه من نموذج بمتجه من نموذج آخر تعطي أرقامًا بلا معنى، حتى لو تساوى عدد الأبعاد. لذلك:
- سمِّ الإصدار صراحة، مثل
openai:text-embedding-3-small:v1. - عند التغيير، إما أن تكتب بالنموذجين معًا لفترة انتقالية (Dual Write)، أو تعيد بناء كل المتجهات بالنموذج الجديد قبل التحويل.
- إذا اختلف عدد الأبعاد، فأنت تحتاج عمودًا أو جدولًا جديدًا، لأن عمود
vector(1536)لا يقبل متجهًا بطول مختلف.
18. خزّن القصد لا النص
في الأنظمة التي تحتوي منطق عمل فعليًا، الأفضل غالبًا ألا تخزّن نص الـ LLM الخام، بل نتيجة منظمة تمثل قصد المستخدم:
{
"intent": "cancel_subscription",
"requires_confirmation": true
}ثم يتولى التطبيق صياغة الرد من البيانات الحالية:
User Question
↓
Semantic Match
↓
intent = cancel_subscription
↓
Load current subscription
↓
Authorization + Business rules
↓
Generate responseبهذا يتحول الـ Semantic Cache من مصدر للإجابة إلى طبقة فهم (Semantic Routing)، والإجابة نفسها تبقى حديثة دائمًا لأنها تُبنى من حالة قاعدة البيانات الآن. وهنا تحديدًا يصبح التطابق عبر اللغات ميزة حقيقية: «How do I cancel?» و«كيف ألغي اشتراكي؟» لهما القصد نفسه، وكل منهما يحصل على رد بلغته. أما في Response Cache فيبقى شرط تطابق الـ locale ضروريًا، وإلا أعدت إجابة عربية لمستخدم إنجليزي.
الجزء الرابع: التشغيل
19. التزامن و Cache Stampede
عندما تنتهي صلاحية سجل شائع ويصل ألف طلب في اللحظة نفسها، قد يرى الجميع Cache Miss ويستدعي كل منهم الـ LLM. هذا هو Cache Stampede أو Thundering Herd.
الكود في القسم 10 يعالجه بثلاثة عناصر:
block()بدلget()، فالطلب الذي لم يحصل على القفل ينتظر بدل أن يتجاهل الحالة.- إعادة فحص الـ cache بعد الحصول على القفل، لأن طلبًا آخر ربما أنشأ الإجابة أثناء الانتظار.
- التعامل مع
LockTimeoutExceptionبالإجابة دون تخزين، بدل إرجاع خطأ للمستخدم.
ويجب الاعتراف بحدود هذا الحل: مفتاح القفل مشتق من النص المطبَّع، فالصياغات المختلفة للسؤال نفسه لا تتشارك القفل. عشرة مستخدمين يكتبون عشر صياغات في اللحظة نفسها قد يولّدون عشر إجابات. هذا مقبول عادةً، لأن الـ stampede الحقيقي يحدث غالبًا مع السؤال الشائع بصيغته الأكثر تكرارًا، والقيد الفريد على question_hash يمنع تكرار السجل نفسه.
20. التحضير المسبق (Prewarming)
إذا كنت تعرف أسئلتك الشائعة مسبقًا، فلا تنتظر أن يسألها المستخدمون. جهّزها دفعة واحدة عبر Queue، باستدعاء API واحد لكل مجموعة بدل استدعاء لكل سؤال. وهذا أيضًا الحل الأنظف لمشكلة التسميم في النطاق العام، لأن الإجابات تأتي من مصدر معتمد:
namespace App\Jobs;
use App\SemanticCache\CacheContext;
use App\SemanticCache\QuestionNormalizer;
use App\SemanticCache\SemanticCacheService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Laravel\Ai\Embeddings;
class PrewarmFaqCache implements ShouldQueue
{
use Queueable;
/** @param array<int, array{question: string, answer: string}> $faqs */
public function __construct(
public array $faqs,
public CacheContext $context,
) {}
public function handle(
QuestionNormalizer $normalizer,
SemanticCacheService $cache,
): void {
$questions = array_map(
fn ($faq) => $normalizer->normalize($faq['question']),
$this->faqs
);
$response = Embeddings::for($questions)->generate();
foreach ($response->embeddings as $i => $embedding) {
$cache->store(
$questions[$i],
$embedding,
$this->faqs[$i]['answer'],
$this->context,
now()->addDays(7),
);
}
}
}استخدم الـ Queue للتحضير المسبق فقط. المسار التفاعلي يبقى متزامنًا، فالمستخدم الذي ينتظر إجابة لا ينتظر Queue.
21. المراقبة
نسبة الـ Hit وحدها رقم مضلل. نظام بنسبة Hit تبلغ 70% ونسبة Wrong Hit تبلغ 5% يبدو ناجحًا في لوحة المقاييس، لكنه يعطي إجابة خاطئة لواحد من كل عشرين مستخدمًا ممن خدمهم الـ cache. راقب على الأقل:
- نسبة الـ Hit لكل طبقة (Exact وSemantic) ولكل نطاق.
- نسبة الـ Wrong Hit، من المراجعة الدورية لجدول
semantic_cache_matches. - زمن كل مرحلة: الـ embedding، والبحث المتجهي، والـ LLM.
- عدد طلبات الـ LLM الموفّرة والتكلفة التقديرية الموفّرة.
- توزيع درجات التشابه للمطابقات المقبولة، لاكتشاف أي انزياح بعد تغيير نموذج أو prompt.
وسجّل لكل مطابقة السؤال الوارد والسؤال المطابَق ودرجة التشابه. هذا ما يحوّل النظام من صندوق أسود إلى شيء يمكن فهمه وتحسينه:
incoming: "بدي ألغي الاشتراك"
matched: "كيف يمكنني إلغاء اشتراكي؟"
similarity: 0.9732122. التنظيف ونمو الجدول
الإصدارات تجعل السجلات القديمة غير قابلة للمطابقة، لكنها لا تحذفها. ومع الوقت سيكبر الجدول ويبطؤ الفهرس. لأن الـ Model يستخدم MassPrunable، يكفي جدولة أمر التنظيف المدمج في Laravel، وهو يحذف على دفعات دون تحميل السجلات إلى الذاكرة:
// routes/console.php
use App\Models\SemanticCacheEntry;
use Illuminate\Support\Facades\Schedule;
Schedule::command('model:prune', [
'--model' => [SemanticCacheEntry::class],
])->hourly();ويمكنك توسيع prunable() ليشمل السجلات ذات الإصدارات القديمة أيضًا. والأهم من التنظيف هو الانتقائية: لا تخزّن كل سؤال. الـ cache الذي يخزّن كل شيء يتضخم بسجلات لن تُطابَق أبدًا، ويرفع احتمال الـ Wrong Hit.
23. الاختبار
أهم اختبار في النظام كله هو اختبار حدود الصلاحية، ويجب أن يكون آليًا ودائمًا. هذا الاختبار يدخل المتجهات مباشرة بدل توليدها، فيصبح حتميًا لا يعتمد على مزوّد خارجي. ويتطلب قاعدة بيانات اختبار PostgreSQL مفعّل فيها pgvector:
use App\SemanticCache\CacheContext;
use App\SemanticCache\SemanticCacheService;
function contextFor(string $scope): CacheContext
{
return new CacheContext(
scope: $scope,
locale: 'ar',
embeddingVersion: 'test:v1',
llmModel: 'test-model',
promptVersion: 'v1',
knowledgeVersion: 'v1',
);
}
test('cache entry never crosses tenant boundary', function () {
$service = app(SemanticCacheService::class);
$embedding = array_fill(0, 1536, 0.01);
$service->store('ما رصيد العميل أحمد؟', $embedding, 'رصيد سري', contextFor('tenant:1'), now()->addHour());
expect($service->lookup($embedding, contextFor('tenant:2'), 0.5))->toBeNull();
expect($service->lookup($embedding, contextFor('tenant:1'), 0.5))->not->toBeNull();
});
test('expired entries are never returned', function () {
$service = app(SemanticCacheService::class);
$embedding = array_fill(0, 1536, 0.01);
$service->store('سؤال', $embedding, 'إجابة', contextFor('global'), now()->subMinute());
expect($service->lookup($embedding, contextFor('global'), 0.5))->toBeNull();
});وبالأسلوب نفسه، تأكد من تغطية الحالات التالية: Exact Hit، وSemantic Hit، وSemantic Miss، وكل بُعد من أبعاد السياق على حدة (locale، وprompt version، وknowledge version، وembedding version)، وفشل الـ LLM، وانتهاء مهلة القفل، والطلبات المتزامنة. وللاختبارات التي تمر عبر توليد الـ embeddings، يوفر الـ SDK Embeddings::fake().
24. نموذج التكلفة
المعادلة الكاملة ليست تكلفة الـ LLM وحدها:
Total Cost = Embedding Cost
+ (1 - Hit Rate) × LLM Cost
+ Vector DB + Cache Infrastructureلنأخذ مثالًا بأرقام افتراضية. لنفترض أن طلب الـ LLM الكامل يكلّف 100 وحدة، وأن الـ embedding والبحث المتجهي معًا يكلّفان وحدة واحدة لكل طلب. دون cache تدفع 100 وحدة لكل طلب. ومع نسبة Hit قدرها h تدفع:
1 + (1 - h) × 100نقطة التعادل هنا عند h = 1%: أي نسبة Hit فوقها تحقق توفيرًا. وعند نسبة Hit تبلغ 40% تدفع 61 وحدة بدل 100، أي توفير 39%. والـ Exact Cache يرفع التوفير أكثر، لأنه يتخطى حتى تكلفة الـ embedding للأسئلة المتكررة حرفيًا.
استبدل هذه الأرقام بأسعار مزوّدك الفعلية ومتوسط طول إجاباتك. الخلاصة العامة أن الـ Semantic Cache مجدٍ اقتصاديًا في معظم حالات الدعم والـ FAQ، لأن الـ embedding أرخص بكثير من توليد إجابة كاملة.
وعلى مستوى الزمن، قد يستغرق طلب الـ LLM عدة ثوانٍ، بينما يستغرق الـ Exact Hit أجزاء من الميلي ثانية، والـ Semantic Hit زمن توليد الـ embedding مضافًا إليه زمن البحث. لذلك فإن الـ Embedding Cache والـ Exact Cache ليسا تفصيلًا ثانويًا، بل هما ما يجعل مسار الـ Hit سريعًا فعلًا.
الخلاصة
الـ Semantic Cache ليس مجرد Cache + Embeddings، بل نظام قرار كامل. التشابه الدلالي يعطيك مرشحًا، والباقي هو ما يحدد إن كان هذا المرشح صالحًا وآمنًا.
قبل الإطلاق في الإنتاج، تأكد من التالي:
- كل فلاتر الصلاحية والسياق داخل الاستعلام نفسه، ومغطاة باختبار آلي.
- كل مكوّن يؤثر في الإجابة له إصدار صريح ضمن السياق.
- قرار التخزين مبني على نوع الاستخدام، والبيانات اللحظية والشخصية مستثناة.
- عتبة التشابه مقيسة على بياناتك، لا منقولة من مقال.
- فهرس HNSW يعمل فعلًا مع فلاتر النطاق، وiterative scan مفعّل إن لزم.
- الأسئلة التابعة في المحادثة لا تُطابَق دون سياقها.
- النطاق العام مبني من مصادر موثوقة أو محمي بفحص قبل التخزين.
- القفل يحمي من الـ stampede، ويتعامل مع انتهاء المهلة.
- المطابقات مسجّلة، وعينة منها تُراجَع دوريًا.
- التنظيف الدوري مجدول.
يجعل Laravel AI SDK الجزء الخاص بالـ embeddings والـ vector search قريبًا من أسلوب Laravel المعتاد. أما الـ Semantic Response Cache نفسه فهو نمط معماري تبنيه أنت فوق ذلك. وعندما يُبنى بالشكل الصحيح، لا يوفر المال فقط، بل يجعل نظامك أسرع، وأقدر على التوسع، وأسهل في الفهم والمراقبة.
التعليقات (0)
لا توجد تعليقات بعد — كن أول من يشارك رأيه.
أضف تعليقك