构建基于 NestJS 与 Milvus 的 RAG 管道以实现前端组件的语义化搜索


我们团队的内部组件库已经膨胀到了一个临界点。最初的基于元数据和组件名的文本搜索,在面对数百个功能相似但命名各异的组件时,效率低得令人发指。在一个敏捷回顾会上,前端团队提出的一个核心痛点是:“我无法找到一个我记得‘长什么样’或‘做什么用’的组件,除非我知道它的确切名字”。这个看似简单的需求,实际上指向了一个复杂的技术挑战:如何实现基于语义和视觉特征的组件搜索。

这个 sprint 的目标很明确:验证一个可行性方案,构建一个内部工具的原型,允许开发者用自然语言描述一个功能(例如:“一个带有关闭按钮和标题的警告框”),系统能返回功能和结构上最相似的几个组件。这本质上是一个小型的检索增强生成(RAG)问题,核心在于如何将非结构化的组件信息转化为可供检索的向量。

初步构想与技术选型决策

我们的初步构想是建立一个多模态的检索管道。对于每个组件,我们提取两部分信息:

  1. 结构与代码信息: 关键 prop 定义、组件文档中的描述性文本。
  2. 视觉信息: 组件渲染后的截图。

这些信息将被送入一个 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_onhealthcheck 确保了服务的启动顺序和健康状态,这是生产级部署的基础。

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 是业务逻辑的核心,它编排了 EmbeddingServiceMilvusService

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。


  目录