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

تجاوز تكلفة محركات البحث الخارجية. يأتي 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 | أقل من 1ms | 2ms |
| 100,000 | 2-5ms | 150ms |
| 1,000,000 | 5-15ms | 1,500ms |
| 10,000,000 | 15-50ms | 15,000ms |
نصائح لمجموعات البيانات الكبيرة
- قلّل إنشاء العناوين —
ts_headlineمكلفة. احسبها فقط للصفحة النهائية من النتائج - تجسيد الـ tsvector — نهج
GENERATED ALWAYS AS ... STOREDيفعل ذلك تلقائياً - الفهارس الجزئية — إذا كنت تبحث فقط في المقالات المنشورة، أضف شرط
WHEREللفهرس - تجميع الاتصالات — استخدم 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 إليه.
ناقش مشروعك معنا
نحن هنا للمساعدة في احتياجات تطوير الويب الخاصة بك. حدد موعدًا لمناقشة مشروعك وكيف يمكننا مساعدتك.
دعنا نجد أفضل الحلول لاحتياجاتك.
مقالات ذات صلة

بناء تطبيق متكامل باستخدام Drizzle ORM و Next.js 15: قاعدة بيانات آمنة الأنواع من الصفر إلى الإنتاج
تعلّم كيفية بناء تطبيق متكامل آمن الأنواع باستخدام Drizzle ORM مع Next.js 15. يغطي هذا الدليل العملي تصميم المخططات والترحيلات وServer Actions وعمليات CRUD والنشر مع PostgreSQL.

Zustand + Next.js App Router: إدارة حالة React الحديثة من الصفر إلى الإنتاج
أتقن إدارة حالة React الحديثة مع Zustand و Next.js 15 App Router. يغطي هذا الدليل العملي إنشاء المتاجر والوسائط والاستمرارية والترطيب من جانب الخادم وأنماط التطبيقات القابلة للتوسع.

بناء وكيل ذكاء اصطناعي مستقل باستخدام Agentic RAG و Next.js
تعلم كيف تبني وكيل ذكاء اصطناعي يقرر بشكل مستقل متى وكيف يسترجع المعلومات من قواعد البيانات المتجهية. دليل عملي شامل باستخدام Vercel AI SDK و Next.js مع أمثلة قابلة للتنفيذ.