当你的数据库里有5亿条数据,而业务方要求你“找出所有与A公司有间接投资关系的供应商,并按关联强度排序”时,传统SQL查询的局限性就暴露无遗了。复杂的多表JOIN、递归查询不仅写起来头疼,执行起来更是动辄分钟级,甚至直接超时。这背后,是传统关系型数据库在处理深度、复杂的逻辑关系时,对“图”这种数据结构的天然不擅长。
最近,一个名为SAG(SQL-Augmented Generation)的技术范式正在引起关注。它没有试图推翻SQL,而是巧妙地引入图检索和向量检索作为“外挂”,在查询时动态构建“超边”,让SQL在5亿级数据量上也能跑进秒级。这听起来像是一个“缝合怪”,但它解决的,恰恰是数据分析师和开发者在面对海量关系数据时最真实的痛点:既要SQL的简洁直观,又要图数据库的关系挖掘能力,还要向量检索的语义理解。
这篇文章,我们不谈空洞的概念,而是深入拆解SAG的核心思想。你会发现,它不是一个遥不可及的学术概念,而是一套可以逐步落地的工程架构。我们将从“为什么需要SAG”讲起,剖析其“查询时动态构建超边”的核心机制,并通过一个模拟的供应链金融风控场景,展示如何将图、向量与SQL结合,最终实现复杂关系查询的秒级响应。
1. 这篇文章真正要解决的问题:当SQL遇上复杂关系
几乎所有后端和数据分析工程师都熟悉这样的场景:业务提了一个看似合理的需求——“帮我查一下这家公司的所有二级供应商,以及这些供应商的负面新闻”。你打开数据库ER图,发现需要连接company、investment、supply_relationship、news等至少四张表,其中投资关系还是多层嵌套的。
传统SQL的困境立刻浮现:
- 递归查询性能差:使用
WITH RECURSIVE(CTE) 查询多层级关系,在数据量稍大时(如百万级边)就可能陷入性能泥潭。 - JOIN爆炸:多表关联,尤其是多对多关系,会产生巨大的中间结果集,消耗大量内存和CPU。
- 语义理解缺失:SQL擅长“A投资B”这种明确关系,但无法理解“A和B在业务上相似”或“这篇新闻在讲B的负面信息”这种需要语义匹配的关系。
- 开发维护成本高:复杂的SQL语句难以编写、调试和优化,可读性差。
而图数据库(如Neo4j, NebulaGraph)虽然天生为关系查询设计,但其查询语言(如Cypher, nGQL)的学习成本、以及如何与现有基于SQL的庞大生态(BI工具、报表系统、ORM框架)集成,成了新的门槛。
SAG的核心价值就在这里:它不要求你迁移数据或重写应用,而是在现有SQL体系上,通过引入图检索和向量检索作为“增强组件”,在查询时刻动态地扩展SQL的能力边界。它要解决的不是“替换SQL”,而是“让SQL在处理复杂关系和语义搜索时,不再力不从心”。
2. 基础概念与核心原理:超边、图检索与向量检索如何协同
理解SAG,需要先厘清三个关键概念:超边(Hyperedge)、图检索(Graph Retrieval)和向量检索(Vector Search)。
2.1 什么是“查询时动态超边”?
在传统图论中,一条“边”通常只连接两个节点。而“超边”可以连接任意数量的节点。在SAG的语境下,“动态超边”指的是在SQL查询执行时,根据查询条件,实时从图数据库或向量数据库中检索出一组相关联的实体ID,并将这组ID作为一个整体,注入到原SQL查询中。
举个例子:
- 原始SQL意图:
SELECT * FROM orders WHERE customer_id IN (?)。这个?本来是一个具体的客户ID。 - SAG增强后:
SELECT * FROM orders WHERE customer_id IN (【动态超边结果集】)。这个结果集可能是“所有与目标客户有相似购买行为的客户群”(来自向量检索),也可能是“目标客户所在集团的所有子公司”(来自图检索)。
“动态”意味着这条超边不是预先物化在数据库里的,而是按需计算、即时生成的。“查询时”则明确了计算发生的时机,保证了结果的实时性。
2.2 图检索与向量检索的分工
SAG利用这两种检索技术来构建不同类型的动态超边:
| 检索类型 | 核心能力 | 在SAG中构建的“超边” | 适用场景 |
|---|---|---|---|
| 图检索 | 挖掘实体间显式的、结构化的关系路径。 | 关系扩散超边。例如:从一个公司节点出发,找出其所有N度内的投资方、子公司、供应商等。 | 风险传导分析(找出风险公司的关联方)、供应链溯源、社交网络分析。 |
| 向量检索 | 基于语义相似度,找到内容或行为相似的实体。 | 语义聚类超边。例如:根据一份公司描述文本,找到业务模式相似的其他公司;或根据用户行为向量,找到相似用户群。 | 相似公司推荐、语义化标签扩展、用户画像聚类。 |
2.3 SAG的核心工作流程
SAG的整体流程可以概括为“解析-检索-注入-执行”四步:
- SQL解析与意图识别:系统解析用户提交的SQL,识别出其中可以被增强的部分。通常是识别出
IN、= ANY等操作符后的子查询或参数,并提取其中的关键实体(如公司名、ID)和关系意图(如“查找关联方”)。 - 动态超边检索:根据上一步提取的意图和实体,并行或串行发起图检索和向量检索。
- 图检索:将实体ID发送给图数据库,执行如
k-hop邻居查询、最短路径查询等,获取一组关联实体ID。 - 向量检索:将实体属性(如公司简介)或查询条件本身转化为向量,在向量数据库中进行相似度搜索,获取一组相似实体ID。
- 图检索:将实体ID发送给图数据库,执行如
- 结果融合与SQL重写:将图检索和向量检索返回的ID集合进行去重、排序、剪枝等融合操作,形成最终的“动态超边”结果集。然后用这个结果集替换原SQL中对应的部分,生成一条新的、可执行的SQL语句。
- 执行增强后SQL:将重写后的SQL发送回关系型数据库执行。由于
IN列表现在是明确的一组ID,数据库可以利用索引高效执行,从而实现复杂逻辑的秒级响应。
关键在于:最耗时的关系挖掘和语义匹配工作,由图数据库和向量数据库这些专用引擎承担。而最终的结果汇聚和业务数据获取,仍由熟悉且强大的SQL引擎完成。这是一种高效的“异构计算”分工。
3. 环境准备与前置条件
要实践SAG的思想,我们需要搭建一个包含多种组件的环境。以下是一个基于开源技术的参考架构,你可以根据实际情况调整。
核心组件清单:
- 关系型数据库 (RDBMS):存储业务主体数据。例如 MySQL 8.0+ 或 PostgreSQL 12+。本文示例使用 PostgreSQL。
- 图数据库 (Graph DB):存储实体间的显式关系网络。例如 Neo4j 5.x 或 NebulaGraph 3.x。本文示例使用 Neo4j(其Cypher查询语言更直观)。
- 向量数据库 (Vector DB):存储实体或内容的向量嵌入,并提供相似性搜索。例如 Milvus 2.x、Weaviate 或 PGVector(PostgreSQL扩展)。本文示例使用 Milvus。
- 嵌入模型 (Embedding Model):用于将文本转换为向量。例如
text-embedding-ada-002(OpenAI API) 或开源的BAAI/bge-small-zh。本文示例使用BAAI/bge-small-zh-v1.5,可在本地运行。 - 应用层/中间件:实现SAG逻辑的应用程序。可以使用任何语言编写,如 Python (FastAPI/Flask)、Java (Spring Boot) 或 Go。本文示例使用 Python + FastAPI。
版本与依赖建议:
- Python: 3.9+
- PostgreSQL: 14+
- Neo4j: 5.15+ (社区版即可)
- Milvus: 2.3.x (使用
standalone模式便于测试) - 关键Python包:
pip install fastapi uvicorn sqlalchemy psycopg2-binary neo4j pymilvus sentence-transformers
4. 核心流程拆解:从SQL到增强结果的完整路径
让我们通过一个具体的供应链金融风控查询,来一步步拆解SAG的工作流程。业务查询是:“找出与‘公司A’存在直接或间接投资关系(最多3度)的所有供应商,并返回这些供应商的最近3个月交易额。”
4.1 步骤一:定义数据模型与初始化数据
首先,我们在三个数据库中分别建立数据。
1. 关系数据库 (PostgreSQL): 存储交易事实
-- 创建公司表 CREATE TABLE company ( id VARCHAR(32) PRIMARY KEY, name VARCHAR(255) NOT NULL, description TEXT ); -- 创建交易记录表 CREATE TABLE transaction ( id SERIAL PRIMARY KEY, supplier_id VARCHAR(32) NOT NULL, buyer_id VARCHAR(32) NOT NULL, amount DECIMAL(15, 2), transaction_date DATE, FOREIGN KEY (supplier_id) REFERENCES company(id), FOREIGN KEY (buyer_id) REFERENCES company(id) ); CREATE INDEX idx_transaction_supplier ON transaction(supplier_id); CREATE INDEX idx_transaction_buyer ON transaction(buyer_id); -- 插入示例数据 (简化) INSERT INTO company (id, name, description) VALUES ('comp_a', '公司A', '一家大型科技制造商'), ('comp_b', '公司B', '精密零部件供应商'), ('comp_c', '公司C', '原材料提供商'), ('comp_d', '公司D', '物流服务商'), ('comp_e', '公司E', '与公司B业务相似的中小供应商'); -- ... 插入更多公司 INSERT INTO transaction (supplier_id, buyer_id, amount, transaction_date) VALUES ('comp_b', 'comp_a', 100000.00, '2024-02-15'), ('comp_c', 'comp_b', 50000.00, '2024-02-10'), ('comp_d', 'comp_a', 30000.00, '2024-01-20'), ('comp_e', 'comp_f', 20000.00, '2024-02-01'); -- ... 插入更多交易2. 图数据库 (Neo4j): 存储投资等股权关系在Neo4j浏览器中执行Cypher语句:
// 创建公司节点和投资关系 CREATE (a:Company {id: 'comp_a', name: '公司A'}), (b:Company {id: 'comp_b', name: '公司B'}), (c:Company {id: 'comp_c', name: '公司C'}), (d:Company {id: 'comp_d', name: '公司D'}), (e:Company {id: 'comp_e', name: '公司E'}), (f:Company {id: 'comp_f', name: '公司F'}); // 公司A投资了公司B,公司B投资了公司C CREATE (a)-[:INVESTS {share: 0.3}]->(b); CREATE (b)-[:INVESTS {share: 0.15}]->(c); // 公司D是公司A的子公司 CREATE (a)-[:OWNS {share: 1.0}]->(d);3. 向量数据库 (Milvus): 存储公司描述的语义向量首先,用嵌入模型为每家公司的description生成向量。
# 文件:generate_embeddings.py from sentence_transformers import SentenceTransformer import pymilvus from pymilvus import connections, Collection, FieldSchema, CollectionSchema, DataType # 1. 连接 Milvus connections.connect(host='localhost', port='19530') # 2. 定义集合(表)结构 fields = [ FieldSchema(name="id", dtype=DataType.VARCHAR, is_primary=True, max_length=32), FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=384) # bge-small-zh 向量维度为384 ] schema = CollectionSchema(fields, description="Company description embeddings") collection_name = "company_embeddings" if collection_name in pymilvus.utility.list_collections(): pymilvus.utility.drop_collection(collection_name) collection = Collection(name=collection_name, schema=schema) # 3. 创建索引(加速搜索) index_params = { "index_type": "IVF_FLAT", "metric_type": "COSINE", "params": {"nlist": 128} } collection.create_index(field_name="embedding", index_params=index_params) collection.load() # 4. 准备数据并生成嵌入 model = SentenceTransformer('BAAI/bge-small-zh-v1.5') company_data = [ {"id": "comp_a", "desc": "一家大型科技制造商"}, {"id": "comp_b", "desc": "精密零部件供应商"}, # ... 其他公司 ] ids = [c["id"] for c in company_data] texts = [c["desc"] for c in company_data] embeddings = model.encode(texts, normalize_embeddings=True).tolist() # 5. 插入数据 entities = [ids, embeddings] collection.insert(entities) collection.flush() print("Embeddings inserted successfully.")4.2 步骤二:解析原始SQL与意图识别
用户提交的查询可能很直接,但我们需要识别其背后的增强意图。
-- 用户提交的原始查询(参数化) SELECT s.id, s.name, SUM(t.amount) as total_amount FROM company s JOIN transaction t ON s.id = t.supplier_id WHERE s.id IN (?) -- 这里期望的是一组供应商ID AND t.transaction_date >= CURRENT_DATE - INTERVAL '3 months' GROUP BY s.id, s.name ORDER BY total_amount DESC;我们的SAG中间件需要解析这个SQL,并识别出:
- 增强点:
WHERE s.id IN (?)这个条件。 - 查询意图:根据上下文(可能是从前端传递的),我们知道这个
?应该替换为“与公司A有投资关联的供应商”。这包含了图检索意图(投资关系)和可能的向量检索意图(找相似公司,作为补充)。
4.3 步骤三:并行执行图检索与向量检索
这是SAG的“魔法”发生环节。中间件会同时向图数据库和向量数据库发起查询。
1. 图检索(获取投资关联方)
# 文件:sag_middleware.py (部分代码) from neo4j import GraphDatabase class GraphRetriever: def __init__(self, uri, user, password): self.driver = GraphDatabase.driver(uri, auth=(user, password)) def get_investment_related_companies(self, company_id, max_hops=3): """获取与指定公司有投资关联(最多N度)的所有公司ID""" query = """ MATCH (start:Company {id: $company_id}) MATCH path = (start)-[:INVESTS|OWNS*1..%d]->(related:Company) RETURN DISTINCT related.id as company_id UNION MATCH (start:Company {id: $company_id}) RETURN start.id as company_id """ % max_hops with self.driver.session() as session: result = session.run(query, company_id=company_id) return [record["company_id"] for record in result] # 使用 graph_retriever = GraphRetriever("bolt://localhost:7687", "neo4j", "password") investment_related_ids = graph_retriever.get_investment_related_companies("comp_a", max_hops=3) # 结果可能包含: ['comp_a', 'comp_b', 'comp_c', 'comp_d']2. 向量检索(获取语义相似公司)有时,仅靠明确的投资关系可能不够。例如,公司E可能与公司B业务高度相似,虽无股权关系,但风险特征相近。我们可以用向量检索作为补充。
# 文件:sag_middleware.py (部分代码) from pymilvus import Collection class VectorRetriever: def __init__(self, collection_name): self.collection = Collection(collection_name) self.collection.load() self.model = SentenceTransformer('BAAI/bge-small-zh-v1.5') def get_similar_companies(self, query_text, top_k=5): """根据描述文本,找到最相似的公司ID""" # 将查询文本向量化 query_embedding = self.model.encode([query_text], normalize_embeddings=True).tolist() # 在Milvus中搜索 search_params = {"metric_type": "COSINE", "params": {"nprobe": 10}} results = self.collection.search( data=query_embedding, anns_field="embedding", param=search_params, limit=top_k, output_fields=["id"] ) similar_ids = [] for hits in results: for hit in hits: similar_ids.append(hit.entity.get("id")) return similar_ids # 使用:假设我们想找与“精密零部件供应商”相似的公司 vector_retriever = VectorRetriever("company_embeddings") similar_ids = vector_retriever.get_similar_companies("精密零部件供应商", top_k=3) # 结果可能包含: ['comp_b', 'comp_e', ...]4.4 步骤四:结果融合与SQL重写
现在,我们拥有了两个ID集合:investment_related_ids(来自图) 和similar_ids(来自向量)。需要将它们融合成一个最终的ID列表用于SQL查询。
# 文件:sag_middleware.py (部分代码) def fuse_and_rewrite_sql(original_sql, graph_ids, vector_ids, original_placeholder="?"): """ 融合图检索和向量检索结果,并重写SQL。 策略:取并集,并去重。 """ # 1. 结果融合(简单取并集) all_related_ids = list(set(graph_ids) | set(vector_ids)) if not all_related_ids: # 如果没有找到任何相关ID,可以返回一个不可能的条件,或者根据业务逻辑处理 rewritten_sql = original_sql.replace(original_placeholder, "NULL") else: # 2. 构建IN列表字符串 # 注意:这里需要根据数据库类型进行正确的值转义,防止SQL注入。 # 以PostgreSQL为例,使用参数化查询更安全,这里为演示拼接。 id_list_str = ", ".join([f"'{id_}'" for id_ in all_related_ids]) # 3. 重写SQL rewritten_sql = original_sql.replace(original_placeholder, id_list_str) return rewritten_sql, all_related_ids # 使用 original_sql = """ SELECT s.id, s.name, SUM(t.amount) as total_amount FROM company s JOIN transaction t ON s.id = t.supplier_id WHERE s.id IN (?) AND t.transaction_date >= CURRENT_DATE - INTERVAL '3 months' GROUP BY s.id, s.name ORDER BY total_amount DESC; """ rewritten_sql, final_id_list = fuse_and_rewrite_sql(original_sql, investment_related_ids, similar_ids) print("重写后的SQL:") print(rewritten_sql) print("最终关联ID列表:", final_id_list)输出可能类似于:
SELECT s.id, s.name, SUM(t.amount) as total_amount FROM company s JOIN transaction t ON s.id = t.supplier_id WHERE s.id IN ('comp_a', 'comp_b', 'comp_c', 'comp_d', 'comp_e') AND t.transaction_date >= CURRENT_DATE - INTERVAL '3 months' GROUP BY s.id, s.name ORDER BY total_amount DESC;4.5 步骤五:执行增强SQL并返回结果
最后,中间件将重写后的、包含了具体ID列表的SQL,发送回PostgreSQL执行。由于IN子句中的值是明确的、有限的,并且company.id和transaction.supplier_id上建有索引,PostgreSQL可以非常高效地完成这次查询,即使transaction表有数亿条记录。
# 文件:sag_middleware.py (部分代码) import psycopg2 from psycopg2.extras import RealDictCursor def execute_enhanced_sql(rewritten_sql, db_connection_params): """执行增强后的SQL并返回结果""" conn = psycopg2.connect(**db_connection_params) cursor = conn.cursor(cursor_factory=RealDictCursor) try: cursor.execute(rewritten_sql) results = cursor.fetchall() return results finally: cursor.close() conn.close() # 执行并打印结果 db_params = { "host": "localhost", "database": "your_db", "user": "your_user", "password": "your_password" } final_results = execute_enhanced_sql(rewritten_sql, db_params) for row in final_results: print(row)5. 完整示例与代码实现:一个简易的SAG查询服务
我们将上述步骤整合,创建一个简单的FastAPI服务,对外提供SAG增强查询接口。
项目结构:
sag-demo/ ├── app.py # FastAPI主应用 ├── config.py # 配置文件 ├── graph_client.py # Neo4j客户端封装 ├── vector_client.py # Milvus客户端封装 ├── sql_rewriter.py # SQL解析与重写逻辑 ├── models.py # 数据模型(可选) └── requirements.txt1. 配置文件 (config.py)
# config.py import os from pydantic_settings import BaseSettings class Settings(BaseSettings): # PostgreSQL pg_host: str = "localhost" pg_port: int = 5432 pg_database: str = "sag_demo" pg_user: str = "postgres" pg_password: str = "your_password" # Neo4j neo4j_uri: str = "bolt://localhost:7687" neo4j_user: str = "neo4j" neo4j_password: str = "your_password" # Milvus milvus_host: str = "localhost" milvus_port: str = "19530" milvus_collection: str = "company_embeddings" # Embedding Model embedding_model: str = "BAAI/bge-small-zh-v1.5" settings = Settings()2. 图客户端 (graph_client.py)
# graph_client.py from neo4j import GraphDatabase from config import settings class GraphClient: def __init__(self): self.driver = GraphDatabase.driver( settings.neo4j_uri, auth=(settings.neo4j_user, settings.neo4j_password) ) def get_related_companies(self, company_id: str, relation_types: list = None, max_hops: int = 3): """ 获取与指定公司相关联的公司ID。 :param relation_types: 关系类型列表,如 ['INVESTS', 'OWNS']。为None则匹配所有类型。 """ if relation_types: rel_pattern = "|".join(relation_types) match_clause = f"-[:{rel_pattern}*1..{max_hops}]->" else: match_clause = f"-[*1..{max_hops}]->" query = f""" MATCH (start:Company {{id: $company_id}}) MATCH path = (start){match_clause}(related:Company) RETURN DISTINCT related.id as company_id UNION MATCH (start:Company {{id: $company_id}}) RETURN start.id as company_id """ with self.driver.session() as session: result = session.run(query, company_id=company_id) return [record["company_id"] for record in result] def close(self): self.driver.close()3. 向量客户端 (vector_client.py)
# vector_client.py from pymilvus import connections, Collection from sentence_transformers import SentenceTransformer from config import settings class VectorClient: def __init__(self): connections.connect(host=settings.milvus_host, port=settings.milvus_port) self.collection = Collection(settings.milvus_collection) self.collection.load() self.model = SentenceTransformer(settings.embedding_model) def search_similar(self, query_text: str, top_k: int = 5): """根据文本搜索相似公司ID""" query_embedding = self.model.encode([query_text], normalize_embeddings=True).tolist() search_params = {"metric_type": "COSINE", "params": {"nprobe": 10}} results = self.collection.search( data=query_embedding, anns_field="embedding", param=search_params, limit=top_k, output_fields=["id"] ) similar_ids = [] for hits in results: for hit in hits: similar_ids.append(hit.entity.get("id")) return similar_ids4. SQL重写器 (sql_rewriter.py)
# sql_rewriter.py import re from typing import List class SQLRewriter: @staticmethod def rewrite_with_ids(original_sql: str, id_list: List[str], placeholder: str = "?") -> str: """ 用ID列表替换SQL中的占位符。 注意:生产环境应使用参数化查询,此处为演示进行字符串替换。 """ if not id_list: # 安全处理:如果没有ID,查询应返回空结果 return original_sql.replace(placeholder, "NULL") # 简单转义(生产环境需根据数据库驱动处理) escaped_ids = [f"'{id_}'" for id_ in id_list] id_list_str = ", ".join(escaped_ids) # 替换第一个匹配的占位符 rewritten_sql = original_sql.replace(placeholder, id_list_str, 1) return rewritten_sql @staticmethod def extract_enhancement_intent(sql: str) -> dict: """ 一个简单的意图识别示例。 在实际系统中,这可能是一个复杂的NLP或规则引擎。 这里我们假设SQL注释中包含了意图,例如: -- @sag.intent: graph:INVESTS,OWNS:3; vector:company_desc """ intent = {"graph": None, "vector": None} comment_match = re.search(r'--\s*@sag\.intent:\s*(.+)', sql, re.IGNORECASE) if comment_match: intent_str = comment_match.group(1) # 解析 intent_str,这里简化处理 if "graph" in intent_str: intent["graph"] = {"relation_types": ["INVESTS", "OWNS"], "max_hops": 3} if "vector" in intent_str: intent["vector"] = {"field": "description"} return intent5. 主应用 (app.py)
# app.py from fastapi import FastAPI, HTTPException from pydantic import BaseModel from typing import Optional import psycopg2 from psycopg2.extras import RealDictCursor from config import settings from graph_client import GraphClient from vector_client import VectorClient from sql_rewriter import SQLRewriter app = FastAPI(title="SAG Demo API") graph_client = GraphClient() vector_client = VectorClient() class SAGQueryRequest(BaseModel): sql_template: str # 带有占位符的SQL target_company_id: str # 要查询的核心公司ID target_company_desc: Optional[str] = None # 用于向量检索的公司描述 @app.post("/query/") async def execute_sag_query(request: SAGQueryRequest): """ 执行一个SAG增强查询。 1. 从SQL模板中解析意图(简化版从注释解析)。 2. 根据意图,从图数据库和向量数据库获取相关ID。 3. 融合ID,重写SQL。 4. 执行重写后的SQL并返回结果。 """ # 1. 解析意图(简化) intent = SQLRewriter.extract_enhancement_intent(request.sql_template) related_ids = set() # 2. 图检索 if intent.get("graph"): graph_ids = graph_client.get_related_companies( request.target_company_id, relation_types=intent["graph"].get("relation_types"), max_hops=intent["graph"].get("max_hops", 3) ) related_ids.update(graph_ids) # 3. 向量检索 if intent.get("vector") and request.target_company_desc: vector_ids = vector_client.search_similar(request.target_company_desc, top_k=5) related_ids.update(vector_ids) # 4. 重写SQL # 注意:这里假设SQL模板中只有一个占位符需要替换 final_sql = SQLRewriter.rewrite_with_ids(request.sql_template, list(related_ids)) # 5. 执行SQL try: conn = psycopg2.connect( host=settings.pg_host, port=settings.pg_port, database=settings.pg_database, user=settings.pg_user, password=settings.pg_password ) cursor = conn.cursor(cursor_factory=RealDictCursor) cursor.execute(final_sql) results = cursor.fetchall() cursor.close() conn.close() return { "enhanced_sql": final_sql, "related_company_ids": list(related_ids), "results": results } except Exception as e: raise HTTPException(status_code=500, detail=f"Database error: {str(e)}") @app.on_event("shutdown") def shutdown_event(): graph_client.close() if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000)6. 启动与测试
- 安装依赖:
pip install -r requirements.txt - 确保PostgreSQL, Neo4j, Milvus服务均已启动,且数据已按前述步骤初始化。
- 启动FastAPI服务:
python app.py - 使用
curl或 Postman 测试接口:curl -X POST "http://localhost:8000/query/" \ -H "Content-Type: application/json" \ -d '{ "sql_template": "-- @sag.intent: graph:INVESTS,OWNS:3; vector:company_desc\nSELECT s.id, s.name, SUM(t.amount) as total_amount FROM company s JOIN transaction t ON s.id = t.supplier_id WHERE s.id IN (?) AND t.transaction_date >= CURRENT_DATE - INTERVAL \'3 months\' GROUP BY s.id, s.name ORDER BY total_amount DESC;", "target_company_id": "comp_a", "target_company_desc": "一家大型科技制造商" }'
6. 运行结果与效果验证
调用上述API后,你会得到一个JSON响应,其中包含:
enhanced_sql: 重写后的、包含具体ID列表的SQL语句。related_company_ids: 通过图检索和向量检索找到的所有关联公司ID。results: 从PostgreSQL执行增强SQL后返回的业务数据。
例如,响应可能如下:
{ "enhanced_sql": "SELECT s.id, s.name, SUM(t.amount) as total_amount FROM company s JOIN transaction t ON s.id = t.supplier_id WHERE s.id IN ('comp_a', 'comp_b', 'comp_c', 'comp_d', 'comp_e') AND t.transaction_date >= CURRENT_DATE - INTERVAL '3 months' GROUP BY s.id, s.name ORDER BY total_amount DESC;", "related_company_ids": ["comp_a", "comp_b", "comp_c", "comp_d", "comp_e"], "results": [ {"id": "comp_b", "name": "公司B", "total_amount": 100000.00}, {"id": "comp_d", "name": "公司D", "total_amount": 30000.00} ] }如何验证效果?
- 性能对比:可以分别用传统递归SQL(在PG中模拟多度关系)和SAG方式执行相同逻辑的查询,在数据量达到千万/亿级时,SAG的秒级响应优势会非常明显。
- 结果完备性:检查
related_company_ids是否包含了所有通过投资关系能找到的公司,以及通过语义相似性找到的潜在关联公司。这确保了查询范围的扩展。 - 业务正确性:最终的业务结果
results应符合风控或分析的业务预期。
7. 常见问题与排查思路
在实现和运行SAG系统时,你可能会遇到以下问题:
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 图检索返回空结果 | 1. Neo4j中不存在该节点或关系。 2. Cypher查询语法错误或关系类型不匹配。 3. 网络连接或认证失败。 | 1. 在Neo4j浏览器中手动执行相同查询。 2. 检查 GraphClient中的查询字符串和参数。3. 检查Neo4j服务状态和连接配置。 | 1. 确认数据已正确导入图数据库。 2. 使用 MATCH (n) RETURN n LIMIT 25查看数据。3. 确保关系类型和属性名大小写正确。 |
| 向量检索结果不相关 | 1. 嵌入模型不适合领域文本。 2. Milvus集合中的向量未正确生成或索引未构建。 3. 搜索参数(如 nprobe)设置不当。 | 1. 用模型单独编码一些文本,观察向量是否合理。 2. 检查Milvus集合的实体数量和索引状态。 3. 尝试调整 top_k和search_params。 | 1. 尝试更换或微调嵌入模型。 2. 重新生成向量并构建索引。 3. 根据数据规模调整 nlist和nprobe。 |
| 重写后的SQL执行超慢 | 1.IN列表中的ID数量过多(如超过几千)。2. PostgreSQL中相关表缺少索引。 3. 融合后的ID列表包含大量无效ID。 | 1. 检查related_company_ids列表的长度。2. 使用 EXPLAIN ANALYZE分析SQL执行计划。3. 检查图/向量检索的结果是否有误。 | 1. 在图/向量检索侧增加结果剪枝(如按关系权重过滤)。 2. 确保 company.id和transaction.supplier_id上有索引。3. 考虑对ID列表进行分批次查询。 |
| SQL注入风险 | 在重写SQL时,如果直接拼接字符串,且ID来自不可信源,则存在风险。 | 审查SQLRewriter.rewrite_with_ids方法。 | 务必使用参数化查询。将ID列表作为参数传递给数据库驱动,而不是拼接进SQL字符串。示例(使用psycopg2):cursor.execute("... WHERE id = ANY(%s)", (id_list,)) |
| 服务延迟高 | 1. 图检索或向量检索本身慢。 2. 串行执行两种检索。 3. 网络延迟高。 | 1. 分别对图查询和向量查询进行性能分析。 2. 检查是否为并行执行。 3. 检查各服务所在网络。 | 1. 为图数据库的关系和向量数据库的集合建立合适的索引。 2. 使用 asyncio或线程池并行执行检索。3. 将服务部署在同一内网,或使用更高效的网络协议。 |
8. 最佳实践与工程建议
要将SAG从Demo推向生产,需要考虑以下几点:
- 意图识别标准化:示例中通过SQL注释传递意图是简化的方式。生产环境应设计更规范的“增强查询描述语言”,可以是一个JSON结构,明确指定图检索的起点、关系路径、过滤条件,以及向量检索的查询字段、模型和阈值。
- 结果融合策略:简单的并集可能不够。需要设计更智能的融合策略,例如:
- 加权融合:给图检索结果(明确关系)更高权重,向量检索结果(语义相似)较低权重。
- 分层融合:先使用图检索确定核心关联圈,再用向量检索在圈外做扩展。
- 去噪与剪枝:根据业务规则(如投资比例低于5%的忽略)或统计方法(如设置相似度阈值)过滤结果。
- 缓存策略:对于相对稳定的关系(如股权关系)和公司描述,其检索结果可以缓存。例如,将“公司A的3度投资关联方”缓存起来,有效期内直接使用,避免重复计算。
- 异步与超时控制:将图检索和向量检索设计为异步任务,并设置合理的超时时间。如果一个检索超时,可以降级为使用另一个检索的结果,或直接使用缓存,保证查询服务的可用性。
- SQL重写安全:绝对避免字符串拼接。必须使用数据库驱动提供的参数化查询接口来传递ID列表,从根本上杜绝SQL注入。
- 监控与可观测性:对SAG中间件的各个环节进行监控:各数据库的查询延迟、结果集大小、融合策略效果、最终SQL执行时间。这有助于发现瓶颈和优化策略。
- 数据一致性:确保关系数据库、图数据库和向量数据库之间的数据同步。当主业务数据库(如PostgreSQL)中的数据更新时,需要有机制同步更新图数据库中的关系和向量数据库中的嵌入。可以考虑使用CDC(变更数据捕获)工具或消息队列。
- 模型更新:如果更换了嵌入模型,需要重新为所有实体生成向量并更新Milvus。这是一个批处理任务,需要规划好时间窗口和回滚方案。
9. 总结与后续学习方向
SAG(SQL检索增强生成)为我们提供了一种务实且强大的思路:不颠覆旧世界,而是增强它。它承认SQL在事实查询和聚合分析上的霸主地位,同时用图数据库和向量数据库这两个“特种兵”去解决SQL最不擅长的关系挖掘和语义搜索问题。
通过本文的拆解,你应该已经理解了SAG的核心——“查询时动态构建超边”。它不是预先计算好所有关联,而是在查询那一刻,按需、实时地从专用引擎中获取关联集合,再塞回SQL中执行。这种架构在5亿甚至更大数据量上实现秒级响应,是完全可行的。
下一步,你可以从这些方向深入:
- 深入图数据库:学习更复杂的Cypher查询,如路径查找、模式匹配、图算法(PageRank, Louvain),让你的关系挖掘能力更强。
- 深入向量检索:研究不同嵌入模型(Sentence-BERT, OpenAI Embeddings)的优劣,学习混合检索(Hybrid Search)和重排序(Re-ranking)技术,提升语义搜索的精度。
- 工程化框架:探索将SAG思想框架化,例如设计一个通用的“增强查询规划器”,能够自动解析自然语言查询,并生成最优的检索与融合计划。
- 结合LLM:思考如何将大语言模型(LLM)接入这个流程。例如,用LLM将用户的自然语言问题解析成结构化的SAG查询意图,甚至用LLM来对最终的结果进行总结和报告生成。
技术的价值在于解决真实问题。当下一次业务方提出一个让你头皮发麻的、涉及深层关系和模糊语义的查询需求时,不妨想想SAG这个“三合一”的方案。它可能不是银弹,但它提供了一个清晰、可逐步演进的架构路径,让你能在现有的技术栈上,赋予系统更强大的智能。