1146 lines
25 KiB
Markdown
1146 lines
25 KiB
Markdown
# 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
|
||
<div className="flex gap-2">
|
||
{/* 搜索输入框 */}
|
||
<input
|
||
type="text"
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||
placeholder={
|
||
aiMode
|
||
? "描述你想要的项目..."
|
||
: "搜索项目名称..."
|
||
}
|
||
className="flex-1 px-4 py-2 border-2 border-gray-300"
|
||
/>
|
||
|
||
{/* AI 模式切换按钮 */}
|
||
<button
|
||
onClick={() => setAiMode(!aiMode)}
|
||
className={`
|
||
px-3 py-2 border-2 transition-all
|
||
${aiMode
|
||
? 'bg-yellow-400 border-yellow-500 text-black'
|
||
: 'bg-white border-gray-300 text-gray-600'
|
||
}
|
||
`}
|
||
title="AI 语义搜索"
|
||
>
|
||
<Sparkles className="w-5 h-5" />
|
||
</button>
|
||
|
||
{/* 搜索按钮 */}
|
||
<button
|
||
onClick={handleSearch}
|
||
disabled={loading}
|
||
className="px-6 py-2 bg-blue-600 text-white"
|
||
>
|
||
{loading ? '搜索中...' : <Search className="w-5 h-5" />}
|
||
</button>
|
||
</div>
|
||
|
||
{/* AI 模式提示 */}
|
||
{aiMode && (
|
||
<div className="mt-2 text-sm text-gray-600">
|
||
💡 试试:"帮我找能生成视频的 AI 工具"
|
||
</div>
|
||
)}
|
||
```
|
||
|
||
**Step 5: 测试组件**
|
||
|
||
1. 访问 `http://localhost:3000/zh/projects`
|
||
2. 点击 AI 切换按钮(应该变金色)
|
||
3. 输入查询并搜索
|
||
4. 验证加载状态显示
|
||
|
||
**Step 6: 提交**
|
||
|
||
```bash
|
||
git add src/components/search/SearchBar.tsx
|
||
git commit -m "feat: 添加 AI 搜索模式切换按钮"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: 创建 AI 搜索结果组件
|
||
|
||
**Files:**
|
||
- Create: `src/components/search/AISearchResults.tsx`
|
||
|
||
**Step 1: 创建组件文件**
|
||
|
||
创建 `src/components/search/AISearchResults.tsx`:
|
||
|
||
```tsx
|
||
'use client'
|
||
|
||
import { ProjectCard } from '@/components/project/ProjectCard'
|
||
import type { Project } from '@/hooks/useProjects'
|
||
|
||
interface AISearchResult {
|
||
project: Project
|
||
similarity: number
|
||
matchReason?: string
|
||
}
|
||
|
||
interface AISearchResultsProps {
|
||
results: AISearchResult[]
|
||
}
|
||
|
||
export function AISearchResults({ results }: AISearchResultsProps) {
|
||
if (results.length === 0) {
|
||
return (
|
||
<div className="text-center py-12 text-gray-600">
|
||
未找到相关项目,试试其他描述吧
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* 相似度说明 */}
|
||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||
<span>匹配度:</span>
|
||
<div className="flex items-center gap-1">
|
||
<span className="text-green-600 font-semibold">高</span>
|
||
<span>→</span>
|
||
<span className="text-red-600 font-semibold">低</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 结果列表 */}
|
||
{results.map(({ project, similarity, matchReason }) => (
|
||
<div key={project.id} className="relative">
|
||
{/* 相似度指示条 */}
|
||
<div
|
||
className="absolute left-0 top-0 bottom-0 w-1 rounded-l"
|
||
style={{
|
||
backgroundColor: getSimilarityColor(similarity)
|
||
}}
|
||
/>
|
||
|
||
{/* 项目卡片 */}
|
||
<div className="ml-3">
|
||
<ProjectCard project={project} />
|
||
|
||
{/* AI 匹配信息 */}
|
||
<div className="mt-3 px-4 py-3 bg-yellow-50 border-l-4 border-yellow-400 rounded-r">
|
||
<div className="flex justify-between items-center text-sm">
|
||
<span className="font-semibold text-gray-700">
|
||
匹配度: {(similarity * 100).toFixed(0)}%
|
||
</span>
|
||
{matchReason && (
|
||
<span className="text-gray-600">{matchReason}</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function getSimilarityColor(score: number): string {
|
||
if (score > 0.8) return '#22c55e' // green-500
|
||
if (score > 0.6) return '#eab308' // yellow-500
|
||
return '#ef4444' // red-500
|
||
}
|
||
```
|
||
|
||
**Step 2: 在项目列表页面中使用**
|
||
|
||
修改 `src/app/[locale]/projects/page.tsx`:
|
||
|
||
```tsx
|
||
// 在页面中添加 AI 结果状态
|
||
const [aiResults, setAIResults] = useState<AISearchResult[]>([])
|
||
const [isAIResult, setIsAIResult] = useState(false)
|
||
|
||
// 在搜索处理中设置结果
|
||
if (aiMode) {
|
||
setAIResults(data.results)
|
||
setIsAIResult(true)
|
||
} else {
|
||
setProjects(data.results)
|
||
setIsAIResult(false)
|
||
}
|
||
|
||
// 在渲染中切换
|
||
{isAIResult ? (
|
||
<AISearchResults results={aiResults} />
|
||
) : (
|
||
<ProjectList projects={projects} />
|
||
)}
|
||
```
|
||
|
||
**Step 3: 测试组件**
|
||
|
||
1. 访问项目列表页面
|
||
2. 启用 AI 模式
|
||
3. 搜索查询
|
||
4. 验证:
|
||
- 相似度指示条显示正确颜色
|
||
- 匹配度百分比正确
|
||
- 项目卡片正常渲染
|
||
|
||
**Step 4: 提交**
|
||
|
||
```bash
|
||
git add src/components/search/AISearchResults.tsx
|
||
git add src/app/[locale]/projects/page.tsx
|
||
git commit -m "feat: 添加 AI 搜索结果展示组件"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 5: 测试与验证
|
||
|
||
### Task 8: 端到端测试
|
||
|
||
**Step 1: 测试向量化流程**
|
||
|
||
```sql
|
||
-- 检查向量化进度
|
||
SELECT
|
||
COUNT(*) FILTER (WHERE "embedding" IS NOT NULL) as vectorized,
|
||
COUNT(*) FILTER (WHERE "embedding" IS NULL) as not_vectorized,
|
||
COUNT(*) as total
|
||
FROM "Project";
|
||
```
|
||
|
||
预期:`not_vectorized` 应该逐渐减少(每 5 分钟更新一批)。
|
||
|
||
**Step 2: 测试 AI 搜索**
|
||
|
||
```bash
|
||
# 测试自然语言查询
|
||
curl -X POST http://localhost:3000/api/search/ai \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"search":"帮我找能生成视频的 AI 工具","limit":10}'
|
||
```
|
||
|
||
验证:
|
||
- 返回结果与"视频生成"相关
|
||
- 相似度分数合理(>0.6)
|
||
- 响应时间 < 3 秒
|
||
|
||
**Step 3: 测试边界情况**
|
||
|
||
```bash
|
||
# 空查询
|
||
curl -X POST http://localhost:3000/api/search/ai \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"search":"","limit":10}'
|
||
|
||
# 预期:400 错误
|
||
|
||
# 无结果查询
|
||
curl -X POST http://localhost:3000/api/search/ai \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"search":"xyzabc123不存在的项目","limit":10}'
|
||
|
||
# 预期:空 results 数组
|
||
```
|
||
|
||
**Step 4: 测试降级逻辑**
|
||
|
||
在前端测试:
|
||
1. 停止 n8n 工作流
|
||
2. 执行 AI 搜索
|
||
3. 验证错误提示和降级到传统搜索
|
||
|
||
---
|
||
|
||
### Task 9: 性能验证
|
||
|
||
**Step 1: 测试搜索响应时间**
|
||
|
||
```bash
|
||
# 使用 time 命令测量
|
||
time curl -X POST http://localhost:3000/api/search/ai \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"search":"视频生成","limit":20}'
|
||
```
|
||
|
||
目标:< 3 秒
|
||
|
||
**Step 2: 测试并发搜索**
|
||
|
||
```bash
|
||
# 使用 apachebench 或类似工具
|
||
ab -n 100 -c 10 -p search.json -T application/json \
|
||
http://localhost:3000/api/search/ai
|
||
```
|
||
|
||
目标:能处理 10 并发无错误
|
||
|
||
**Step 3: 检查数据库查询性能**
|
||
|
||
在 n8n PostgreSQL 节点中查看执行时间:
|
||
|
||
```sql
|
||
EXPLAIN ANALYZE
|
||
SELECT ... (向量搜索查询)
|
||
```
|
||
|
||
目标:< 500ms
|
||
|
||
---
|
||
|
||
### Task 10: 文档更新
|
||
|
||
**Files:**
|
||
- Modify: `README.md`(可选)
|
||
- Modify: `CLAUDE.md`
|
||
|
||
**Step 1: 更新 CLAUDE.md**
|
||
|
||
在 `CLAUDE.md` 中添加 AI 搜索部分:
|
||
|
||
```markdown
|
||
## AI Search System
|
||
|
||
项目支持 AI 语义搜索功能,基于 pgvector 和 n8n 工作流实现。
|
||
|
||
### Architecture
|
||
|
||
- **Vector Database**: Neon PostgreSQL + pgvector extension
|
||
- **AI Services**: n8n workflows (vectorization + search)
|
||
- **Frontend**: Toggle button + similarity scores
|
||
|
||
### Environment Variables
|
||
|
||
```bash
|
||
# n8n Webhook
|
||
N8N_AI_SEARCH_WEBHOOK=https://your-n8n.com/webhook/ai-search
|
||
```
|
||
|
||
### n8n Workflows
|
||
|
||
1. **Project Vectorization**: Cron job (every 5 min) to generate embeddings
|
||
2. **AI Semantic Search**: Webhook for natural language queries
|
||
|
||
### Usage
|
||
|
||
用户在项目列表页面点击 AI 搜索按钮(✨ 图标),输入自然语言描述,系统返回语义相关的项目。
|
||
```
|
||
|
||
**Step 2: 提交**
|
||
|
||
```bash
|
||
git add CLAUDE.md
|
||
git commit -m "docs: 添加 AI 搜索系统文档"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 6: 部署
|
||
|
||
### Task 11: 生产环境部署
|
||
|
||
**Step 1: 验证 n8n 工作流**
|
||
|
||
1. 确保 n8n 实例在生产环境运行
|
||
2. 激活两个工作流
|
||
3. 验证 Webhook URL 可访问
|
||
|
||
**Step 2: 配置 Vercel 环境变量**
|
||
|
||
在 Vercel Dashboard 中添加:
|
||
|
||
```bash
|
||
N8N_AI_SEARCH_WEBHOOK=https://your-production-n8n.com/webhook/ai-search
|
||
```
|
||
|
||
**Step 3: 部署 Next.js 应用**
|
||
|
||
```bash
|
||
# 推送到 main 分支触发部署
|
||
git push origin main
|
||
|
||
# 或手动部署
|
||
vercel --prod
|
||
```
|
||
|
||
**Step 4: 验证生产环境**
|
||
|
||
```bash
|
||
# 测试生产环境 API
|
||
curl -X POST https://your-domain.com/api/search/ai \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"search":"视频生成","limit":5}'
|
||
```
|
||
|
||
**Step 5: 监控 n8n 执行**
|
||
|
||
在 n8n Dashboard 中:
|
||
- 查看工作流执行历史
|
||
- 检查错误日志
|
||
- 监控 API 使用量
|
||
|
||
**Step 6: 设置告警(可选)**
|
||
|
||
在 n8n 中配置错误告警:
|
||
- OpenAI API 失败
|
||
- 数据库连接错误
|
||
- Webhook 超时
|
||
|
||
---
|
||
|
||
## 总结
|
||
|
||
### 完成检查清单
|
||
|
||
- [ ] Phase 1: 数据库迁移完成
|
||
- [ ] Phase 2: n8n 工作流配置完成
|
||
- [ ] Phase 3: Next.js API 路由实现
|
||
- [ ] Phase 4: 前端组件实现
|
||
- [ ] Phase 5: 测试与验证通过
|
||
- [ ] Phase 6: 生产环境部署成功
|
||
|
||
### 关键指标
|
||
|
||
- 向量化完成率: > 95%
|
||
- 搜索响应时间: < 3 秒
|
||
- 相似度准确率: 用户满意 > 80%
|
||
- API 成本: < $5/月(假设 1000 次搜索/天)
|
||
|
||
### 后续优化方向
|
||
|
||
1. 添加查询缓存(Redis)
|
||
2. 实现混合搜索(向量 + 关键词)
|
||
3. A/B 测试用户体验
|
||
4. 多模态搜索(图片、语音)
|
||
5. 个性化排序
|
||
|
||
---
|
||
|
||
**计划创建日期**: 2026-01-25
|
||
**预计完成时间**: 4-6 小时
|
||
**难度等级**: 中等
|