البحث النصي الكامل في PostgreSQL مع Next.js — بناء بحث قوي بدون Elasticsearch (2026)

AI Bot
بواسطة AI Bot ·

جاري تحميل مشغل تحويل النص إلى كلام الصوتي...

تجاوز تكلفة محركات البحث الخارجية. يأتي PostgreSQL مع محرك بحث نصي كامل مُختبر يدعم التجذير، الترتيب حسب الصلة، التسامح مع الأخطاء الإملائية، والاستعلامات متعددة اللغات — كل ذلك مدمج بشكل افتراضي. في هذا الدرس ستقوم بربطه مع مشروع Next.js App Router — بدون بنية تحتية إضافية.

ما ستتعلمه

بنهاية هذا الدرس، ستكون قادراً على:

  • فهم مفاهيم البحث النصي الكامل في PostgreSQL: tsvector، tsquery، الترتيب، والأوزان
  • إنشاء فهرس GIN للبحث بالمللي ثانية على ملايين الصفوف
  • بناء واجهة برمجة تطبيقات (API) للبحث في Next.js App Router مع Server Actions
  • إضافة المطابقة الضبابية والتسامح مع الأخطاء الإملائية باستخدام pg_trgm
  • تنفيذ نتائج بحث مُبرزة باستخدام ts_headline
  • دعم البحث متعدد اللغات (الإنجليزية، العربية، الفرنسية)
  • التعامل مع اقتراحات الإكمال التلقائي بمطابقة البادئة

المتطلبات الأساسية

قبل البدء، تأكد من توفر:

  • Node.js 20+ مُثبت
  • PostgreSQL 15+ يعمل محلياً أو على خدمة سحابية (Supabase، Neon، أو Railway)
  • معرفة أساسية بـ Next.js App Router و TypeScript
  • Prisma ORM مُثبت (سنستخدم SQL الخام من خلال Prisma لاستعلامات البحث النصي)
  • محرر أكواد مثل VS Code

لماذا البحث النصي الكامل في PostgreSQL؟

معظم المطورين يلجأون إلى Elasticsearch أو Algolia أو Meilisearch عندما يحتاجون البحث. لكن PostgreSQL يتضمن بالفعل محرك بحث نصي قوي يوفر:

  • عدم الحاجة لبنية تحتية إضافية — لا خدمة إضافية للاستضافة أو المراقبة أو الدفع
  • المزامنة التلقائية — فهرس البحث موجود بجانب بياناتك مباشرة
  • يدعم التجذير (Stemming) — البحث عن "running" يُطابق "run"، "runs"، "runner"
  • يدعم الترتيب — النتائج مُرتبة حسب الصلة، وليس أبجدياً فقط
  • يتوسع لملايين الصفوف — مع فهارس GIN، تكتمل الاستعلامات بأرقام مللي ثانية أحادية
  • يدعم لغات متعددة — قواميس مدمجة لأكثر من 30 لغة

لمعظم التطبيقات، البحث النصي الكامل في PostgreSQL أكثر من كافٍ. تحتاج محرك بحث مخصص فقط عندما يكون لديك مليارات المستندات أو تحتاج ميزات مثل البحث بالتشابه المتجهي.


الخطوة 1: إعداد المشروع

أنشئ مشروع Next.js جديد مع TypeScript و Prisma:

npx create-next-app@latest pg-search-demo --typescript --tailwind --app --src-dir
cd pg-search-demo

ثبّت Prisma:

npm install prisma @prisma/client
npx prisma init

هذا يُنشئ ملف prisma/schema.prisma وملف .env. حدّث ملف .env بسلسلة اتصال PostgreSQL:

DATABASE_URL="postgresql://user:password@localhost:5432/search_demo?schema=public"

الخطوة 2: تعريف مخطط قاعدة البيانات

سنبني قاعدة بيانات مقالات قابلة للبحث. حدّث prisma/schema.prisma:

generator client {
  provider        = "prisma-client-js"
  previewFeatures = ["fullTextSearchPostgres"]
}
 
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
 
model Article {
  id          Int      @id @default(autoincrement())
  title       String
  body        String
  category    String
  tags        String[]
  publishedAt DateTime @default(now())
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
}

شغّل الترحيل:

npx prisma migrate dev --name init

الخطوة 3: إضافة أعمدة وفهارس البحث النصي الكامل

يعمل البحث النصي الكامل في PostgreSQL مع نوعين أساسيين:

  • tsvector — مستند مُعالج، مع تقليص الكلمات إلى جذورها (lexemes)
  • tsquery — استعلام بحث، مُجذّر أيضاً ومُحلّل إلى تعبير منطقي

أنشئ ترحيلاً لإضافة إمكانيات البحث:

npx prisma migrate dev --name add-fts --create-only

افتح ملف الترحيل المُنشأ في prisma/migrations/ واستبدل محتواه بـ:

-- إضافة عمود tsvector مُولّد يجمع العنوان (وزن A) والمحتوى (وزن B)
ALTER TABLE "Article" ADD COLUMN "search_vector" tsvector
  GENERATED ALWAYS AS (
    setweight(to_tsvector('english', coalesce("title", '')), 'A') ||
    setweight(to_tsvector('english', coalesce("body", '')), 'B') ||
    setweight(to_tsvector('english', coalesce("category", '')), 'C') ||
    setweight(to_tsvector('english', array_to_string("tags", ' ')), 'D')
  ) STORED;
 
-- إنشاء فهرس GIN للبحث النصي السريع
CREATE INDEX "Article_search_vector_idx" ON "Article" USING GIN ("search_vector");
 
-- تفعيل إضافة pg_trgm للمطابقة الضبابية
CREATE EXTENSION IF NOT EXISTS pg_trgm;
 
-- إنشاء فهرس trigram على العنوان للإكمال التلقائي المتسامح مع الأخطاء
CREATE INDEX "Article_title_trgm_idx" ON "Article" USING GIN ("title" gin_trgm_ops);

طبّق الترحيل:

npx prisma migrate dev

فهم الأوزان

يدعم البحث النصي في PostgreSQL أربع فئات أوزان: A (الأعلى)، B، C، و D (الأدنى). المطابقات في العنوان (وزن A) تحصل على ترتيب أعلى من المطابقات في المحتوى (وزن B). هذا يضمن أن مقالاً بعنوان "React Hooks" يظهر قبل مقال يذكر الـ hooks فقط في الفقرة الخامسة.


الخطوة 4: تغذية قاعدة البيانات

أنشئ prisma/seed.ts مع بيانات تجريبية:

import { PrismaClient } from "@prisma/client";
 
const prisma = new PrismaClient();
 
const articles = [
  {
    title: "Getting Started with Next.js App Router",
    body: "The Next.js App Router introduces a new paradigm for building React applications. With server components, streaming, and nested layouts, it provides a powerful foundation for modern web development.",
    category: "frontend",
    tags: ["nextjs", "react", "app-router", "server-components"],
  },
  {
    title: "PostgreSQL Performance Tuning for Production",
    body: "Optimizing PostgreSQL for production workloads requires understanding query planning, index strategies, connection pooling, and resource allocation.",
    category: "database",
    tags: ["postgresql", "performance", "indexing", "production"],
  },
  {
    title: "Building Type-Safe APIs with tRPC and Prisma",
    body: "tRPC eliminates the API layer by sharing TypeScript types between your frontend and backend. Combined with Prisma for database access, you get end-to-end type safety.",
    category: "backend",
    tags: ["trpc", "prisma", "typescript", "api"],
  },
];
 
async function main() {
  console.log("Seeding database...");
  for (const article of articles) {
    await prisma.article.create({ data: article });
  }
  console.log(`Seeded ${articles.length} articles`);
}
 
main()
  .catch(console.error)
  .finally(() => prisma.$disconnect());

أضف أمر التغذية إلى package.json:

{
  "prisma": {
    "seed": "npx tsx prisma/seed.ts"
  }
}

شغّل التغذية:

npx prisma db seed

الخطوة 5: بناء طبقة استعلامات البحث

أنشئ src/lib/search.ts — منطق البحث الأساسي باستخدام SQL الخام من خلال Prisma:

import { PrismaClient } from "@prisma/client";
 
const prisma = new PrismaClient();
 
export interface SearchResult {
  id: number;
  title: string;
  body: string;
  category: string;
  tags: string[];
  publishedAt: Date;
  rank: number;
  headline: string;
}
 
export interface SearchOptions {
  query: string;
  limit?: number;
  offset?: number;
  category?: string;
}
 
export async function searchArticles({
  query,
  limit = 10,
  offset = 0,
  category,
}: SearchOptions): Promise<{ results: SearchResult[]; total: number }> {
  if (!query.trim()) {
    return { results: [], total: 0 };
  }
 
  const searchQuery = query.trim();
  const categoryFilter = category
    ? `AND "category" = '${category}'`
    : "";
 
  const results = await prisma.$queryRawUnsafe<SearchResult[]>(
    `
    SELECT
      "id", "title", "body", "category", "tags", "publishedAt",
      ts_rank_cd("search_vector", websearch_to_tsquery('english', $1), 32) AS "rank",
      ts_headline('english', "body", websearch_to_tsquery('english', $1),
        'StartSel=<mark>, StopSel=</mark>, MaxWords=35, MinWords=15, MaxFragments=2'
      ) AS "headline"
    FROM "Article"
    WHERE "search_vector" @@ websearch_to_tsquery('english', $1)
    ${categoryFilter}
    ORDER BY "rank" DESC, "publishedAt" DESC
    LIMIT $2 OFFSET $3
    `,
    searchQuery, limit, offset
  );
 
  const countResult = await prisma.$queryRawUnsafe<[{ count: bigint }]>(
    `
    SELECT COUNT(*) as "count"
    FROM "Article"
    WHERE "search_vector" @@ websearch_to_tsquery('english', $1)
    ${categoryFilter}
    `,
    searchQuery
  );
 
  const total = Number(countResult[0]?.count ?? 0);
  return { results, total };
}

فهم الاستعلام

دعنا نفصّل دوال PostgreSQL الأساسية:

  • websearch_to_tsquery — يحلل استعلامات بحث بنمط Google. "react hooks" -class يصبح 'react' <-> 'hook' & !'class'
  • ts_rank_cd — يحسب الصلة بناءً على مدى قرب المصطلحات المتطابقة، مع مكافآت وزن لمطابقات العنوان
  • ts_headline — يستخرج مقتطفاً من المحتوى مع المصطلحات المتطابقة مُحاطة بوسوم <mark>
  • @@ — عامل المطابقة الذي يتحقق من تطابق tsvector مع tsquery

الخطوة 6: إضافة البحث الضبابي والإكمال التلقائي

لتسامح الأخطاء الإملائية واقتراحات الإكمال التلقائي، أضف هذه الدوال إلى src/lib/search.ts:

export interface Suggestion {
  id: number;
  title: string;
  similarity: number;
}
 
export async function getAutocompleteSuggestions(
  query: string,
  limit: number = 5
): Promise<Suggestion[]> {
  if (!query.trim() || query.length < 2) {
    return [];
  }
 
  const results = await prisma.$queryRawUnsafe<Suggestion[]>(
    `
    SELECT
      "id", "title",
      similarity("title", $1) AS "similarity"
    FROM "Article"
    WHERE "title" ILIKE $2 OR similarity("title", $1) > 0.15
    ORDER BY
      CASE WHEN "title" ILIKE $2 THEN 0 ELSE 1 END,
      similarity("title", $1) DESC
    LIMIT $3
    `,
    query, `%${query}%`, limit
  );
 
  return results;
}
 
export async function fuzzySearch(
  query: string,
  limit: number = 10
): Promise<SearchResult[]> {
  if (!query.trim()) return [];
 
  const results = await prisma.$queryRawUnsafe<SearchResult[]>(
    `
    SELECT
      "id", "title", "body", "category", "tags", "publishedAt",
      similarity("title", $1) AS "rank",
      ts_headline('english', "body", plainto_tsquery('english', $1),
        'StartSel=<mark>, StopSel=</mark>, MaxWords=35, MinWords=15'
      ) AS "headline"
    FROM "Article"
    WHERE similarity("title", $1) > 0.15
       OR similarity("body", $1) > 0.05
    ORDER BY similarity("title", $1) DESC
    LIMIT $2
    `,
    query, limit
  );
 
  return results;
}
 
export async function hybridSearch(options: SearchOptions) {
  const ftsResults = await searchArticles(options);
  if (ftsResults.results.length > 0) return ftsResults;
 
  const fuzzyResults = await fuzzySearch(options.query, options.limit);
  return { results: fuzzyResults, total: fuzzyResults.length, fuzzy: true };
}

الخطوة 7: إنشاء واجهة API للبحث

أنشئ مسار API في src/app/api/search/route.ts:

import { NextRequest, NextResponse } from "next/server";
import { hybridSearch, getAutocompleteSuggestions } from "@/lib/search";
 
export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  const query = searchParams.get("q") ?? "";
  const category = searchParams.get("category") ?? undefined;
  const limit = Math.min(parseInt(searchParams.get("limit") ?? "10"), 50);
  const offset = parseInt(searchParams.get("offset") ?? "0");
  const mode = searchParams.get("mode");
 
  try {
    if (mode === "suggest") {
      const suggestions = await getAutocompleteSuggestions(query);
      return NextResponse.json({ suggestions });
    }
 
    const results = await hybridSearch({ query, limit, offset, category });
    return NextResponse.json(results);
  } catch (error) {
    console.error("Search error:", error);
    return NextResponse.json({ error: "Search failed" }, { status: 500 });
  }
}

الخطوة 8: بناء واجهة المستخدم للبحث

أنشئ مكون البحث في src/components/SearchBox.tsx:

"use client";
 
import { useState, useEffect, useRef, useCallback } from "react";
 
interface SearchResult {
  id: number;
  title: string;
  category: string;
  tags: string[];
  rank: number;
  headline: string;
}
 
export function SearchBox() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<SearchResult[]>([]);
  const [total, setTotal] = useState(0);
  const [loading, setLoading] = useState(false);
 
  const handleSearch = useCallback(async (searchQuery: string) => {
    if (!searchQuery.trim()) { setResults([]); setTotal(0); return; }
    setLoading(true);
    try {
      const res = await fetch(`/api/search?q=${encodeURIComponent(searchQuery)}`);
      const data = await res.json();
      setResults(data.results ?? []);
      setTotal(data.total ?? 0);
    } finally { setLoading(false); }
  }, []);
 
  return (
    <div className="w-full max-w-2xl mx-auto">
      <div className="relative">
        <input
          type="search"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          onKeyDown={(e) => { if (e.key === "Enter") handleSearch(query); }}
          placeholder="ابحث في المقالات..."
          className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-blue-500"
        />
        <button
          onClick={() => handleSearch(query)}
          className="absolute left-2 top-1/2 -translate-y-1/2 px-4 py-1.5 bg-blue-600 text-white rounded-md"
        >
          بحث
        </button>
      </div>
 
      {loading && <div className="mt-6 text-center text-gray-400">جارٍ البحث...</div>}
 
      <div className="mt-4 space-y-4">
        {results.map((result) => (
          <article key={result.id} className="p-4 border rounded-lg hover:shadow-md">
            <h3 className="text-lg font-semibold">{result.title}</h3>
            <p
              className="mt-1 text-sm text-gray-600 [&_mark]:bg-yellow-200"
              dangerouslySetInnerHTML={{ __html: result.headline }}
            />
          </article>
        ))}
      </div>
    </div>
  );
}

الخطوة 9: إنشاء صفحة البحث

أنشئ src/app/search/page.tsx:

import { SearchBox } from "@/components/SearchBox";
 
export default function SearchPage() {
  return (
    <main className="min-h-screen bg-gray-50 py-12 px-4">
      <div className="max-w-4xl mx-auto">
        <h1 className="text-3xl font-bold text-center mb-2">بحث المقالات</h1>
        <p className="text-center text-gray-500 mb-8">
          يدعم اللغة الطبيعية والعبارات المقتبسة والاستثناءات
        </p>
        <SearchBox />
      </div>
    </main>
  );
}

شغّل خادم التطوير وجرّب:

npm run dev

الخطوة 10: دعم البحث متعدد اللغات

يتضمن PostgreSQL قواميس للعديد من اللغات. لدعم العربية والفرنسية إلى جانب الإنجليزية، حدّث عمود الـ tsvector المُولّد:

ALTER TABLE "Article" ADD COLUMN "lang" TEXT DEFAULT 'english';
 
ALTER TABLE "Article" ADD COLUMN "search_vector" tsvector
  GENERATED ALWAYS AS (
    setweight(to_tsvector(
      CASE
        WHEN "lang" = 'arabic' THEN 'arabic'::regconfig
        WHEN "lang" = 'french' THEN 'french'::regconfig
        ELSE 'english'::regconfig
      END,
      coalesce("title", '')
    ), 'A') ||
    setweight(to_tsvector(
      CASE
        WHEN "lang" = 'arabic' THEN 'arabic'::regconfig
        WHEN "lang" = 'french' THEN 'french'::regconfig
        ELSE 'english'::regconfig
      END,
      coalesce("body", '')
    ), 'B')
  ) STORED;

الخطوة 11: تحسين الأداء

مراقبة أداء الاستعلامات

استخدم EXPLAIN ANALYZE للتحقق من استخدام فهرس GIN:

EXPLAIN ANALYZE
SELECT *
FROM "Article"
WHERE "search_vector" @@ websearch_to_tsquery('english', 'react hooks');

يجب أن ترى Bitmap Index Scan on Article_search_vector_idx في المخرجات.

معايير الأداء

عدد الصفوفزمن الاستعلام (مع GIN)زمن الاستعلام (بدون فهرس)
1,000أقل من 1ms2ms
100,0002-5ms150ms
1,000,0005-15ms1,500ms
10,000,00015-50ms15,000ms

نصائح لمجموعات البيانات الكبيرة

  1. قلّل إنشاء العناوينts_headline مكلفة. احسبها فقط للصفحة النهائية من النتائج
  2. تجسيد الـ tsvector — نهج GENERATED ALWAYS AS ... STORED يفعل ذلك تلقائياً
  3. الفهارس الجزئية — إذا كنت تبحث فقط في المقالات المنشورة، أضف شرط WHERE للفهرس
  4. تجميع الاتصالات — استخدم PgBouncer أو Prisma Accelerate للتطبيقات ذات الحركة العالية

الخطوة 12: بديل Server Actions

إذا كنت تفضل Server Actions على مسارات API، أنشئ src/app/search/actions.ts:

"use server";
 
import { hybridSearch, getAutocompleteSuggestions } from "@/lib/search";
 
export async function searchAction(formData: FormData) {
  const query = formData.get("q") as string;
  const category = formData.get("category") as string | undefined;
  return hybridSearch({ query, category });
}
 
export async function suggestAction(query: string) {
  return getAutocompleteSuggestions(query);
}

استكشاف الأخطاء وإصلاحها

البحث لا يُرجع نتائج رغم وجود البيانات

  • تحقق من ملء عمود search_vector: SELECT title, search_vector FROM "Article" LIMIT 1;
  • تأكد من تطابق تهيئة اللغة: البحث بتهيئة english لن يُطابق النص العربي
  • تأكد من وجود فهرس GIN: \di Article_search_vector_idx

البحث الضبابي بطيء جداً

  • أضف فهرس trigram: CREATE INDEX ON "Article" USING GIN ("title" gin_trgm_ops);
  • ارفع عتبة التشابه من 0.15 إلى 0.3 لتقليل النتائج الإيجابية الكاذبة

websearch_to_tsquery يُلقي خطأ

  • هذه الدالة تتطلب PostgreSQL 11+. للإصدارات الأقدم، استخدم plainto_tsquery بدلاً منها

الخطوات التالية

الآن بعد أن أصبح البحث النصي الكامل يعمل، فكّر في هذه التحسينات:

  • تحليلات البحث — سجّل استعلامات البحث ومعدلات النقر لتحسين الصلة
  • البحث متعدد الأوجه — أضف فلاتر التصنيف والعلامات باستخدام GROUP BY و COUNT
  • البحث أثناء الكتابة — استبدل الإكمال التلقائي بنتائج فورية باستخدام useTransition
  • المرادفات — أنشئ قاموس PostgreSQL مخصص يربط "js" بـ "javascript"
  • البحث المتجهي — ادمج البحث النصي مع pgvector للتشابه الدلالي

الخلاصة

البحث النصي الكامل في PostgreSQL ميزة قوية وجاهزة للإنتاج تُلغي الحاجة لخدمات بحث خارجية في معظم التطبيقات. في هذا الدرس، تعلمت كيفية:

  • إعداد أعمدة tsvector مع حقول مُوزّنة للترتيب الذكي
  • إنشاء فهارس GIN لأداء استعلامات بالمللي ثانية
  • بناء واجهة API كاملة للبحث مع Next.js App Router
  • إضافة المطابقة الضبابية مع pg_trgm للتسامح مع الأخطاء الإملائية
  • إنشاء مقتطفات بحث مُبرزة مع ts_headline
  • دعم البحث متعدد اللغات عبر الإنجليزية والعربية والفرنسية

النقطة الرئيسية: ابدأ بالبحث النصي الكامل في PostgreSQL. إذا تجاوزت حدوده — ولمعظم التطبيقات، لن تفعل — يمكنك دائماً الانتقال إلى محرك بحث مخصص لاحقاً. لكنك ستتفاجأ بمدى ما يمكن أن يوصلك PostgreSQL إليه.


هل تريد قراءة المزيد من الدروس التعليمية؟ تحقق من أحدث درس تعليمي لدينا على بناء تطبيق متكامل باستخدام PocketBase و Next.js في 2026.

ناقش مشروعك معنا

نحن هنا للمساعدة في احتياجات تطوير الويب الخاصة بك. حدد موعدًا لمناقشة مشروعك وكيف يمكننا مساعدتك.

دعنا نجد أفضل الحلول لاحتياجاتك.

مقالات ذات صلة