最终效果:你只用说一句大白话,系统自动帮你筛选符合条件的初创公司,不用写一行复杂的ES查询语句。

示例:输入「找A/B轮融资800-2500万美元、月营收超50万美元的上海SaaS公司」,直接返回整理好的企业清单。


一、先搞懂:这个项目到底是怎么跑的?

1.1 核心痛点解决

风投/金融从业者找项目时,要手动在数据库里组合几十个筛选条件,又慢又容易漏机会。现在要做的这个工具,就是把「自然语言→智能筛选→结果整理」全流程自动化。

1.2 全流程

整个系统就像一条自动化流水线,每个工位只干一件事,全程用一个「共享记事本」传递信息,不会丢数据:


用户说一句大白话 → 【工位1:判断意图】AI看你是查「财务/融资」还是「行业/地域」→ 【流水线分叉口】走对应分支
→ 【工位2:提取参数】AI从你的话里抠出所有筛选条件,填到提前写好的「填空题模板」里
→ 【工位3:执行查询】把填好的模板发给Elasticsearch(以下简称ES),从数据库里找符合条件的公司
→ 【工位4:整理结果】把查出来的数据排版成清晰可读的格式,返回给你

1.3 核心概念

专业术语 大白话类比 核心作用
LangGraph 节点 流水线的单个工位 每个节点只干一件固定的事(比如判断意图、提取参数),干完就把结果写到共享记事本里
LangGraph 状态(State) 流水线的「共享记事本+传料盒」 全程只有这一个本子,所有节点都能读、能写,从用户输入到最终结果,所有信息都存在这里,不会丢
条件边 流水线的分叉口 根据「意图判断」的结果,自动选对应的加工路线,查财务就走财务分支,查行业就走行业分支
ES 搜索模板 标准化的「填空题试卷」 我们提前把ES查询的固定框架写好,AI只需要往空里填关键词/数字,不用自己写查询语句,从根源上避免AI写的DSL语法错误
混合检索(RRF) 找对象的双重标准 既要符合「硬性条件」(比如融资轮次、地域=结构化过滤),又要「聊得来」(比如业务描述匹配=语义向量搜索),两个结果融合,找出来的内容最准
结构化输出(Zod) 给AI定的「答题规矩」 强制AI必须返回我们规定的格式,不能乱说话、不能答非所问,保证代码能正常读取AI的结果

二、前置准备:

每一步都有「操作命令+验证成功的方法」,跟着做不会卡壳

2.1 步骤1:安装Node.js(代码运行环境)

  1. 下载地址:Node.js官网,选18.x及以上的LTS版本(稳定版),一路默认安装即可。

  2. 验证是否安装成功:打开终端(Windows用CMD/PowerShell,Mac用终端),输入以下命令,能输出版本号就是成功:

    
    node -v  # 输出v18.0.0以上即可
    npm -v   # 对应输出版本号即可
    

2.2 步骤2:本地安装Elasticsearch 9.x

推荐用Docker安装,一键启动,不会踩环境坑;没有Docker的话,下面也给了二进制安装方法

方式一:Docker安装(如遇到问题可以参照前面博文)
  1. 先安装Docker:Docker官网下载,一路默认安装,启动Docker。

  2. 打开终端,输入以下命令,一键拉取ES 9.x镜像并启动:

    
    # 拉取ES 9.x官方镜像
    docker pull docker.elastic.co/elasticsearch/elasticsearch:9.0.0
    
    # 启动单节点ES容器,配置端口、安全、内存
    docker run -d \
      --name es-local \
      -p 9200:9200 \
      -e "discovery.type=single-node" \
      -e "xpack.security.enabled=true" \
      -e "ES_JAVA_OPTS=-Xms2g -Xmx2g" \
      docker.elastic.co/elasticsearch/elasticsearch:9.0.0
    
  3. 验证是否启动成功:

    
    # 等待10秒,输入这个命令,能返回一串JSON,就是启动成功了
    curl http://localhost:9200
    
  4. 重置elastic用户密码(后面要用):

    
    # 容器内执行密码重置,会自动生成一个随机密码,一定要记下来!
    docker exec -it es-local bin/elasticsearch-reset-password -u elastic -a
    

    示例输出:Password for the [elastic] user successfully reset. New value: abc123xyz,把这个密码记好,后面一直要用。

方式二:二进制包安装(不用Docker)
  1. 下载地址:ES 9.x官网下载,选对应系统的版本。

  2. 解压后,打开config/elasticsearch.yml,把下面的内容复制进去,覆盖默认配置:

    
    cluster.name: my-local-cluster
    node.name: node-1
    network.host: 0.0.0.0
    discovery.type: single-node
    xpack.security.enabled: true
    
  3. 启动ES:

    • Windows:双击bin/elasticsearch.bat

    • Mac/Linux:终端进入解压目录,输入./bin/elasticsearch

  4. 重置密码:新开一个终端,进入ES的bin目录,输入./elasticsearch-reset-password -u elastic -a,记下生成的密码。

2.3 步骤3:获取2个必备的API Key

(1)生成ES的API Key(代码连接ES用)

新手推荐用Kibana可视化操作,不会写错命令;也可以用curl命令

  1. 先安装Kibana(和ES版本必须一致,都是9.0.0):

    
    # Docker安装Kibana
    docker pull docker.elastic.co/kibana/kibana:9.0.0
    docker run -d --name kibana-local -p 5601:5601 -e "ELASTICSEARCH_HOSTS=http://host.docker.internal:9200" docker.elastic.co/kibana/kibana:9.0.0
    
  2. 打开浏览器,访问http://localhost:5601,用户名是elastic,密码是刚才重置的ES密码。

  3. 左侧菜单找到「Management → Dev Tools」,在控制台输入以下命令,点击运行:

    
    POST /_security/api_key
    {
      "name": "financial-search-app",
      "role_descriptors": {
        "search-role": {
          "cluster": ["all"],
          "index": [
            {
              "names": ["vc_startups*"],
              "privileges": ["read", "write", "manage"]
            }
          ]
        }
      }
    }
    
  4. 运行后会返回结果,把idapi_key拼起来,格式是id:api_key,记下来,后面要用。

示例:id是abc123,api_key是xyz789,最终的Key就是abc123:xyz789

(2)获取OpenAI API Key(AI大模型用)
  1. 打开OpenAI平台,注册登录,在「API Keys」里生成一个新的Key,记下来。

  2. 国内用户替代方案:可以换成阿里云通义千问、智谱AI等国内大模型,后面会给替换代码。


三、项目搭建:从零开始,每一步都讲透

3.1 步骤1:初始化项目,安装依赖

  1. 新建一个项目文件夹,比如叫es-financial-search,终端进入这个文件夹:

    
    mkdir es-financial-search && cd es-financial-search
    
  2. 初始化Node.js项目,一路回车即可:

    
    npm init -y
    
  3. 安装所有依赖(每个依赖的作用都标清楚了,不是黑盒):

    
    # 安装核心依赖
    npm install @elastic/elasticsearch @langchain/langgraph @langchain/openai @langchain/core dotenv zod openai
    
    # 安装TypeScript开发依赖(直接运行TS代码,不用编译)
    npm install --save-dev @types/node tsx typescript
    
依赖包 大白话作用
@elastic/elasticsearch ES官方客户端,代码和ES数据库之间的「翻译官」
@langchain/langgraph LangGraph核心库,用来搭我们的自动化流水线
@langchain/openai 让LangGraph能调用OpenAI的大模型
dotenv 把密钥存在.env文件里,不会硬写在代码里,安全
zod 给AI定「答题规矩」,强制AI返回固定格式,不会乱输出
tsx 直接运行TypeScript代码,不用先编译成JS,新手省事
  1. 配置TypeScript(新手直接复制,不会踩坑):

终端输入npx tsc --init生成配置文件,然后打开tsconfig.json,把里面的内容全替换成下面的:


{
  "compilerOptions": {
    "target": "ES2020",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
    "resolveJsonModule": true
  },
  "include": ["*.ts"],
  "exclude": ["node_modules"]
}

3.2 步骤2:创建4个核心文件

在项目文件夹里,新建以下4个文件,后面会逐个写内容:

  1. .env:存所有密钥和配置

  2. dataset.json:初创公司的测试数据

  3. elasticsearchSetup.ts:ES数据库的所有配置(建表、模板、导入数据)

  4. main.ts:LangGraph流水线的核心代码


3.3 文件1:.env(配置文件,新手直接复制)

把里面的内容替换成你自己的Key,不要加多余的引号、空格


# Elasticsearch 配置
ELASTICSEARCH_ENDPOINT="http://localhost:9200"
ELASTICSEARCH_API_KEY="你刚才生成的ES API Key(格式:id:api_key)"

# OpenAI 配置
OPENAI_API_KEY="你的OpenAI API Key"

# 索引和模板名称(不用改,直接用)
INDEX_NAME="vc_startups"
INVESTMENT_TEMPLATE="investment_focused_template"
MARKET_TEMPLATE="market_focused_template"

3.4 文件2:dataset.json(测试数据集,直接复制)

这里给10条测试数据,你后面可以自己加更多,格式完全一致即可


[
  {
    "company_name": "TechFlow",
    "location": "San Francisco, CA",
    "industry": "logistics",
    "business_model": "B2B",
    "funding_stage": "Series A",
    "funding_amount": 8000000,
    "monthly_revenue": 500000,
    "employee_count": 45,
    "lead_investor": "Sequoia Capital",
    "description": "TechFlow optimizes supply chain operations using AI-powered route optimization and real-time tracking. Founded in 2023, shows remarkable growth with $500K monthly revenue."
  },
  {
    "company_name": "FinanceAI",
    "location": "San Francisco, CA",
    "industry": "fintech",
    "business_model": "B2C",
    "funding_stage": "Series B",
    "funding_amount": 25000000,
    "monthly_revenue": 1200000,
    "employee_count": 120,
    "lead_investor": "Tiger Global Management",
    "description": "FinanceAI provides AI-powered investment advisory services to retail investors. Uses machine learning to analyze market trends with over 100,000 active users."
  },
  {
    "company_name": "UrbanMobility",
    "location": "New York, NY",
    "industry": "logistics",
    "business_model": "B2B2C",
    "funding_stage": "Series B",
    "funding_amount": 15000000,
    "monthly_revenue": 750000,
    "employee_count": 78,
    "lead_investor": "Kleiner Perkins",
    "description": "UrbanMobility revolutionizes urban transportation through autonomous delivery drones and smart logistics hubs. Partners with major retailers for same-day delivery across Manhattan and Brooklyn."
  },
  {
    "company_name": "HealthTech Solutions",
    "location": "Boston, MA",
    "industry": "healthcare",
    "business_model": "B2B",
    "funding_stage": "Series B",
    "funding_amount": 18000000,
    "monthly_revenue": 900000,
    "employee_count": 95,
    "lead_investor": "General Catalyst",
    "description": "HealthTech Solutions develops medical devices and software for remote patient monitoring. Comprehensive telehealth platform reducing hospital readmissions by 30%."
  },
  {
    "company_name": "DataViz",
    "location": "New York, NY",
    "industry": "enterprise software",
    "business_model": "B2B",
    "funding_stage": "Series A",
    "funding_amount": 10000000,
    "monthly_revenue": 450000,
    "employee_count": 42,
    "lead_investor": "Battery Ventures",
    "description": "DataViz creates intuitive data visualization tools for enterprise customers. No-code platform allows business users to create dashboards without technical expertise."
  },
  {
    "company_name": "CryptoWallet",
    "location": "Miami, FL",
    "industry": "fintech",
    "business_model": "B2C",
    "funding_stage": "Series B",
    "funding_amount": 16000000,
    "monthly_revenue": 820000,
    "employee_count": 73,
    "lead_investor": "Coinbase Ventures",
    "description": "CryptoWallet provides secure digital wallet solutions for cryptocurrency trading and storage. Multi-chain support with enterprise-grade security features."
  },
  {
    "company_name": "GreenEnergy",
    "location": "Austin, TX",
    "industry": "clean energy",
    "business_model": "B2B",
    "funding_stage": "Series A",
    "funding_amount": 12000000,
    "monthly_revenue": 600000,
    "employee_count": 55,
    "lead_investor": "Breakthrough Energy Ventures",
    "description": "GreenEnergy develops commercial solar energy solutions for small and medium businesses. Helps companies reduce energy costs by 40% on average."
  },
  {
    "company_name": "EdTech Labs",
    "location": "Boston, MA",
    "industry": "education",
    "business_model": "B2C",
    "funding_stage": "Series A",
    "funding_amount": 7500000,
    "monthly_revenue": 350000,
    "employee_count": 38,
    "lead_investor": "Owl Ventures",
    "description": "EdTech Labs creates AI-powered learning platforms for K12 students. Personalized curriculum that adapts to each student's learning pace and style."
  },
  {
    "company_name": "SaaSFlow",
    "location": "Shanghai, China",
    "industry": "SaaS",
    "business_model": "B2B",
    "funding_stage": "Series A",
    "funding_amount": 10000000,
    "monthly_revenue": 550000,
    "employee_count": 60,
    "lead_investor": "Sequoia Capital China",
    "description": "SaaSFlow provides one-stop enterprise management SaaS for small and medium businesses. Covers CRM, HR, finance and inventory management in a single platform."
  },
  {
    "company_name": "FoodTech",
    "location": "Los Angeles, CA",
    "industry": "food & beverage",
    "business_model": "B2B",
    "funding_stage": "Series B",
    "funding_amount": 22000000,
    "monthly_revenue": 880000,
    "employee_count": 85,
    "lead_investor": "a16z",
    "description": "FoodTech develops plant-based food ingredients for restaurants and food manufacturers. Helps brands create healthier, more sustainable menu items."
  }
]

3.5 文件3:elasticsearchSetup.ts(ES核心配置)

这个文件干3件事:① 给ES建表(含向量字段);② 创建「填空题模板」;③ 把测试数据导入ES(含向量生成)


import { Client } from "@elastic/elasticsearch";
import * as fs from "fs";
import * as path from "path";
import OpenAI from "openai";
import dotenv from "dotenv";

// 加载.env里的配置
dotenv.config();

// ========== 1. 初始化ES客户端(和ES数据库建立连接) ==========
export const esClient = new Client({
  node: process.env.ELASTICSEARCH_ENDPOINT, // ES的地址
  auth: { apiKey: process.env.ELASTICSEARCH_API_KEY! }, // 刚才生成的API Key
  tls: { rejectUnauthorized: false } // 本地开发关闭证书校验,生产环境要打开
});

// ========== 2. 初始化OpenAI客户端(生成向量用) ==========
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

// ========== 3. 常量定义(从.env里读取,不用改) ==========
export const INDEX_NAME = process.env.INDEX_NAME!;
export const INVESTMENT_TEMPLATE = process.env.INVESTMENT_TEMPLATE!;
export const MARKET_TEMPLATE = process.env.MARKET_TEMPLATE!;

// ========== 功能1:创建ES索引(相当于数据库里的「表」) ==========
export async function createIndex() {
  // 先检查表是否存在,如果存在就删掉重建(开发环境方便测试)
  const indexExists = await esClient.indices.exists({ index: INDEX_NAME });
  if (indexExists) {
    console.log(`🗑️ 索引${INDEX_NAME}已存在,删除重建...`);
    await esClient.indices.delete({ index: INDEX_NAME });
  }

  // 定义表结构(每个字段是什么类型,干什么用的)
  const indexMapping = {
    properties: {
      company_name: { type: "text" }, // 公司名称,文本类型
      location: { type: "keyword" }, // 地域,keyword类型(用于精确匹配筛选)
      industry: { type: "keyword" }, // 行业,keyword类型
      business_model: { type: "keyword" }, // 商业模式,keyword类型
      funding_stage: { type: "keyword" }, // 融资轮次,keyword类型
      funding_amount: { type: "long" }, // 融资金额,数字类型(单位:美元)
      monthly_revenue: { type: "long" }, // 月营收,数字类型(单位:美元)
      employee_count: { type: "integer" }, // 员工数,整数类型
      lead_investor: { type: "keyword" }, // 领投方,keyword类型
      description: { type: "text" }, // 公司描述,文本类型(用于语义搜索)
      // 重点:向量字段,用于语义搜索,匹配公司描述的含义
      semantic_field: {
        type: "dense_vector", // 向量类型
        dims: 1536, // 向量维度,和OpenAI embedding模型的维度一致
        index: true, // 开启索引,用于快速搜索
        similarity: "cosine" // 相似度计算方式,用余弦相似度(最常用)
      }
    }
  };

  // 执行创建索引
  await esClient.indices.create({
    index: INDEX_NAME,
    body: { mappings: indexMapping }
  });
  console.log(`✅ 索引${INDEX_NAME}创建成功!`);
}

// ========== 功能2:创建搜索模板(核心!填空题模板) ==========
// 原理:我们提前写好ES查询的固定框架,AI只需要往空里填值,不会写语法错误的查询
export async function createSearchTemplates() {
  // 模板1:投资型查询模板(聚焦融资、营收、财务指标)
  const investmentTemplate = {
    script: {
      lang: "mustache", // ES模板的固定语法,类似填空题
      source: `{
        "size": 5, // 最多返回5条结果
        "retriever": {
          "rrf": { // RRF混合检索:同时用「语义搜索」和「结构化过滤」,结果融合
            "retrievers": [
              // 第一个检索器:语义搜索(匹配公司描述的含义)
              {
                "standard": {
                  "query": {
                    "semantic": {
                      "field": "semantic_field", // 用哪个向量字段
                      "query": "{{query_text}}" // 填空:用户的原始查询
                    }
                  }
                }
              },
              // 第二个检索器:结构化过滤(精确匹配财务条件)
              {
                "standard": {
                  "query": {
                    "bool": {
                      "filter": [
                        // 只有用户提到了融资轮次,才会生成这个过滤条件
                        {{#funding_stage}}
                        {"terms": {"funding_stage": {{#toJson}}funding_stage{{/toJson}}}},
                        {{/funding_stage}}
                        // 只有用户提到了融资金额下限,才会生成这个过滤条件
                        {{#funding_amount_gte}}
                        {"range": {"funding_amount": {
                          "gte": {{funding_amount_gte}}
                          {{#funding_amount_lte}},"lte": {{funding_amount_lte}}{{/funding_amount_lte}}
                        }}},
                        {{/funding_amount_gte}}
                        // 只有用户提到了月营收下限,才会生成这个过滤条件
                        {{#monthly_revenue_gte}}
                        {"range": {"monthly_revenue": {
                          "gte": {{monthly_revenue_gte}}
                          {{#monthly_revenue_lte}},"lte": {{monthly_revenue_lte}}{{/monthly_revenue_lte}}
                        }}},
                        {{/monthly_revenue_gte}}
                        // 只有用户提到了领投方,才会生成这个过滤条件
                        {{#lead_investor}}
                        {"terms": {"lead_investor": {{#toJson}}lead_investor{{/toJson}}}}
                        {{/lead_investor}}
                      ]
                    }
                  }
                }
              }
            ],
            "rank_window_size": 100, // 参与融合的结果数量
            "rank_constant": 20 // RRF调优参数,固定20即可
          }
        }
      }`
    }
  };

  // 模板2:市场型查询模板(聚焦行业、地域、商业模式)
  const marketTemplate = {
    script: {
      lang: "mustache",
      source: `{
        "size": 5,
        "retriever": {
          "rrf": {
            "retrievers": [
              {
                "standard": {
                  "query": {
                    "semantic": {
                      "field": "semantic_field",
                      "query": "{{query_text}}"
                    }
                  }
                }
              },
              {
                "standard": {
                  "query": {
                    "bool": {
                      "filter": [
                        {{#industry}}
                        {"terms": {"industry": {{#toJson}}industry{{/toJson}}}},
                        {{/industry}}
                        {{#location}}
                        {"terms": {"location": {{#toJson}}location{{/toJson}}}},
                        {{/location}}
                        {{#business_model}}
                        {"terms": {"business_model": {{#toJson}}business_model{{/toJson}}}}
                        {{/business_model}}
                      ]
                    }
                  }
                }
              }
            ],
            "rank_window_size": 50,
            "rank_constant": 10
          }
        }
      }`
    }
  };

  // 把两个模板注册到ES里
  await esClient.putScript({ id: INVESTMENT_TEMPLATE, body: investmentTemplate });
  await esClient.putScript({ id: MARKET_TEMPLATE, body: marketTemplate });
  console.log("✅ 投资型/市场型搜索模板创建成功!");
}

// ========== 功能3:导入测试数据到ES(含向量生成) ==========
export async function ingestDocuments() {
  // 读取dataset.json里的测试数据
  const datasetPath = path.join(__dirname, "dataset.json");
  const rawData = fs.readFileSync(datasetPath, "utf-8");
  const documents = JSON.parse(rawData);

  // 批量处理数据:给每条数据生成向量,然后导入ES
  const bulkOperations: any[] = [];
  for (const doc of documents) {
    // 调用OpenAI的embedding接口,给公司描述生成向量
    const embeddingResult = await openai.embeddings.create({
      model: "text-embedding-3-small",
      input: doc.description // 用公司描述生成向量,用于语义搜索
    });
    // 把生成的向量加到数据里
    doc.semantic_field = embeddingResult.data[0].embedding;

    // 构建批量导入的操作(ES的bulk语法固定格式)
    bulkOperations.push({ index: { _index: INDEX_NAME } });
    bulkOperations.push(doc);
  }

  // 执行批量导入
  await esClient.bulk({ body: bulkOperations, refresh: true });
  console.log(`✅ 成功导入${documents.length}条初创公司数据!`);
}

3.6 文件4:main.ts(LangGraph流水线核心代码)

这个文件就是搭我们的自动化流水线,定义每个工位(节点)干什么,流水线怎么流转,逐行注释无黑盒


import { StateGraph, Annotation, START, END } from "@langchain/langgraph";
import { ChatOpenAI } from "@langchain/openai";
import { z } from "zod";
import { writeFileSync } from "fs";
import dotenv from "dotenv";
// 导入ES里的配置和方法
import {
  esClient,
  createIndex,
  createSearchTemplates,
  ingestDocuments,
  INDEX_NAME,
  INVESTMENT_TEMPLATE,
  MARKET_TEMPLATE
} from "./elasticsearchSetup.js";

// 加载.env配置
dotenv.config();

// ========== 1. 初始化大模型 ==========
const llm = new ChatOpenAI({
  model: "gpt-4o-mini", // 用这个模型性价比最高,速度快、效果好
  temperature: 0 // 温度设为0,让AI输出更稳定,不会乱发挥
});

// ========== 2. 定义「共享记事本」(LangGraph State) ==========
// 整个流水线所有节点都能读、能写这个记事本,全程信息不丢失
const VCState = Annotation.Root({
  input: Annotation<string>(), // 【初始值】用户输入的自然语言查询
  searchStrategy: Annotation<string>(), // 节点1输出:AI判断的搜索策略(investment_focused/market_focused)
  searchParams: Annotation<any>(), // 节点2输出:填到模板里的筛选参数
  results: Annotation<any[]>(), // 节点3输出:ES查询返回的结果
  final: Annotation<string>() // 节点4输出:最终整理好的可读结果
});

// ========== 3. 通用辅助函数 ==========
// 辅助1:保存流水线的可视化图(png格式,能看到整个流程的流转)
async function saveWorkflowGraph(app: any) {
  try {
    const drawableGraph = app.getGraph();
    const imageBuffer = await drawableGraph.drawMermaidPng();
    writeFileSync("./workflow.png", Buffer.from(await imageBuffer.arrayBuffer()));
    console.log("📊 流水线流程图已保存为 workflow.png");
  } catch (error) {
    console.log("⚠️ 无法保存流程图:", error);
  }
}

// 辅助2:从ES里动态获取所有可用的筛选值(比如有哪些行业、哪些地域)
// 作用:把这些值给AI,AI就不会瞎编不存在的行业/地域,减少幻觉
async function getAvailableFilters() {
  try {
    const response = await esClient.search({
      index: INDEX_NAME,
      size: 0, // 不返回具体数据,只返回统计结果
      aggs: { // 聚合统计,获取所有可选值
        industries: { terms: { field: "industry", size: 100 } }, // 所有行业
        locations: { terms: { field: "location", size: 100 } }, // 所有地域
        funding_stages: { terms: { field: "funding_stage", size: 20 } }, // 所有融资轮次
        business_models: { terms: { field: "business_model", size: 10 } }, // 所有商业模式
        lead_investors: { terms: { field: "lead_investor", size: 100 } }, // 所有领投方
        funding_stats: { stats: { field: "funding_amount" } } // 融资金额的范围(最大/最小)
      }
    });
    return response.aggregations;
  } catch (error) {
    console.error("❌ 获取筛选值失败:", error);
    return {};
  }
}

// ========== 4. 定义流水线的每个工位(节点) ==========
// ------------------------------
// 节点1:判断搜索策略(意图识别)
// 作用:看用户的查询是查「财务/融资」还是「行业/地域」,决定走哪个分支
// ------------------------------
async function decideSearchStrategy(state: typeof VCState.State) {
  // 用Zod给AI定规矩:必须返回这个格式的结果,不能乱说话
  const SearchDecisionSchema = z.object({
    search_type: z.enum(["investment_focused", "market_focused"]), // 二选一
    reasoning: z.string() // 解释为什么选这个策略,方便我们调试
  });

  // 让AI必须按上面的Schema返回结果
  const decisionLLM = llm.withStructuredOutput(SearchDecisionSchema);
  // 获取ES里所有可用的筛选值,给AI参考
  const availableFilters = await getAvailableFilters();

  // 给AI的提示词,说清楚规则
  const prompt = `
用户的查询是:"${state.input}"
当前数据库里可用的筛选值:${JSON.stringify(availableFilters, null, 2)}

请从两个搜索策略里选一个最合适的:
1. investment_focused:用户的查询涉及融资轮次、融资金额、月营收、领投方等财务/投资相关的指标
2. market_focused:用户的查询涉及行业、地域、商业模式、市场细分等市场相关的维度

只返回规定的JSON格式,不要加其他内容。
  `;

  try {
    const result = await decisionLLM.invoke(prompt);
    console.log(`🤔 策略判断结果:${result.search_type} - ${result.reasoning}`);
    // 把结果写到共享记事本里
    return { searchStrategy: result.search_type };
  } catch (error) {
    console.error("❌ 策略判断失败,默认使用投资型策略:", error);
    return { searchStrategy: "investment_focused" };
  }
}

// ------------------------------
// 通用函数:从用户查询里提取筛选参数
// 作用:AI从用户的话里抠出所有筛选条件,填到模板里
// ------------------------------
async function extractFilterParams(input: string) {
  // 用Zod定规矩:AI必须返回这个格式的参数
  const FilterSchema = z.object({
    // 投资型参数
    funding_stage: z.array(z.string()).default([]), // 融资轮次
    funding_amount_gte: z.number().optional(), // 融资金额下限
    funding_amount_lte: z.number().optional(), // 融资金额上限
    monthly_revenue_gte: z.number().optional(), // 月营收下限
    monthly_revenue_lte: z.number().optional(), // 月营收上限
    lead_investor: z.array(z.string()).default([]), // 领投方
    // 市场型参数
    industry: z.array(z.string()).default([]), // 行业
    location: z.array(z.string()).default([]), // 地域
    business_model: z.array(z.string()).default([]) // 商业模式
  });

  const extractorLLM = llm.withStructuredOutput(FilterSchema);
  const availableFilters = await getAvailableFilters();

  const prompt = `
从用户的查询里提取所有筛选参数:"${input}"
数据库里可用的选项:${JSON.stringify(availableFilters, null, 2)}

规则:
1. 只提取用户明确提到的参数,没提到的不要填,留空
2. 必须从上面的可用选项里提取,不要瞎编不存在的值
3. 金额单位统一为美元,比如用户说800万美元,就填8000000

只返回规定的JSON格式,不要加其他内容。
  `;

  return await extractorLLM.invoke(prompt);
}

// ------------------------------
// 节点2A:准备投资型搜索的参数
// 作用:用户查财务相关的,就走这个节点,提取参数,填到投资型模板里
// ------------------------------
async function prepareInvestmentSearch(state: typeof VCState.State) {
  console.log("💰 正在准备投资型搜索参数...");
  try {
    // 提取用户查询里的参数
    const filterParams = await extractFilterParams(state.input);
    // 组装模板需要的参数
    const searchParams = {
      template_id: INVESTMENT_TEMPLATE, // 用哪个模板
      query_text: state.input, // 用户的原始查询(用于语义搜索)
      ...filterParams // 提取的筛选参数
    };
    // 写到共享记事本里
    return { searchParams };
  } catch (error) {
    console.error("❌ 投资型参数准备失败:", error);
    return { searchParams: {} };
  }
}

// ------------------------------
// 节点2B:准备市场型搜索的参数
// 作用:用户查行业/地域相关的,就走这个节点,提取参数,填到市场型模板里
// ------------------------------
async function prepareMarketSearch(state: typeof VCState.State) {
  console.log("🔍 正在准备市场型搜索参数...");
  try {
    const filterParams = await extractFilterParams(state.input);
    const searchParams = {
      template_id: MARKET_TEMPLATE,
      query_text: state.input,
      ...filterParams
    };
    return { searchParams };
  } catch (error) {
    console.error("❌ 市场型参数准备失败:", error);
    return { searchParams: {} };
  }
}

// ------------------------------
// 节点3:执行ES搜索
// 作用:把填好的模板发给ES,执行查询,拿到结果
// ------------------------------
async function executeSearch(state: typeof VCState.State) {
  const { searchParams } = state;
  try {
    // 【调试用】先渲染模板,看看最终生成的ES查询语句是什么样的
    const renderedTemplate = await esClient.renderSearchTemplate({
      id: searchParams.template_id,
      params: searchParams
    });
    console.log("📋 最终生成的ES查询语句:\n", JSON.stringify(renderedTemplate.template_output, null, 2));

    // 执行搜索,拿到结果
    const searchResult = await esClient.searchTemplate({
      index: INDEX_NAME,
      id: searchParams.template_id,
      params: searchParams
    });

    // 把结果里的公司数据提取出来,写到共享记事本里
    return { results: searchResult.hits.hits.map((hit: any) => hit._source) };
  } catch (error) {
    console.error("❌ ES搜索失败:", error);
    return { results: [] };
  }
}

// ------------------------------
// 节点4:整理结果,生成可读的输出
// 作用:把ES返回的原始数据,排版成清晰好看的格式
// ------------------------------
async function visualizeResults(state: typeof VCState.State) {
  const results = state.results || [];
  let formattedOutput = `🎯 找到${results.length}家符合条件的初创公司:\n\n`;

  // 遍历结果,逐条排版
  results.forEach((startup, index) => {
    formattedOutput += `${index + 1}. **${startup.company_name}**\n`;
    formattedOutput += `   📍 地域:${startup.location} | 🏢 行业:${startup.industry} | 💼 商业模式:${startup.business_model}\n`;
    formattedOutput += `   💰 融资轮次:${startup.funding_stage} | 融资金额:$${(startup.funding_amount / 1000000).toFixed(1)}M\n`;
    formattedOutput += `   👥 员工数:${startup.employee_count}人 | 📈 月营收:$${(startup.monthly_revenue / 1000).toFixed(0)}K\n`;
    formattedOutput += `   🏦 领投方:${startup.lead_investor}\n`;
    formattedOutput += `   📝 公司描述:${startup.description}\n\n`;
  });

  // 把最终结果写到共享记事本里
  return { final: formattedOutput };
}

// ========== 5. 组装流水线,定义流转规则 ==========
async function main() {
  // 第一步:初始化ES环境(建表、建模板、导入数据)
  console.log("===== 开始初始化ES环境 =====");
  await createIndex();
  await createSearchTemplates();
  await ingestDocuments();
  console.log("===== ES环境初始化完成 =====\n");

  // 第二步:搭建LangGraph流水线
  const workflow = new StateGraph(VCState)
    // 1. 注册所有工位(节点)
    .addNode("decideStrategy", decideSearchStrategy)
    .addNode("prepareInvestment", prepareInvestmentSearch)
    .addNode("prepareMarket", prepareMarketSearch)
    .addNode("executeSearch", executeSearch)
    .addNode("visualizeResults", visualizeResults)
    // 2. 定义流水线的流转顺序
    .addEdge(START, "decideStrategy") // 起点:先执行意图判断
    // 3. 定义分叉口:根据意图判断的结果,走对应的分支
    .addConditionalEdges(
      "decideStrategy", // 上一个节点:意图判断
      (state) => state.searchStrategy, // 用共享记事本里的策略判断结果
      {
        investment_focused: "prepareInvestment", // 投资型,走这个分支
        market_focused: "prepareMarket" // 市场型,走这个分支
      }
    )
    // 4. 两个分支都要走到「执行搜索」节点
    .addEdge("prepareInvestment", "executeSearch")
    .addEdge("prepareMarket", "executeSearch")
    // 5. 搜索完了整理结果,然后结束
    .addEdge("executeSearch", "visualizeResults")
    .addEdge("visualizeResults", END);

  // 第三步:编译流水线,生成可执行的app
  const app = workflow.compile();
  // 保存流水线的流程图
  await saveWorkflowGraph(app);

  // 第四步:测试查询1:投资型查询
  console.log("===== 测试投资型查询 =====");
  const investmentQuery = "找A轮或B轮融资800-2500万美元,月营收超过50万美元的初创公司";
  const investmentResult = await app.invoke({ input: investmentQuery });
  console.log(investmentResult.final);

  // 第五步:测试查询2:市场型查询
  console.log("===== 测试市场型查询 =====");
  const marketQuery = "找旧金山、纽约、波士顿的金融科技和医疗健康行业的初创公司";
  const marketResult = await app.invoke({ input: marketQuery });
  console.log(marketResult.final);
}

// 启动整个程序
main().catch(console.error);

四、运行测试:一键启动,看效果

  1. 打开终端,进入项目文件夹,输入以下命令,直接运行:

    
    npx tsx main.ts
    
  2. 预期输出(全程有日志,不会黑盒):

    • 先初始化ES环境,建表、建模板、导入数据

    • 保存流水线流程图到workflow.png,打开就能看到整个流程的流转

    • 执行投资型测试查询,输出符合条件的公司列表

    • 执行市场型测试查询,输出符合条件的公司列表


五、新手必看:常见踩坑指南(会遇到的问题)

1. ES连接失败

  • 报错:connect ECONNREFUSED 127.0.0.1:9200

  • 解决办法:

    1. 检查ES是否启动成功:curl http://localhost:9200 能不能返回JSON

    2. 检查.env里的ELASTICSEARCH_ENDPOINT地址是否正确

    3. 检查API Key是否正确,格式是不是id:api_key

2. 搜索不到结果

  • 解决办法:

    1. 看终端输出的「最终生成的ES查询语句」,检查过滤条件是不是写反了(比如月营收下限50万,上限0,就会查不到)

    2. 检查金额单位是不是正确,代码里的单位是美元,用户输入的时候要对应

    3. 检查数据集里有没有符合条件的数据

3. LLM返回格式错误

  • 报错:Zod validation error

  • 解决办法:

    1. temperature设为0,降低AI的随机性

    2. 检查提示词里有没有明确要求返回JSON格式

    3. 换用更稳定的模型(比如gpt-4o)

4. 向量维度不匹配

  • 报错:vector dimension mismatch

  • 解决办法:

    1. 检查ES映射里的dims和embedding模型的维度是否一致(text-embedding-3-small是1536维)

    2. 重建索引,重新导入数据

5. TypeScript运行报错

  • 报错:Cannot find module

  • 解决办法:

    1. 检查tsconfig.json里的modulemoduleResolution是不是设为NodeNext

    2. 检查import的文件后缀是不是加了.js(比如./elasticsearchSetup.js


六、国内用户优化:替换为国内大模型

如果OpenAI访问不了,可以把代码里的OpenAI换成阿里云通义千问、智谱AI等国内大模型,示例如下:

替换为通义千问

  1. 安装依赖:npm install @langchain/community

  2. 替换main.ts里的LLM初始化代码:

    
    import { ChatTongyi } from "@langchain/community/chat_models/tongyi";
    
    const llm = new ChatTongyi({
      model: "qwen-turbo",
      temperature: 0,
      apiKey: "你的阿里云通义千问API Key"
    });
    
  3. 向量生成也换成通义千问的embedding模型即可。


七、进阶扩展:自己动手优化

  1. 加新的搜索分支:比如「技术栈聚焦」「团队背景」等分支

  2. 增加用户反馈节点:用户可以对结果打分,AI自动优化查询

  3. 导出结果:把查询结果导出成Excel/CSV文件

  4. 加权限控制:给不同的用户分配不同的查询权限

  5. 接入实时数据:对接Crunchbase、IT桔子等平台,实时更新初创公司数据

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐