diff --git a/docs/plans/2026-01-25-ai-search-implementation.md b/docs/plans/2026-01-25-ai-search-implementation.md new file mode 100644 index 0000000..11f3bff --- /dev/null +++ b/docs/plans/2026-01-25-ai-search-implementation.md @@ -0,0 +1,1145 @@ +# AI 智能搜索系统实施计划 + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 为项目列表添加 AI 语义搜索功能,支持自然语言查询并返回语义相关的项目,所有 AI 逻辑在 n8n 中实现,Next.js 应用保持纯净。 + +**Architecture:** 使用 Neon PostgreSQL 的 pgvector 扩展存储项目向量,n8n 工作流处理向量化(定时任务)和 AI 搜索(Webhook),Next.js API 路由作为代理转发请求到 n8n,前端添加切换按钮和相似度展示。 + +**Tech Stack:** Neon PostgreSQL + pgvector, n8n, OpenAI Embeddings API (text-embedding-3-small), Next.js 15 App Router, Prisma ORM, TypeScript, Tailwind CSS + +--- + +## 前置准备 + +### Task 0: 验证环境和依赖 + +**Step 1: 验证 Neon 数据库 pgvector 支持** + +连接到 Neon 数据库(使用 Prisma Studio 或 psql): + +```bash +# 使用 psql 连接 +psql $DATABASE_URL + +# 或使用 Neon SQL Editor: https://console.neon.tech +``` + +执行 SQL: + +```sql +-- 检查 pgvector 扩展是否已安装 +SELECT * FROM pg_extension WHERE extname = 'vector'; + +-- 如果未安装,安装它 +CREATE EXTENSION IF NOT EXISTS vector; + +-- 验证安装 +SELECT vector_dims('[1,2,3]'::vector); +``` + +预期输出:`3` + +--- + +## Phase 1: 数据库迁移 + +### Task 1: 创建 Prisma Schema 迁移 + +**Files:** +- Modify: `prisma/schema.prisma` + +**Step 1: 添加向量字段到 Project 模型** + +在 `prisma/schema.prisma` 中找到 `model Project`,在字段列表末尾添加: + +```prisma +model Project { + // ... 现有字段(id, name, nameEn, slug, description, descriptionEn, content, contentEn, status, source, createdAt, updatedAt) + + // 新增:向量嵌入字段 + embedding vector(1536)? // pgvector 类型,1536维(OpenAI text-embedding-3-small) + embeddingUpdatedAt DateTime? // 记录向量化更新时间 +} +``` + +**Step 2: 创建迁移文件** + +```bash +# 生成迁移(会创建 SQL 文件) +pnpm prisma migrate dev --name add_project_embedding + +# 预期输出: +# ✓ The following migration has been created and applied from new schema changes: +# migrations/TIMESTAMP_add_project_embedding/migration.sql +``` + +**Step 3: 验证生成的 SQL** + +打开生成的迁移文件(例如 `prisma/migrations/20260125000000_add_project_embedding/migration.sql`),确保包含: + +```sql +-- 启用 pgvector 扩展 +CREATE EXTENSION IF NOT EXISTS vector; + +-- 添加向量列 +ALTER TABLE "Project" ADD COLUMN "embedding" vector(1536); +ALTER TABLE "Project" ADD COLUMN "embeddingUpdatedAt" TIMESTAMP; + +-- 创建 HNSW 索引(余弦距离,适合文本搜索) +CREATE INDEX idx_project_embedding_cosine ON "Project" USING hnsw ("embedding" vector_cosine_ops) WITH (m = 16, ef_construction = 64); + +-- 创建部分索引(查找未向量化项目) +CREATE INDEX idx_project_embedding_null ON "Project" ("id") WHERE "embedding" IS NULL; +``` + +**Step 4: 手动优化迁移(可选)** + +如果生成的 SQL 不包含索引,手动修改迁移文件添加上述索引 SQL。 + +**Step 5: 应用迁移** + +```bash +# 如果在上一步中已经自动应用,跳过此步骤 +# 否则手动应用: +pnpm prisma migrate deploy +``` + +**Step 6: 验证迁移** + +```sql +-- 检查表结构 +\d "Project" + +-- 应该看到: +-- embedding | vector(1536) | +-- embeddingUpdatedAt | timestamp | + +-- 检查索引 +SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'Project'; + +-- 应该看到: +-- idx_project_embedding_cosine +-- idx_project_embedding_null +``` + +**Step 7: 更新 Prisma Client** + +```bash +pnpm prisma generate +``` + +**Step 8: 提交** + +```bash +git add prisma/schema.prisma prisma/migrations +git commit -m "feat: 添加项目向量嵌入字段和 pgvector 索引" +``` + +--- + +## Phase 2: n8n 工作流配置 + +### Task 2: 配置 n8n 凭证 + +**Step 1: 获取 OpenAI API Key** + +访问 https://platform.openai.com/api-keys 创建新的 API Key。 + +**Step 2: 在 n8n 中添加 OpenAI 凭证** + +1. 打开 n8n 实例 +2. 导航到 **Credentials** → **New** +3. 选择 **OpenAI API** +4. 输入名称:`OpenAI Embeddings` +5. 粘贴 API Key +6. 保存 + +**Step 3: 添加 Neon 数据库凭证** + +1. 在 n8n 中导航到 **Credentials** → **New** +2. 选择 **PostgreSQL** +3. 输入名称:`Neon Database` +4. 配置连接: + - Host: 从 `DATABASE_URL` 提取(例如 `xxx.neon.tech`) + - Database: 数据库名(例如 `neondb`) + - User: 用户名 + - Password: 密码 + - SSL: 启用 +5. 点击 **Test Connection** 验证 +6. 保存 + +**注意**:不提交凭证到 git。 + +--- + +### Task 3: 创建向量化工作流 + +**Step 1: 创建新工作流** + +在 n8n 中: +1. 点击 **New Workflow** +2. 命名为:`Project Vectorization` + +**Step 2: 添加 Cron 触发器** + +1. 添加节点:**Cron** +2. 配置: + - Trigger Interval: `Every 5 minutes` + - Cron Expression: `*/5 * * * *` +3. 保存节点 + +**Step 3: 添加 PostgreSQL 节点(查询未向量化项目)** + +1. 添加节点:**Postgres**(使用 `Neon Database` 凭证) +2. 操作:**Execute Query** +3. 输入查询: + +```sql +SELECT id, name, "nameEn", description, "descriptionEn", content, "contentEn" +FROM "Project" +WHERE "embedding" IS NULL + AND "status" = 'ACTIVE' +LIMIT 20 +``` + +4. 测试节点(应该返回现有项目列表) +5. 保存节点 + +**Step 4: 添加 Function 节点(构造文本内容)** + +1. 添加节点:**Function** → **JavaScript** +2. 命名:`Construct Text Content` +3. 输入代码: + +```javascript +// 为每个项目构造用于向量化的文本内容 +const projects = $input.all(); + +return projects.map(item => { + const project = item.json; + + // 合并字段,按权重构造 + const parts = [ + project.name || '', + project.nameEn || '', + project.description || '', + project.descriptionEn || '', + // tags 稍后处理 + (project.content || '').substring(0, 500), + (project.contentEn || '').substring(0, 500) + ].filter(Boolean); + + const textContent = parts.join('\n\n'); + + return { + json: { + ...project, + textContent: textContent + } + }; +}); +``` + +4. 测试节点(应该添加 `textContent` 字段) +5. 保存节点 + +**Step 5: 添加 Split in Batches 节点** + +1. 添加节点:**Split In Batches** +2. 配置: + - Batch Size: `5`(每批 5 个项目,避免 API 限流) +3. 保存节点 + +**Step 6: 添加 OpenAI Embeddings 节点** + +1. 添加节点:**OpenAI** → **Embeddings** +2. 使用 `OpenAI Embeddings` 凭证 +3. 配置: + - Model: `text-embedding-3-small` + - Input: `{{ $json.textContent }}` +4. 测试节点(应该返回向量数组) +5. 保存节点 + +**Step 7: 添加 PostgreSQL 节点(更新 embedding)** + +1. 添加节点:**Postgres** +2. 操作:**Execute Query** +3. 输入查询: + +```sql +UPDATE "Project" +SET + "embedding" = '{{ $json.data[0].embedding }}'::vector, + "embeddingUpdatedAt" = NOW() +WHERE "id" = {{ $json.id }} +``` + +4. 测试节点 +5. 保存节点 + +**Step 8: 添加 Wait 节点(控制速率)** + +1. 添加节点:**Wait** +2. 配置: + - Amount: `1` 秒 +3. 保存节点(在 Split in Batches 和 OpenAI 之间) + +**Step 9: 连接节点** + +按顺序连接: +``` +Cron → Postgres (查询) → Function (构造) → Split in Batches → Wait → OpenAI Embeddings → Postgres (更新) → Loop back to Split in Batches +``` + +**Step 10: 测试完整工作流** + +1. 点击 **Test Workflow** +2. 手动触发 Cron 节点 +3. 验证: + - 查询返回未向量化项目 + - 文本内容正确构造 + - OpenAI API 成功调用 + - 数据库成功更新 + +**Step 11: 激活工作流** + +1. 点击 **Active** 开关 +2. 验证 Cron 调度正常运行 + +**Step 12: 验证向量化结果** + +```sql +-- 检查已向量化项目数 +SELECT COUNT(*) FROM "Project" WHERE "embedding" IS NOT NULL; + +-- 查看某个项目的向量 +SELECT id, name, "embeddingUpdatedAt" +FROM "Project" +WHERE "embedding" IS NOT NULL +LIMIT 5; +``` + +--- + +### Task 4: 创建 AI 搜索工作流 + +**Step 1: 创建新工作流** + +在 n8n 中: +1. 点击 **New Workflow** +2. 命名为:`AI Semantic Search` + +**Step 2: 添加 Webhook 触发器** + +1. 添加节点:**Webhook** +2. 配置: + - HTTP Method: `POST` + - Path: `ai-search` + - Response Mode: **When Last Node Finishes** +3. 记录 Webhook URL(例如 `https://your-n8n.com/webhook/ai-search`) +4. 测试 Webhook(curl): + +```bash +curl -X POST https://your-n8n.com/webhook/ai-search \ + -H "Content-Type: application/json" \ + -d '{"query":"test","locale":"zh","limit":5}' +``` + +5. 保存节点 + +**Step 3: 添加 OpenAI Embeddings 节点(生成查询向量)** + +1. 添加节点:**OpenAI** → **Embeddings** +2. 使用 `OpenAI Embeddings` 凭证 +3. 配置: + - Model: `text-embedding-3-small` + - Input: `{{ $json.query }}` +4. 测试节点 +5. 保存节点 + +**Step 4: 添加 PostgreSQL 节点(向量相似度搜索)** + +1. 添加节点:**Postgres** +2. 操作:**Execute Query** +3. 输入查询: + +```sql +SELECT + p.id, + p.name, + p."nameEn", + p.slug, + p.description, + p."descriptionEn", + p.status, + p."createdAt", + 1 - (p."embedding" <=> '{{ $json.data[0].embedding }}'::vector) as similarity +FROM "Project" p +WHERE p."embedding" IS NOT NULL + AND p.status = 'ACTIVE' +ORDER BY p."embedding" <=> '{{ $json.data[0].embedding }}'::vector +LIMIT {{ $json.limit || 20 }} +``` + +4. 测试节点(应该返回相似度排序的项目) +5. 保存节点 + +**Step 5: 添加 Function 节点(查询项目标签)** + +1. 添加节点:**Function** → **JavaScript** +2. 输入代码: + +```javascript +const results = $input.all(); +const projectIds = results.map(r => r.json.id).join(','); + +// 为每个项目查询标签 +return results.map(item => { + return { + json: { + ...item.json, + projectId: item.json.id + } + }; +}); +``` + +3. 保存节点 + +**Step 6: 添加 PostgreSQL 节点(查询标签)** + +1. 添加节点:**Postgres** +2. 操作:**Execute Query** +3. 输入查询: + +```sql +SELECT + t.id, + t.name, + t."nameEn", + t.slug, + pt."projectId" +FROM "Tag" t +INNER JOIN "ProjectTag" pt ON t.id = pt."tagId" +WHERE pt."projectId" IN (SELECT UNNEST(STRING_TO_ARRAY('{{ $json.projectIds }}', ','))::INTEGER) +``` + +4. 保存节点 + +**Step 7: 添加 Function 节点(合并标签)** + +1. 添加节点:**Function** → **JavaScript** +2. 输入代码: + +```javascript +const projects = $('Postgres').all(); +const tags = $('Postgres1').all(); + +// 合并标签到项目 +const results = projects.map(project => { + const projectTags = tags + .filter(t => t.json.projectId === project.json.id) + .map(t => ({ + id: t.json.id, + name: t.json.name, + nameEn: t.json.nameEn, + slug: t.json.slug + })); + + return { + json: { + project: { + ...project.json, + tags: projectTags + }, + similarity: project.json.similarity, + matchReason: `相似度: ${(project.json.similarity * 100).toFixed(0)}%` + } + }; +}); + +return results; +``` + +3. 测试节点 +4. 保存节点 + +**Step 8: 添加 Function 节点(格式化响应)** + +1. 添加节点:**Function** → **JavaScript** +2. 输入代码: + +```javascript +const results = $input.all(); +const searchTime = $now.toMillis() - $('Webhook').item.json.startTime; + +return { + json: { + results: results.map(r => r.json), + total: results.length, + searchTime: searchTime + } +}; +``` + +3. 保存节点 + +**Step 9: 连接节点** + +``` +Webhook → OpenAI Embeddings → Postgres (向量搜索) → Function (查询标签) → Postgres (标签) → Function (合并) → Function (格式化) → Response +``` + +**Step 10: 测试完整工作流** + +1. 点击 **Test Workflow** +2. 在 Webhook 节点中点击 **Listen for Test Event** +3. 发送测试请求: + +```bash +curl -X POST https://your-n8n.com/webhook/ai-search \ + -H "Content-Type: application/json" \ + -d '{"query":"视频生成工具","locale":"zh","limit":5}' +``` + +4. 验证响应格式: + +```json +{ + "results": [ + { + "project": { /* 项目数据 */ }, + "similarity": 0.89, + "matchReason": "相似度: 89%" + } + ], + "total": 5, + "searchTime": 1234 +} +``` + +**Step 11: 激活工作流** + +1. 点击 **Active** 开关 +2. 保存生产环境的 Webhook URL + +**Step 12: 配置环境变量** + +在 `.env.local` 中添加: + +```bash +N8N_AI_SEARCH_WEBHOOK=https://your-n8n.com/webhook/ai-search +``` + +--- + +## Phase 3: Next.js API 实现 + +### Task 5: 创建 AI 搜索 API 路由 + +**Files:** +- Create: `src/app/api/search/ai/route.ts` + +**Step 1: 创建 API 路由文件** + +创建 `src/app/api/search/ai/route.ts`: + +```typescript +import { NextResponse } from 'next/server' +import { ProjectQuerySchema } from '@/lib/validations' + +const N8N_WEBHOOK_URL = process.env.N8N_AI_SEARCH_WEBHOOK + +if (!N8N_WEBHOOK_URL) { + throw new Error('N8N_AI_SEARCH_WEBHOOK environment variable is not set') +} + +export async function POST(request: Request) { + try { + const body = await request.json() + + // 验证查询参数 + const validatedQuery = ProjectQuerySchema.parse(body) + + // 转发到 n8n 工作流 + const n8nResponse = await fetch(N8N_AI_SEARCH_WEBHOOK, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: validatedQuery.search, + locale: body.locale || 'zh', + limit: validatedQuery.limit, + filters: { + tags: validatedQuery.tags, + status: validatedQuery.status + } + }) + }) + + if (!n8nResponse.ok) { + throw new Error(`n8n webhook failed: ${n8nResponse.statusText}`) + } + + const results = await n8nResponse.json() + + return NextResponse.json(results) + + } catch (error) { + console.error('AI search error:', error) + + if (error instanceof Error && error.name === 'ZodError') { + return NextResponse.json( + { error: 'Invalid query parameters', details: error }, + { status: 400 } + ) + } + + return NextResponse.json( + { error: 'AI search failed', message: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 } + ) + } +} +``` + +**Step 2: 测试 API 路由** + +启动开发服务器: + +```bash +pnpm dev +``` + +测试 API: + +```bash +curl -X POST http://localhost:3000/api/search/ai \ + -H "Content-Type: application/json" \ + -d '{"search":"视频生成工具","limit":5}' +``` + +预期响应: + +```json +{ + "results": [...], + "total": 5, + "searchTime": 1234 +} +``` + +**Step 3: 提交** + +```bash +git add src/app/api/search/ai/route.ts +git commit -m "feat: 添加 AI 搜索 API 路由" +``` + +--- + +## Phase 4: 前端组件实现 + +### Task 6: 修改 SearchBar 组件 + +**Files:** +- Modify: `src/components/search/SearchBar.tsx` + +**Step 1: 读取现有组件** + +```bash +# 查看现有组件结构 +cat src/components/search/SearchBar.tsx +``` + +**Step 2: 添加 AI 模式状态** + +在组件中添加: + +```tsx +'use client' + +import { useState } from 'react' +import { Sparkles, Search } from 'lucide-react' +// ... 保留现有 imports + +export function SearchBar() { + // ... 保留现有状态 + const [aiMode, setAiMode] = useState(false) + const [loading, setLoading] = useState(false) + + // ... 保留现有逻辑 +} +``` + +**Step 3: 修改搜索处理函数** + +更新 `handleSearch` 函数: + +```tsx +const handleSearch = async () => { + if (!query.trim()) return + + setLoading(true) + try { + const endpoint = aiMode ? '/api/search/ai' : '/api/projects' + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + search: query, + locale: 'zh', + limit: 20 + }) + }) + + if (!response.ok) { + throw new Error('Search failed') + } + + const data = await response.json() + + // 处理结果... + // if (aiMode) { + // setAIResults(data.results) + // } else { + // setProjects(data.results) + // } + + } catch (error) { + console.error('Search error:', error) + // 显示错误提示 + } finally { + setLoading(false) + } +} +``` + +**Step 4: 添加 AI 切换按钮** + +在搜索输入框旁边添加: + +```tsx +