我们团队的内部组件库已经膨胀到了一个临界点。最初的基于元数据和组件名的文本搜索,在面对数百个功能相似但命名各异的组件时,效率低得令人发指。在一个敏捷回顾会上,前端团队提出的一个核心痛点是:“我无法找到一个我记得‘长什么样’或‘做什么用’的组件,除非我知道它的确切名字”。这个看似简单的需求,实际上指向了一个复杂的技术挑战:如何实现基于语义和视觉特征的组件搜索。
这个 sprint 的目标很明确:验证一个可行性方案,构建一个内部工具的原型,允许开发者用自然语言描述一个功能(例如:“一个带有关闭按钮和标题的警告框”),系统能返回功能和结构上最相似的几个组件。这本质上是一个小型的检索增强生成(RAG)问题,核心在于如何将非结构化的组件信息转化为可供检索的向量。
初步构想与技术选型决策
我们的初步构想是建立一个多模态的检索管道。对于每个组件,我们提取两部分信息:
- 结构与代码信息: 关键 prop 定义、组件文档中的描述性文本。
- 视觉信息: 组件渲染后的截图。
这些信息将被送入一个 embedding 模型,生成向量,并存入专门的向量数据库。搜索时,用户的查询文本也被转换成向量,然后在数据库中执行相似度搜索。
技术选型过程充满了现实考量:
后端框架: 团队技术栈以 Node.js 为主,NestJS 是我们的标准。其模块化的架构、依赖注入系统和对 TypeScript 的原生支持,非常适合构建这种结构清晰、可维护性要求高的后端服务。没有必要引入新的语言或框架,这符合敏捷开发中“保持简单”和快速迭代的原则。
向量数据库: 评估了几个选项。云服务如 Pinecone 虽好,但考虑到这是内部工具,数据完全私有化部署是优先选项,也能更好地控制成本。Milvus 作为一款开源、高性能的向量数据库,社区活跃,支持多种索引类型(如 HNSW),非常适合我们的场景。它能作为 Docker 容器运行,与我们现有的 DevOps 流程无缝集成。
前端组件样式: 搜索结果将以一个独立、可嵌入的面板形式存在。这个面板可能会被集成到多个不同的内部平台(文档站、Storybook、CI/CD 报告页面)。为了避免任何潜在的样式冲突,必须采用作用域隔离的 CSS 方案。CSS Modules 是最直接、最无依赖的选择。它在编译时生成唯一的类名,提供了真正的样式隔离,没有 CSS-in-JS 的运行时开销和复杂的依赖管理。
环境搭建:Dockerized 一体化开发环境
在真实项目中,保证所有团队成员开发环境的一致性至关重要。我们使用 Docker Compose 将整个后端服务栈编排起来。
docker-compose.yml
version: '3.8'
services:
# Milvus 向量数据库
milvus:
image: milvusdb/milvus:v2.3.3-cpu
container_name: milvus_db
ports:
- "19530:19530" # gRPC port
- "9091:9091" # HTTP port
volumes:
- ./volumes/milvus:/var/lib/milvus
environment:
- ETCD_ENDPOINTS=etcd:2379
- MINIO_ADDRESS=minio:9000
depends_on:
- etcd
- minio
healthcheck:
test: ["CMD", "/milvus/bin/milvus-health-check"]
interval: 10s
timeout: 5s
retries: 5
networks:
- component_search_net
# Milvus 依赖
etcd:
image: quay.io/coreos/etcd:v3.5.5
container_name: milvus_etcd
volumes:
- ./volumes/etcd:/etcd
command: etcd -advertise-client-urls=http://127.0.0.1:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd
networks:
- component_search_net
minio:
image: minio/minio:RELEASE.2023-03-20T20-16-18Z
container_name: milvus_minio
ports:
- "9000:9000"
- "9001:9001"
volumes:
- ./volumes/minio:/data
command: server /data --console-address ":9001"
environment:
- MINIO_ROOT_USER=minioadmin
- MINIO_ROOT_PASSWORD=minioadmin
networks:
- component_search_net
# 我们的 NestJS 应用
app:
container_name: nestjs_app
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
depends_on:
milvus:
condition: service_healthy
environment:
- MILVUS_HOST=milvus
- MILVUS_PORT=19530
# 假设我们有一个独立的 embedding 服务
- EMBEDDING_SERVICE_URL=http://embedding-service:5000/embed
networks:
- component_search_net
networks:
component_search_net:
driver: bridge
这个配置不仅启动了 Milvus 及其依赖,还定义了我们的 NestJS 应用,并通过 depends_on
和 healthcheck
确保了服务的启动顺序和健康状态,这是生产级部署的基础。
NestJS 后端:构建 RAG 管道核心
我们的 NestJS 应用是整个系统的中枢。它负责接收数据、调用 embedding 服务、与 Milvus 交互以及提供 API。
模块化设计
遵循 NestJS 的最佳实践,我们将所有与向量搜索相关的功能都封装在 VectorSearchModule
中。
src/vector-search/vector-search.module.ts
import { Module, HttpModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { MilvusService } from './milvus.service';
import { EmbeddingService } from './embedding.service';
import { ComponentSearchService } from './component-search.service';
import { ComponentSearchController } from './component-search.controller';
@Module({
imports: [
ConfigModule,
// HttpModule is crucial for making requests to the embedding service
HttpModule,
],
providers: [MilvusService, EmbeddingService, ComponentSearchService],
controllers: [ComponentSearchController],
})
export class VectorSearchModule {}
Milvus 服务封装
直接在业务逻辑中使用 Milvus SDK 是一个常见的错误。这会导致代码耦合度高,难以测试和维护。我们创建了一个 MilvusService
来封装所有底层交互。
src/vector-search/milvus.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MilvusClient, DataType, IndexType } from '@milvus-io/milvus';
// 定义我们组件向量的数据结构
export interface ComponentVector {
id: string; // 组件的唯一标识符
name: string; // 组件名称
description: string; // 从文档中提取的描述
vector: number[]; // embedding 向量
}
@Injectable()
export class MilvusService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(MilvusService.name);
private client: MilvusClient;
private readonly collectionName = 'ui_components';
private readonly vectorDimension = 768; // 必须与 embedding 模型的输出维度一致
constructor(private configService: ConfigService) {
const host = this.configService.get<string>('MILVUS_HOST');
const port = this.configService.get<string>('MILVUS_PORT');
this.client = new MilvusClient({ address: `${host}:${port}` });
}
async onModuleInit() {
await this.ensureCollection();
}
async onModuleDestroy() {
await this.client.close();
}
private async ensureCollection() {
try {
const hasCollection = await this.client.hasCollection({ collection_name: this.collectionName });
if (hasCollection) {
this.logger.log(`Collection '${this.collectionName}' already exists. Ensuring it is loaded.`);
await this.client.loadCollection({ collection_name: this.collectionName });
return;
}
this.logger.log(`Collection '${this.collectionName}' does not exist. Creating...`);
await this.client.createCollection({
collection_name: this.collectionName,
dimension: this.vectorDimension,
fields: [
{ name: 'id', data_type: DataType.VarChar, is_primary_key: true, max_length: 255 },
{ name: 'name', data_type: DataType.VarChar, max_length: 255 },
{ name: 'description', data_type: DataType.VarChar, max_length: 65535 },
{ name: 'vector', data_type: DataType.FloatVector, dim: this.vectorDimension },
],
});
this.logger.log(`Collection created. Creating index...`);
// HNSW 是一种性能和精度均衡的索引类型,适用于我们的场景
await this.client.createIndex({
collection_name: this.collectionName,
field_name: 'vector',
index_type: IndexType.HNSW,
metric_type: 'L2', // L2 (Euclidean distance) is good for many text embedding models
params: { M: 16, efConstruction: 64 },
});
this.logger.log(`Index created. Loading collection...`);
await this.client.loadCollection({ collection_name: this.collectionName });
} catch (error) {
this.logger.error('Failed to initialize Milvus collection', error.stack);
// 在生产环境中,这里应该抛出一个致命错误,阻止应用启动
throw error;
}
}
async insert(data: ComponentVector[]): Promise<any> {
if (!data || data.length === 0) {
return;
}
// Milvus SDK 要求字段按列组织
const preparedData = {
collection_name: this.collectionName,
fields_data: {
id: data.map(d => d.id),
name: data.map(d => d.name),
description: data.map(d => d.description),
vector: data.map(d => d.vector),
},
};
try {
const result = await this.client.insert(preparedData);
// 立刻 flush 保证数据可被搜索,这在小规模插入时可以接受
// 大规模批量导入时应考虑延迟 flush
await this.client.flush({ collection_names: [this.collectionName] });
return result;
} catch (error) {
this.logger.error(`Failed to insert data into Milvus: ${error.message}`, error.stack);
throw error;
}
}
async search(queryVector: number[], topK: number = 5): Promise<any> {
try {
const results = await this.client.search({
collection_name: this.collectionName,
vectors: [queryVector],
limit: topK,
// 返回结果中包含的字段
output_fields: ['id', 'name', 'description'],
search_params: {
anns_field: 'vector',
top_k: topK,
metric_type: 'L2',
params: { ef: 64 }, // ef 搜索参数,越高越准但越慢
},
});
return results.results;
} catch (error) {
this.logger.error(`Failed to search in Milvus: ${error.message}`, error.stack);
throw error;
}
}
}
这个服务处理了连接、集合和索引的自动创建、数据插入和搜索的逻辑。onModuleInit
钩子确保了服务在启动时就能准备好 Milvus 环境,这是一个非常稳健的设计。
业务逻辑编排
ComponentSearchService
是业务逻辑的核心,它编排了 EmbeddingService
和 MilvusService
。
src/vector-search/component-search.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { EmbeddingService } from './embedding.service';
import { MilvusService } from './milvus.service';
@Injectable()
export class ComponentSearchService {
private readonly logger = new Logger(ComponentSearchService.name);
constructor(
private readonly embeddingService: EmbeddingService,
private readonly milvusService: MilvusService,
) {}
async indexComponent(componentData: { id: string; name: string; description: string }): Promise<void> {
this.logger.log(`Indexing component: ${componentData.name}`);
const textToEmbed = `Component name: ${componentData.name}. Description: ${componentData.description}`;
// 1. 获取 embedding
const vector = await this.embeddingService.getEmbedding(textToEmbed);
// 2. 存入 Milvus
await this.milvusService.insert([{
id: componentData.id,
name: componentData.name,
description: componentData.description,
vector: vector,
}]);
this.logger.log(`Successfully indexed component: ${componentData.name}`);
}
async search(query: string, topK: number = 5): Promise<any> {
this.logger.log(`Performing search for query: "${query}"`);
// 1. 获取查询的 embedding
const queryVector = await this.embeddingService.getEmbedding(query);
// 2. 在 Milvus 中搜索
const searchResults = await this.milvusService.search(queryVector, topK);
// 3. 格式化返回结果
const formattedResults = searchResults.map(result => ({
id: result.id,
name: result.name,
description: result.description,
score: result.score, // score 代表距离,越小越相似
}));
this.logger.log(`Found ${formattedResults.length} results for query: "${query}"`);
return formattedResults;
}
}
API 接口暴露
最后,ComponentSearchController
将这些功能暴露为 RESTful API。
// ... DTOs for validation ...
import { Controller, Post, Body, Get, Query } from '@nestjs/common';
import { ComponentSearchService } from './component-search.service';
import { IndexComponentDto, SearchQueryDto } from './dto';
@Controller('search')
export class ComponentSearchController {
constructor(private readonly searchService: ComponentSearchService) {}
@Post('index')
async index(@Body() indexDto: IndexComponentDto): Promise<{ success: boolean }> {
await this.searchService.indexComponent(indexDto);
return { success: true };
}
@Get()
async search(@Query() queryDto: SearchQueryDto): Promise<any> {
return this.searchService.search(queryDto.q, queryDto.k);
}
}
流程图:请求的生命周期
一个搜索请求的完整流程可以用 Mermaid 图清晰地表示:
sequenceDiagram participant User participant Frontend as React Component participant Backend as NestJS API participant Embedding as Embedding Service participant Database as Milvus User->>Frontend: 输入搜索词 "一个带关闭按钮的模态框" Frontend->>Backend: GET /search?q=一个带关闭按钮的模态框 Backend->>Embedding: 请求文本 embedding Embedding-->>Backend: 返回 [0.1, 0.5, ..., 0.2] Backend->>Database: search(vector=[0.1, ...], top_k=5) Database-->>Backend: 返回 top 5 相似的组件向量及元数据 Backend->>Frontend: 返回 JSON 格式的搜索结果 Frontend->>User: 渲染结果列表
前端实现:CSS Modules 的威力
前端是一个 React 组件,它负责调用 API 并展示结果。这里的关键是使用 CSS Modules 来确保样式的绝对隔离。
文件结构:
/SearchResultPanel
- index.tsx
- style.module.css
style.module.css
/*
* 使用 kebab-case 命名是 CSS Modules 的一个好习惯。
* 所有这里的类名都会被编译成唯一的 hash 值。
*/
.search-panel {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
border: 1px solid #e0e0e0;
border-radius: 8px;
max-width: 600px;
background-color: #fff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.result-list {
list-style: none;
padding: 0;
margin: 0;
max-height: 400px;
overflow-y: auto;
}
.result-item {
display: flex;
flex-direction: column;
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
transition: background-color 0.2s ease;
}
.result-item:last-child {
border-bottom: none;
}
.result-item:hover {
background-color: #f9f9f9;
}
.item-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.item-name {
font-weight: 600;
color: #333;
font-size: 16px;
}
.item-score {
font-size: 12px;
color: #999;
background-color: #eee;
padding: 2px 6px;
border-radius: 4px;
}
.item-description {
margin-top: 4px;
font-size: 14px;
color: #666;
line-height: 1.5;
}
index.tsx
import React, { useState, useEffect } from 'react';
// 这里的 styles 对象就是 CSS Modules 魔法的核心
import styles from './style.module.css';
interface SearchResult {
id: string;
name: string;
description: string;
score: number;
}
interface SearchResultPanelProps {
query: string;
}
export const SearchResultPanel: React.FC<SearchResultPanelProps> = ({ query }) => {
const [results, setResults] = useState<SearchResult[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!query) {
setResults([]);
return;
}
const fetchResults = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}&k=5`);
if (!response.ok) {
throw new Error('Network response was not ok');
}
const data = await response.json();
setResults(data);
} catch (e) {
setError(e.message || 'Failed to fetch search results.');
console.error(e);
} finally {
setIsLoading(false);
}
};
// 使用 debounce 防止过于频繁的 API 调用
const debounceTimer = setTimeout(() => {
fetchResults();
}, 300);
return () => clearTimeout(debounceTimer);
}, [query]);
// 注意这里的 className={styles.searchPanel} 等用法
// React 会渲染出 <div class="style_search-panel__1a2b3c"> 这样的 HTML
return (
<div className={styles.searchPanel}>
{isLoading && <div>Loading...</div>}
{error && <div>Error: {error}</div>}
{!isLoading && !error && results.length === 0 && query && <div>No results found.</div>}
<ul className={styles.resultList}>
{results.map((item) => (
<li key={item.id} className={styles.resultItem}>
<div className={styles.itemHeader}>
<span className={styles.itemName}>{item.name}</span>
<span className={styles.itemScore}>Score: {item.score.toFixed(4)}</span>
</div>
<p className={styles.itemDescription}>{item.description}</p>
</li>
))}
</ul>
</div>
);
};
这里的关键是 import styles from './style.module.css';
。构建工具(如 Webpack 或 Vite)会处理这个 CSS 文件,确保 styles.searchPanel
这样的引用会映射到一个全局唯一的类名。这意味着,即使宿主页面中也存在一个名为 .search-panel
的全局样式,也绝不会影响到我们的组件,反之亦然。这对于构建可复用、可嵌入的微前端组件或库组件来说,是至关重要的。
局限性与未来迭代路径
我们在这个敏捷 sprint 中成功验证了核心技术链路,但距离一个完善的生产级工具还有距离。
首先,当前的 embedding 模型是一个通用的文本模型。它的效果好坏严重依赖于组件描述的质量。下一步需要引入多模态模型(如 CLIP),将组件的截图也转换成向量,与文本向量融合,从而实现真正的视觉+语义搜索。
其次,数据索引流程目前是手动的。一个完整的解决方案需要与 CI/CD 流程深度集成。当一个新组件被合并到主分支时,CI 管道应自动触发一个任务,截取组件快照,提取元数据,调用我们的 NestJS 服务的 /index
接口,实现索引的自动化更新。
最后, Milvus 的性能调优尚未进行。对于大规模数据集,需要对 index_params
(如 efConstruction
)和 search_params
(如 ef
)进行精细调整,以在召回率、延迟和资源消耗之间找到最佳平衡点。同时,当前的 flush 策略过于频繁,对于批量导入场景需要优化为定时或定量 flush。