基于词向量嵌入与pgvector的AI反馈自动分类系统实践

发布时间:2026/5/28 6:12:43

基于词向量嵌入与pgvector的AI反馈自动分类系统实践 1. 项目概述当用户反馈如潮水般涌来作为一名独立开发者我最近上线了一个名为 FeedMission 的新功能。起初收到用户的反馈让我兴奋不已每一个“点赞”或“建议”都像是产品成长的养分。然而当反馈数量从个位数增长到几十条、上百条时最初的喜悦迅速被一种熟悉的焦虑所取代我发现自己陷入了手动整理和分类的泥潭。问题在于用户的表达方式千差万别。三个人可能说了三件听起来完全不同的事但核心诉求却是同一个。比如“请增加深色模式”、“晚上看屏幕眼睛疼”、“希望能自定义背景色”——这三条反馈本质上都是在请求“深色主题”。手动阅读每一条反馈再凭直觉将它们归类在只有10条时还算轻松一旦超过50条这项工作就变成了吞噬时间的无底洞而且极易出错导致重要的需求被埋没在琐碎的表述中。我意识到我需要一个“懒人”解决方案让机器自动理解这些反馈的语义并将相似的归为一类。于是我决定利用 AI 技术构建一个自动化的反馈分类系统。整个系统的核心目标很明确在用户提交反馈后系统能自动、准确地将相似反馈聚类并生成清晰的主题名称和摘要让我能一眼看清用户最集中、最迫切的需求是什么。最终我用 188 行 TypeScript 代码实现了这个管道并将其无缝集成到了现有的 Next.js 应用和 Supabase (PostgreSQL) 数据库中。2. 技术选型与核心思路拆解面对“自动归类相似文本”这个问题最直接的暴力解法是将每两条反馈都扔给一个大语言模型比如 Claude直接问“这两句话意思一样吗”。这在技术上完全可行但成本和时间上都是灾难。假设有 N 条反馈两两比较的组合数是 N*(N-1)/2。100条反馈就需要进行 4950 次 API 调用这无论从响应时间还是账单角度看都是不可接受的。因此我们需要一个更高效的基础设施来处理语义相似度计算。这就是“词向量嵌入”技术登场的时候。2.1 为什么是词向量嵌入词向量嵌入的核心思想是将一段文本一个词、一句话或一个段落转换为一组高维空间中的向量即一串数字。这个向量的神奇之处在于语义相近的文本其对应的向量在空间中的“距离”也会很近。你可以把它想象成一个美食推荐系统。当用户搜索“深夜解馋”时系统不会只匹配含有“深夜”和“解馋”字样的菜品而是会理解这个查询的“语义”——指向那些高热量、令人满足的食物——从而同时推荐“炸鸡”和“烤猪蹄”。我们的反馈分类也是同理“请加深色模式”和“晚上刺眼”这两个向量虽然字面毫无重叠但在高维语义空间中它们的位置会非常接近。我选择了Voyage AI的voyage-3-lite模型来生成嵌入向量。它能为每段文本生成一个包含 1024 个浮点数的向量在效果、速度和成本之间取得了很好的平衡。这个向量就是后续进行相似度比较的“数学指纹”。2.2 数据库为什么选择 pgvector 而非专用向量数据库存储和查询这些 1024 维的向量是另一个关键决策点。市面上有 Pinecone、Weaviate 等专业的向量数据库它们为此类操作做了高度优化。然而引入一个新数据库意味着额外的运维复杂度、网络延迟和成本。我的应用已经使用了Supabase其底层是PostgreSQL。幸运的是PostgreSQL 有一个名为pgvector的强大扩展它可以直接在关系型数据库中存储向量数据类型并支持高效的相似度搜索运算符如余弦相似度。这意味着我可以在同一场数据库事务中既处理用户、项目等关系型数据又进行向量相似度查询保持了数据的一致性和架构的简洁。注意虽然 pgvector 方案在中小规模数据下表现优异且极大地简化了架构但它并非银弹。如果你的数据量达到数千万甚至上亿条向量专用向量数据库在超大规相似性搜索的性能和成本上可能仍有优势。但对于一个初创项目或中等规模的用户反馈系统pgvector 通常是更务实、更优雅的选择。2.3 情感分析为什么同时进行除了“在说什么”用户“以何种情绪说”也同样重要。“请增加深色模式”和“为什么还没有深色模式”都表达了同一需求但后者的紧迫性和用户的不满情绪明显更高。为了捕捉这一维度我决定在生成嵌入向量的同时并行进行情感分析。我使用了Claude Haiku模型进行情感分析它会返回一个介于 -1.0极度负面到 1.0极度正面之间的分数。这样每个反馈条目都拥有了两个核心的 AI 衍生属性语义向量和情感分数。情感分数后续可以用于优先级排序或者在聚类内部区分用户情绪分布。2.4 异步处理模式用户体验优先生成嵌入向量和情感分析都需要调用外部 API这可能会引入数百毫秒甚至上秒级的延迟。如果让用户在提交反馈后同步等待所有这些处理完成体验会非常糟糕。因此我采用了after()模式。其核心思想是在 API 路由中先快速将反馈的原始内容如文本、用户ID存入数据库并立即向用户返回“提交成功”的响应。然后在一个“后置”的、异步执行的函数中再触发耗时的 AI 处理和聚类逻辑。// app/api/feedback/route.ts 示例 export async function POST(request: Request) { // 1. 解析请求验证数据 const { content, projectId } await request.json(); // 2. 快速将原始反馈存入数据库 const newFeedback await prisma.feedback.create({ data: { content, projectId } }); // 3. 立即返回成功响应用户无需等待 const response NextResponse.json({ success: true, id: newFeedback.id }, { status: 201 }); // 4. 在返回响应后异步启动后台处理任务 after(async () { try { await processFeedbackAsync(newFeedback.id); // 包含AI调用和聚类 } catch (error) { console.error(‘后台处理反馈失败:’, error); // 此处可集成错误监控如 Sentry } }); return response; }这种模式确保了用户操作的流畅性将后台处理与前端交互完全解耦。3. 核心实现细节与实操要点有了清晰的技术蓝图接下来就是动手实现。我将整个自动化管道构建在一个名为clustering.ts的文件中以下是其核心环节的拆解。3.1 生成向量与情感分数并行化优化当processFeedbackAsync函数被调用时它首先需要获取反馈的文本内容然后并行调用 Voyage AI 和 Claude Haiku API。// lib/ai/processors.ts 示例 import { generateEmbedding } from ‘./voyage-client’; import { analyzeSentiment } from ‘./claude-client’; export async function enrichFeedbackWithAI(content: string): Promise{ embedding: number[]; sentiment: number; } { // 使用 Promise.all 进行并行调用显著减少总等待时间 const [embedding, sentiment] await Promise.all([ generateEmbedding(content), // 假设耗时 ~200ms analyzeSentiment(content) // 假设耗时 ~150ms ]); // 顺序执行总耗时约350ms并行后耗时约200ms取决于最慢的API return { embedding, sentiment }; }实操心得并行化是优化外部 API 调用链路的有效手段。但要注意并非所有操作都适合并行。如果后续操作严重依赖于前一步的结果则必须顺序执行。在本例中向量和情感分析彼此独立非常适合并行。3.2 向量存储与 Prisma ORM 的兼容性方案我的项目使用 Prisma 作为 ORM。然而Prisma 目前尚未官方支持pgvector扩展定义的vector数据类型。这带来了一个挑战如何在 Prisma 模式中定义这个字段并进行读写操作解决方案是一种“混合”模式在 Prisma Schema 中声明为Unsupported类型这告诉 Prisma 忽略这个字段的特定类型验证但依然会在数据库中创建对应的列。// prisma/schema.prisma model Feedback { id String id default(cuid()) content String // 其他业务字段... embedding Unsupported(“vector(1024)”)? // pgvector 列 sentiment Float? clusterId String? // ... 关系定义 }使用 Prisma 原生 SQL 查询进行读写对于embedding字段我们无法使用prisma.feedback.create或update来直接操作。必须使用$queryRaw或$executeRaw。// 写入向量 await prisma.$executeRaw UPDATE “Feedback” SET embedding ${embeddingVector}::vector WHERE id ${feedbackId} ; // 读取及相似度查询 const similarFeedbacks await prisma.$queryRaw SELECT id, content, 1 - (embedding ${queryVector}::vector) as similarity FROM “Feedback” WHERE “projectId” ${projectId} AND id ! ${excludeId} ORDER BY embedding ${queryVector}::vector LIMIT 5 ;重要提示使用原生 SQL 时务必注意 SQL 注入风险。Prisma 的$queryRaw使用标签模板字面量能提供参数化查询相对安全。但一定要确保传入的变量是可信的或经过严格处理。3.3 相似度搜索与阈值抉择使用 pgvector 的运算符余弦距离可以轻松计算向量间的相似度。余弦距离范围是 0完全相同到 2完全相反。更常用的相似度分数是1 - 距离这样得到的就是一个 0 到 1 的值1 代表完全相同。我遇到的第一个大坑在为新反馈寻找相似反馈时我最初写的查询忘记排除这条反馈自身。-- 错误查询忘记排除自身 SELECT id, 1 - (embedding $1) as similarity FROM “Feedback” WHERE “projectId” $2 ORDER BY similarity DESC LIMIT 1;这条查询总是会返回新反馈自己作为最相似的结果相似度永远是 1.0。这导致系统认为每条新反馈都已经存在一个“完全一样”的旧反馈于是所有反馈都被归入了第一个创建的群组新的群组永远无法产生。调试了许久才发现解决方案就是加一个条件AND id ! ${currentFeedbackId}。如何确定相似度阈值这是一个需要实验的“魔法数字”。我手动创建了约 20 条具有代表性的测试反馈然后调整阈值观察聚类结果相似度阈值聚类效果观察0.70“深色模式请求”和“UI 颜色调整”被归为一组。它们相关但严格来说不是同一请求过于宽松。0.80“CSV 导出”和“数据下载”被归为一组。处于模糊地带有时正确有时错误。0.85“请加深色模式”和“晚上刺眼”被稳定归为一组。“Bug报告”和“功能请求”能被有效区分。效果符合预期。0.90只有措辞几乎一致的句子如重复提交才能匹配过于严格导致群组过多。最终我选择了0.85作为阈值。它能在“语义相同”和“语义相关但不同”之间取得一个较好的平衡。3.4 聚类逻辑加入旧群组还是创建新群组对于一条新反馈处理流程如下计算其向量。在同一个项目内搜索与其相似度大于 0.85 的现有反馈排除自身。如果找到则检查这些相似反馈是否已经属于某个群组clusterId非空。决策点加入现有群组如果存在相似度 0.85 且已属于某群组的反馈则将新反馈归入该群组。创建新群组如果未找到符合条件的现有群组则需要调用 Claude 为这个新群组生成一个标题和摘要。// clustering.ts 决策逻辑简化示例 const similarFeedbacks await findSimilarFeedbacks(newFeedbackVector, newFeedbackId, projectId); // 寻找最佳匹配既相似又已有群组的 const bestMatch similarFeedbacks.find(fb fb.similarity SIMILARITY_THRESHOLD fb.clusterId); if (bestMatch) { // 加入最佳匹配所在的群组 await prisma.feedback.update({ where: { id: newFeedbackId }, data: { clusterId: bestMatch.clusterId } }); // 触发群组优先级重算因为新增了反馈 await recalculateClusterPriority(bestMatch.clusterId); } else { // 没有找到合适的现有群组需要创建新的 const clusterNameAndSummary await generateClusterNameAndSummary([newFeedbackContent]); const newCluster await prisma.cluster.create({ data: { projectId, title: clusterNameAndSummary.title, summary: clusterNameAndSummary.summary, // 初始优先级等字段 } }); // 将新反馈关联到新群组 await prisma.feedback.update({ where: { id: newFeedbackId }, data: { clusterId: newCluster.id } }); }3.5 群组命名与摘要生成与大语言模型协作当需要创建新群组时我会将当前群组内的所有反馈内容初期可能只有一条发送给 Claude请求它生成一个简洁、概括性的标题和一段摘要。 我的提示词大致如下“你是一个产品经理助理。请根据以下用户反馈归纳出一个简短的群组标题不超过10个单词和一段概要说明不超过50个单词。标题应直接点明核心需求或问题概要应总结用户的共同点。请直接返回一个合法的 JSON 对象格式为{“title”: “...”, “summary”: “...”}。反馈内容[此处插入反馈文本]”我遇到的第二个坑非标准 JSON 响应。大多数时候Claude 会返回完美的 JSON。但偶尔它会把 JSON 包裹在 Markdown 代码块中例如{ “title”: “深色模式与主题自定义”, “summary”: “用户普遍请求增加深色模式或自定义主题颜色以缓解视觉疲劳” }直接用JSON.parse()解析这个字符串会抛出错误。因此必须在解析前进行防御性处理。async function parseClaudeJSONResponse(responseText: string) { let jsonString responseText.trim(); // 尝试去除可能的 Markdown 代码块标记 const codeBlockRegex /^(?:json)?\s*([\s\S]*?)$/; const match responseText.match(codeBlockRegex); if (match) { jsonString match[1].trim(); } try { return JSON.parse(jsonString); } catch (error) { console.error(‘解析 Claude 响应失败:’, jsonString); // 降级方案返回一个默认的标题和摘要 return { title: ‘未分类反馈’, summary: ‘需要手动检查的反馈集合。’ }; } }实操心得与任何外部 AI API 协作时都必须假设其输出是不可靠的。一定要添加健壮的错误处理逻辑包括重试、降级方案和详细的日志记录。这能避免整个流程因为一次意外的 API 响应格式而崩溃。4. 优先级计算与系统优化自动聚类解决了归类问题但面对几十个群组我仍需决定先处理哪个。为此我设计了一个简单的优先级评分公式为每个反馈群组计算一个 0 到 100 之间的分数。4.1 优先级公式的设计逻辑我的公式综合考虑了三个维度投票数、反馈数量和时效性。// 优先级计算函数示例 function calculatePriorityScore(cluster: Cluster, feedbacks: Feedback[]): number { // 1. 投票权重 (50%) const totalVotes feedbacks.reduce((sum, fb) sum (fb.voteCount || 0), 0); const voteScore Math.min((totalVotes / 50) * 100, 100) * 0.5; // 50票封顶占50分 // 2. 反馈数量权重 (30%) const feedbackCount feedbacks.length; const countScore Math.min((feedbackCount / 10) * 100, 100) * 0.3; // 10条封顶占30分 // 3. 时效性权重 (20%) const latestFeedbackDate new Date(Math.max(...feedbacks.map(fb new Date(fb.createdAt).getTime()))); const daysSinceLast (Date.now() - latestFeedbackDate.getTime()) / (1000 * 60 * 60 * 24); const recencyScore Math.max(0, 100 - (daysSinceLast * 2)) * 0.2; // 每天减2分50天后为0占20分 return Math.round(voteScore countScore recencyScore); }投票数50%代表了需求的“强度”。用户主动为某个反馈投票说明其共鸣感强。反馈数量30%代表了需求的“广度”。同一个问题被越多独立用户提及其普遍性越高。时效性20%代表了需求的“热度”。最近被频繁提及的问题可能更紧迫。这个公式没有绝对的科学依据但它结合了产品决策中常见的几个关键因素。你可以根据自己产品的特点调整权重和封顶值。4.2 性能优化与缓存策略随着反馈数量增长每次为所有群组计算优先级可能变得昂贵。我采用了以下策略惰性更新优先级不是实时计算的。只有在群组发生变化时新增反馈、收到投票才触发该群组的优先级重算。结果缓存将计算出的优先级分数存储在群组数据库记录的priorityScore字段中。前端列表查询时直接排序这个字段性能极佳。定时任务设置一个每天运行一次的定时任务重新计算所有群组的时效性分数部分确保“热度”随时间衰减。4.3 前端可视化一目了然的需求看板在后端计算好优先级分数后前端界面需要清晰地呈现。我设计了一个看板视图分数颜色编码 70分显示为红色高亮代表高优先级需要立即关注。 40分显示为黄色代表中等优先级可以规划排期。 40分显示为绿色代表低优先级或历史需求可暂缓。群组信息展示每个群组卡片展示 AI 生成的标题、摘要、包含的反馈数量、总票数以及最后更新时间。交互点击群组可以展开查看其下所有具体的反馈原文和情感倾向正面/负面这为深入理解用户语境提供了可能。5. 常见问题、踩坑实录与进阶思考在开发和运行这个系统的过程中我遇到了不少预料之外的问题也积累了一些经验。5.1 遇到的典型问题与解决方案问题现象根本原因解决方案所有新反馈都被归入第一个群组无法创建新群组。相似度查询时未排除反馈自身导致自己与自己匹配相似度1.0。在 SQL 查询中添加AND id ! ${currentFeedbackId}条件。向数据库写入向量时失败报错“无效的向量值”。从 AI API 获取的嵌入向量数组中混入了NaN(Not a Number) 或Infinity值。在存储前对向量数组进行验证if (!embedding.every(num Number.isFinite(num))) { throw new Error(‘Invalid embedding’); }新创建的群组优先级分数为 0在列表中不显示。recalculatePriority函数只在反馈加入现有群组时被调用而创建新群组后漏掉了调用。在创建新群组并关联反馈后立即调用该群组的优先级计算函数。Claude 返回的 JSON 无法解析导致群组创建失败。Claude 的响应有时被包裹在 Markdown 代码块中。在解析前使用正则表达式去除可能的 json ... 标记并添加 try-catch 提供降级标题。当某个群组反馈激增如超过50条后AI 生成的标题/摘要变得笼统或不准确。将过多文本一次性发送给 AI可能超出上下文窗口或导致焦点分散。改为只发送该群组中最新的、投票最高的或最具代表性的 10-15 条反馈给 AI 进行总结。5.2 词向量嵌入的局限性认知必须清醒认识到词向量嵌入并非万能。它存在一些固有的局限性在特定场景下效果会打折扣对数字不敏感“我需要3个按钮”和“我需要30个按钮”在嵌入空间中位置可能非常接近因为模型更关注“按钮”这个核心名词而忽略了数量这个关键差异。对否定词处理不佳“深色模式很棒”和“深色模式很糟糕”由于核心主语相同其向量可能依然相似情感上的对立难以通过纯语义向量区分。这就是为什么我需要额外引入情感分析。领域特异性通用模型对专业领域术语如特定编程库的函数名的语义理解可能不够精确。我的应对策略接受没有完美的解决方案。当前系统能解决我80%的自动化分类问题已经带来了巨大的效率提升。对于剩下20%可能分类不准的情况我在管理后台保留了“手动调整群组”和“重新生成标题”的功能。先有一个能跑起来的系统再根据实际遇到的具体问题去迭代优化远比追求一个理论上完美但迟迟无法上线的方案要实际得多。5.3 系统扩展性与监控目前这个系统运行良好但为了长期稳定我考虑了几个扩展方向重聚类随着时间推移早期反馈的语义可能与新反馈产生新的关联。可以每周在后台低峰期运行一个任务对旧群组进行拆分或合并。多维度分类除了按主题聚类未来或许可以增加按“反馈类型”自动打标如“Bug报告”、“功能请求”、“用户体验问题”、“文档咨询”等。这可以训练一个简单的文本分类模型来实现。监控与告警为 AI API 调用失败、聚类异常如一个群组过大等情况添加日志和监控告警例如集成到 Sentry 或 Datadog确保问题能及时发现。成本控制为每个项目的 AI API 调用设置月度预算或频率限制防止意外滥用导致高昂费用。6. 总结与个人体会回顾这 188 行代码构建的自动化管道其核心价值不在于使用了多么前沿的 AI 技术而在于它切实地解决了一个真实、具体且耗时的痛点。技术最终要服务于产品和效率。我个人最大的体会是在构建此类 AI 增强型功能时“混合智能”的思路往往比“全自动”更有效。让 AI 处理它擅长的、海量数据的模式识别和初步归纳如语义聚类和摘要而将最终的决定权、纠错权和复杂判断留给人。我的后台系统就是这样AI 提供了清晰的需求看板和建议但是否要开发、优先级如何微调仍然由我——这个产品的负责人——来把控。另一个深刻的教训是与外部 API尤其是 LLM协作时防御性编程至关重要。永远不要假设响应格式是完美的。进行输入校验、输出清洗、添加重试机制和完备的降级方案是保证系统鲁棒性的生命线。最后从零开始构建这样一个系统听起来复杂但拆解下来无非是几个清晰步骤的串联文本转向量、向量存数据库、查询相似项、根据阈值决策、调用 AI 总结。每一个环节都有成熟的开源工具或云服务Voyage AI, pgvector, Claude API可供使用。作为开发者我们的工作更多是当好“胶水”将这些组件以正确、稳健的方式连接起来并设计出贴合业务逻辑的处理流程。这个小小的自动化系统上线后我每天花在整理反馈上的时间从超过一小时减少到了几分钟。我可以快速识别出最受关注的前三大需求并根据优先级集中精力去解决它们。这让我重新找回了倾听用户的乐趣而不是被信息的海洋所淹没。或许这就是技术带来的最实在的“懒惰”与效率。

相关新闻