AI知识库

53AI知识库

学习大模型的前沿技术与行业应用场景


将微软GraphRAG输出到Neo4J并使用Langchain或LlamaIndex实现本地和全局检索
发布日期:2024-08-27 08:09:26 浏览次数: 1695



微软的 GraphRAG 实现最近获得了极大的关注。在上一篇博文中,我讨论了如何构建图形,并探讨了研究论文中强调的一些创新方面。从高层次来看,GraphRAG 库的输入是包含各种信息的源文件。这些文档使用大语言模型(LLM)进行处理,以提取文档中出现的实体及其关系的结构化信息。提取的结构化信息随后被用于构建知识图谱。

构建知识图谱后,GraphRAG 库将图谱算法(特别是莱顿社区检测算法)与 LLM 提示相结合,生成知识图谱中实体和关系社区的自然语言摘要。在这篇文章中,我们将从 GraphRAG 库中获取输出,将其存储在 Neo4j 中,然后使用 LangChain 和 LlamaIndex 协调框架直接从 Neo4j 设置检索器。您可以在 GitHub 上访问代码和 GraphRAG 输出[1],从而跳过 GraphRAG 提取过程。

1. 数据集

本博文中的数据集是查尔斯-狄更斯的《圣诞颂歌》,可通过古腾堡计划[2]免费获取。我们选择这本书作为源文件,是因为它在介绍性文件中得到了强调,使我们能够毫不费力地进行提取。

2. 图形构建

尽管可以跳过图形提取部分,但我们还是要谈谈我认为最重要的几个配置选项。例如,图提取可能会非常耗费Token和成本。因此,使用 gpt-4o-mini 这样相对便宜但性能良好的 LLM 测试提取是合理的。正如这篇博文所述,gpt-4-turbo 可以显著降低成本,同时保持良好的准确性。

GRAPHRAG_LLM_MODEL=gpt-4o-mini

最重要的配置是我们要提取的实体类型。默认情况下,会提取组织、人员、事件和地理信息。

GRAPHRAG_ENTITY_EXTRACTION_ENTITY_TYPES=organization,person,event,geo

这些默认实体类型可能对一本书很有用,但请务必根据您在特定用例中要处理的文档域对它们进行相应更改。另一个重要配置是最大提取值。作者发现,LLM 并不能一次提取所有可用信息,我们也分别进行了验证。

拾取配置允许 LLM 执行多次提取。在上图中,我们可以清楚地看到,在执行多次提取(拾取)时,我们能提取出更多的信息。多次提取需要大量令牌,因此使用 gpt-4o-mini 这样的廉价模型有助于降低成本。

GRAPHRAG_ENTITY_EXTRACTION_MAX_GLEANINGS=1

此外,默认情况下不提取索赔或协变量信息。您可以通过设置 GRAPHRAG_CLAIM_EXTRACTION_ENABLED 配置来启用它。

GRAPHRAG_CLAIM_EXTRACTION_ENABLED=False

译者注:其实这个在微软GraphRAG中没啥用,最后查询并没有使用。

并非所有结构化信息都能一次性提取出来,这似乎是一个经常出现的问题。因此,我们在这里也提供了拾取配置选项。同样有趣但我还没来得及深入研究的是提示调整部分。Prompt微调是可选的,但我们非常鼓励这样做,因为它可以提高准确性。配置设置完成后,我们就可以按照说明运行图形提取管道了,具体步骤如下。

提取管道会执行上图中所有的蓝色步骤。回顾我之前的博文,了解有关图构建和社区汇总的更多信息。微软GraphRAG 库的图提取管道的输出是一组 parquet 文件,如 Dulce 行动示例所示。这些 parquet 文件可以轻松导入 Neo4j 图形数据库,用于下游分析、可视化和检索。我们可以使用免费的云 Aura 实例,也可以建立本地 Neo4j 环境。我的朋友迈克尔-亨格(Michael Hunger)完成了将 parquet 文件导入 Neo4j 的大部分工作。在这篇博文中,我们将跳过导入的解释,但它包括从五六个 CSV 文件中导入和构建知识图谱。如果你想了解更多关于 CSV 导入的信息,可以查看 Neo4j Graph Academy 课程。导入代码和 GraphRAG 输出示例可在Jupyter 笔记本[3]上下载。导入完成后,我们可以打开 Neo4j 浏览器来验证和可视化导入的部分图表。

3. 图表分析

在开始实施检索器之前,我们先进行一个简单的图形分析,以熟悉提取的数据。我们首先要定义数据库连接和一个执行 Cypher 语句(图形数据库查询语言)并输出 Pandas DataFrame 的函数。

NEO4J_URI="bolt://localhost"
NEO4J_USERNAME="neo4j"
NEO4J_PASSWORD="password"

driver = GraphDatabase.driver(NEO4J_URI, auth=(NEO4J_USERNAME, NEO4J_PASSWORD))

def db_query(cypher: str, params: Dict[str, Any] = {}) -> pd.DataFrame:
    """Executes a Cypher statement and returns a DataFrame"""
    return driver.execute_query(
        cypher, parameters_=params, result_transformer_=Result.to_df
    )

在进行图形提取时,我们使用的块大小为 300。此后,作者将默认的块大小改为 1200。我们可以使用下面的Cypher语句来验证块大小。

db_query(
"MATCH (n:__Chunk__) RETURN n.n_tokens as token_count, count(*) AS count"
)
# token_count count
# 300 230
# 155 1

230 块有 300 个Token,而最后一块只有 155 个Token。现在让我们检查一个实体及其描述的示例。

db_query(
"MATCH (n:__Entity__) RETURN n.name AS name, n.description AS description LIMIT 1"
)

古腾堡计划 "似乎在书中的某个地方有所描述,可能是在开头。我们可以观察到描述是如何捕捉到比实体名称更详细、更复杂的信息的,GraphRAG 论文引入了实体名称,以便从文本中保留更复杂、更细微的数据。我们也来看看示例关系。

db_query(
"MATCH ()-[n:RELATED]->() RETURN n.description AS description LIMIT 5"
)

微软GraphRAG 不仅仅能提取实体间的简单关系类型,还能捕捉详细的关系描述。与简单的关系类型相比,这种功能可以捕捉到更多细微的信息。我们还可以检查单个社区及其生成的描述。

db_query("""
MATCH (n:__Community__)
RETURN n.title AS title, n.summary AS summary, n.full_content AS full_content LIMIT 1
""")

一个社区有标题、摘要和使用 LLM 生成的完整内容。我没有看到作者在检索时是使用完整内容还是只使用摘要,但我们可以在两者之间做出选择。我们可以观察到完整内容中的引文,这些引文指向信息来源的实体和关系。有趣的是,如果引文过长,LLM 有时会对其进行修剪,比如下面的例子。

[Data: Entities (11, 177); Relationships (25, 159, 20, 29, +more)]

没有办法扩展 "+more "符号,所以这是LLM处理长引文的一种有趣方法。现在我们来评估一些分布情况。首先,我们将检查从文本块中提取的实体计数的分布情况。

entity_df = db_query(
"""
MATCH (d:__Chunk__)
RETURN count {(d)-[:HAS_ENTITY]->()} AS entity_count
"""
)
# Plot distribution
plt.figure(figsize=(10, 6))
sns.histplot(entity_df['entity_count'], kde=True, bins=15, color='skyblue')
plt.axvline(entity_df['entity_count'].mean(), color='red', linestyle='dashed', linewidth=1)
plt.axvline(entity_df['entity_count'].median(), color='green', linestyle='dashed', linewidth=1)
plt.xlabel('Entity Count', fontsize=12)
plt.ylabel('Frequency', fontsize=12)
plt.title('Distribution of Entity Count', fontsize=15)
plt.legend({'Mean': entity_df['entity_count'].mean(), 'Median': entity_df['entity_count'].median()})
plt.show()

请记住,文本块有 300 个Token。因此,提取的实体数量相对较少,每个文本块平均约有三个实体。提取时没有进行任何拾取(单次提取)。如果我们增加拾取次数,看看分布情况会很有趣。接下来,我们将评估节点度分布。节点度是一个节点的关系数。

degree_dist_df = db_query(
"""
MATCH (e:__Entity__)
RETURN count {(e)-[:RELATED]-()} AS node_degree
"""
)
# Calculate mean and median
mean_degree = np.mean(degree_dist_df['node_degree'])
percentiles = np.percentile(degree_dist_df['node_degree'], [25, 50, 75, 90])
# Create a histogram with a logarithmic scale
plt.figure(figsize=(12, 6))
sns.histplot(degree_dist_df['node_degree'], bins=50, kde=False, color='blue')
# Use a logarithmic scale for the x-axis
plt.yscale('log')
# Adding labels and title
plt.xlabel('Node Degree')
plt.ylabel('Count (log scale)')
plt.title('Node Degree Distribution')
# Add mean, median, and percentile lines
plt.axvline(mean_degree, color='red', linestyle='dashed', linewidth=1, label=f'Mean: {mean_degree:.2f}')
plt.axvline(percentiles[0], color='purple', linestyle='dashed', linewidth=1, label=f'25th Percentile: {percentiles[0]:.2f}')
plt.axvline(percentiles[1], color='orange', linestyle='dashed', linewidth=1, label=f'50th Percentile: {percentiles[1]:.2f}')
plt.axvline(percentiles[2], color='yellow', linestyle='dashed', linewidth=1, label=f'75th Percentile: {percentiles[2]:.2f}')
plt.axvline(percentiles[3], color='brown', linestyle='dashed', linewidth=1, label=f'90th Percentile: {percentiles[3]:.2f}')
# Add legend
plt.legend()
# Show the plot
plt.show()
现实世界中的大多数网络都遵循幂律节点度分布,大多数节点的节点度相对较小,而一些重要节点的节点度很大。虽然我们的图很小,但节点度却遵循幂律。如果能确定哪个实体有 120 个关系(与 43% 的实体相连),那将会非常有趣。
db_query("""
MATCH (n:__Entity__)
RETURN n.name AS name, count{(n)-[:RELATED]-()} AS degree
ORDER BY degree DESC LIMIT 5""")

我们可以毫不犹豫地认为,Scrooge 就是这本书的主角。我还大胆猜测,Ebenezer Scrooge 和 Scrooge 其实是同一个实体,但由于微软GraphRAG 缺乏实体解析步骤,所以它们没有合并。这也表明,分析和清理数据是减少噪音信息的重要步骤,因为古腾堡计划有 13 种关系,尽管它们并不是图书故事的一部分。

最后,我们将检查每个等级的群落规模分布情况。

community_data = db_query("""
MATCH (n:__Community__)
RETURN n.level AS level, count{(n)-[:IN_COMMUNITY]-()} AS members
""")

stats = community_data.groupby('level').agg(
min_members=('members', 'min'),
max_members=('members', 'max'),
median_members=('members', 'median'),
avg_members=('members', 'mean'),
num_communities=('members', 'count'),
total_members=('members', 'sum')
).reset_index()

# Create box plot
plt.figure(figsize=(10, 6))
sns.boxplot(x='level', y='members', data=community_data, palette='viridis')
plt.xlabel('Level')
plt.ylabel('Members')

# Add statistical annotations
for i in range(stats.shape[0]):
level = stats['level'][i]
max_val = stats['max_members'][i]
text = (f"num: {stats['num_communities'][i]}\n"
f"all_members: {stats['total_members'][i]}\n"
f"min: {stats['min_members'][i]}\n"
f"max: {stats['max_members'][i]}\n"
f"med: {stats['median_members'][i]}\n"
f"avg: {stats['avg_members'][i]:.2f}")
plt.text(level, 85, text, horizontalalignment='center', fontsize=9)

plt.show()

莱顿算法确定了三个级别的社区,其中级别越高的社区平均规模越大。不过,有些技术细节我并不清楚,因为如果检查 all_members 计数,就会发现每个级别的所有节点数都不一样,尽管理论上应该是一样的。另外,如果社区在更高级别上合并,为什么 0 级有 19 个社区,而 1 级有 22 个呢?作者在这里做了一些优化和技巧,我还没有时间详细探讨。

4. 实施检索

在本博文的最后一部分,我们将讨论GraphRAG 中指定的本地和全局检索器。检索器将与 LangChain 和 LlamaIndex 一起实现和集成。

4.1 本地检索

本地检索器首先使用矢量搜索来识别相关节点,然后收集链接信息并将其注入 LLM 提示。

虽然这个图看起来很复杂,但其实很容易实现。我们首先使用基于实体描述文本嵌入的向量相似性搜索来识别相关实体。一旦确定了相关实体,我们就可以遍历相关的文本块、关系、社区摘要等。在 LangChain 和 LlamaIndex 中,使用向量相似性搜索然后遍历整个图的模式可以通过检索查询功能轻松实现。

首先,我们需要配置向量索引。

index_name = "entity"

db_query(
"""
CREATE VECTOR INDEX """
+ index_name
+ """ IF NOT EXISTS FOR (e:__Entity__) ON e.description_embedding
OPTIONS {indexConfig: {
`vector.dimensions`: 1536,
`vector.similarity_function`: 'cosine'
}}
"""
)

我们还将计算并存储社区权重,它被定义为社区中实体出现的不同文本块的数量。

db_query(
"""
MATCH (n:`__Community__`)<-[:IN_COMMUNITY]-()<-[:HAS_ENTITY]-(c)
WITH n, count(distinct c) AS chunkCount
SET n.weight = chunkCount"""
)

每个部分的候选项(文本单元、社区报告......)数量是可配置的。虽然原始实现中基于Token数的过滤略微复杂,但我们在此将其简化。我根据默认配置值开发了以下简化的顶级候选过滤值。

topChunks = 3
topCommunities = 3
topOutsideRels = 10
topInsideRels = 10
topEntities = 10

我们将从 LangChain 的实现开始。我们唯一需要定义的是检索查询(retrieval_query),它涉及的内容较多。

lc_retrieval_query = """
WITH collect(node) as nodes
// Entity - Text Unit Mapping
WITH
collect {
    UNWIND nodes as n
    MATCH (n)<-[:HAS_ENTITY]->(c:__Chunk__)
    WITH c, count(distinct n) as freq
    RETURN c.text AS chunkText
    ORDER BY freq DESC
    LIMIT $topChunks
} AS text_mapping,
// Entity - Report Mapping
collect {
    UNWIND nodes as n
    MATCH (n)-[:IN_COMMUNITY]->(c:__Community__)
    WITH c, c.rank as rank, c.weight AS weight
    RETURN c.summary 
    ORDER BY rank, weight DESC
    LIMIT $topCommunities
} AS report_mapping,
// Outside Relationships 
collect {
    UNWIND nodes as n
    MATCH (n)-[r:RELATED]-(m) 
    WHERE NOT m IN nodes
    RETURN r.description AS descriptionText
    ORDER BY r.rank, r.weight DESC 
    LIMIT $topOutsideRels
} as outsideRels,
// Inside Relationships 
collect {
    UNWIND nodes as n
    MATCH (n)-[r:RELATED]-(m) 
    WHERE m IN nodes
    RETURN r.description AS descriptionText
    ORDER BY r.rank, r.weight DESC 
    LIMIT $topInsideRels
} as insideRels,
// Entities description
collect {
    UNWIND nodes as n
    RETURN n.description AS descriptionText
} as entities
// We don't have covariates or claims here
RETURN {Chunks: text_mapping, Reports: report_mapping, 
       Relationships: outsideRels + insideRels, 
       Entities: entities} AS text, 1.0 AS score, {} AS metadata
"""


lc_vector = Neo4jVector.from_existing_index(
    OpenAIEmbeddings(),
    url=NEO4J_URI,
    username=NEO4J_USERNAME,
    password=NEO4J_PASSWORD,
    index_name=index_name,
    retrieval_query=lc_retrieval_query
)

该 Cypher 查询对一组节点执行多种分析操作,以提取和整理相关文本数据:

1.实体-文本单元映射:对于每个节点,查询会识别链接的文本块(__Chunk__),根据与每个文本块相关的不同节点的数量对它们进行聚合,并根据频率对它们进行排序。最重要的文本块以 text_mapping 的形式返回。

2.实体-报告映射:对于每个节点,查询会找到相关的社区(__社区__),并根据排名和权重返回排名靠前的社区摘要。

3.外部关系:本节提取相关实体(m)不属于初始节点集的关系(RELATED)描述。这些关系会进行排序,并仅限于最重要的外部关系。

4.内部关系:与外部关系类似,但这次只考虑两个实体都在初始节点集内的关系。

5.实体描述:只需收集初始集合中每个节点的描述。

最后,查询会将收集到的数据合并成一个结构化的结果,其中包括数据块、报告、内部和外部关系以及实体描述,还有一个默认分数和一个空元数据对象。您可以选择删除某些检索部分,以测试它们对结果的影响。

现在,你可以使用以下代码运行检索器:

docs = lc_vector.similarity_search(
    "What do you know about Cratchitt family?",
    k=topEntities,
    params={
        "topChunks": topChunks,
        "topCommunities": topCommunities,
        "topOutsideRels": topOutsideRels,
        "topInsideRels": topInsideRels,
    },
)
# print(docs[0].page_content)

同样的检索模式也可以用 LlamaIndex 来实现。对于 LlamaIndex,我们首先需要为节点添加元数据,这样矢量索引才能正常工作。如果没有在相关节点中添加默认元数据,矢量索引将返回错误。

# https://github.com/run-llama/llama_index/blob/main/llama-index-core/llama_index/core/vector_stores/utils.py#L32
from llama_index.core.schema import TextNode
from llama_index.core.vector_stores.utils import node_to_metadata_dict

content = node_to_metadata_dict(TextNode(), remove_text=True, flat_metadata=False)

db_query(
    """
  MATCH (e:__Entity__)
  SET e += $content"""
,
    {"content": content},
)

同样,我们可以使用 LlamaIndex 中的 retrieval_query 功能来定义检索器。与 LangChain 不同的是,我们将使用 f-string 而不是查询参数来传递顶级候选过滤器参数。

retrieval_query = f"""
WITH collect(node) as nodes
// Entity - Text Unit Mapping
WITH
nodes,
collect {{
    UNWIND nodes as n
    MATCH (n)<-[:HAS_ENTITY]->(c:__Chunk__)
    WITH c, count(distinct n) as freq
    RETURN c.text AS chunkText
    ORDER BY freq DESC
    LIMIT {topChunks}
}} AS text_mapping,
// Entity - Report Mapping
collect {{
    UNWIND nodes as n
    MATCH (n)-[:IN_COMMUNITY]->(c:__Community__)
    WITH c, c.rank as rank, c.weight AS weight
    RETURN c.summary 
    ORDER BY rank, weight DESC
    LIMIT {topCommunities}
}} AS report_mapping,
// Outside Relationships 
collect {{
    UNWIND nodes as n
    MATCH (n)-[r:RELATED]-(m) 
    WHERE NOT m IN nodes
    RETURN r.description AS descriptionText
    ORDER BY r.rank, r.weight DESC 
    LIMIT {topOutsideRels}
}} as outsideRels,
// Inside Relationships 
collect {{
    UNWIND nodes as n
    MATCH (n)-[r:RELATED]-(m) 
    WHERE m IN nodes
    RETURN r.description AS descriptionText
    ORDER BY r.rank, r.weight DESC 
    LIMIT {topInsideRels}
}} as insideRels,
// Entities description
collect {{
    UNWIND nodes as n
    RETURN n.description AS descriptionText
}} as entities
// We don't have covariates or claims here
RETURN "Chunks:" + apoc.text.join(text_mapping, '|') + "\nReports: " + apoc.text.join(report_mapping,'|') +  
       "\nRelationships: " + apoc.text.join(outsideRels + insideRels, '|') + 
       "\nEntities: " + apoc.text.join(entities, "|") AS text, 1.0 AS score, nodes[0].id AS id, {{_node_type:nodes[0]._node_type, _node_content:nodes[0]._node_content}} AS metadata
"""

此外,返回方式也略有不同。我们需要将节点类型和内容作为元数据返回;否则,检索器就会崩溃。现在,我们只需实例化 Neo4j 向量存储,并将其用作查询引擎。现在我们可以测试 GraphRAG 本地检索器了。

response = loaded_index.query("What do you know about Scrooge?")
print(response.response)
#print(response.source_nodes[0].text)
# Scrooge is an employee who is impacted by the generosity and festive spirit 
# of the Fezziwig family, particularly Mr. and Mrs. Fezziwig. He is involved 
# in the memorable Domestic Ball hosted by the Fezziwigs, which significantly 
# influences his life and contributes to the broader narrative of kindness 
# and community spirit.

4.2 全局检索器

全局检索器的结构稍微简单一些。它似乎是在指定的层次上遍历所有社区摘要,生成中间摘要,然后根据中间摘要生成最终回复。

我们必须事先确定要迭代哪个层次,这不是一个简单的决定,因为我们不知道哪个层次效果更好。层次越高,社区规模越大,但数量越少。这是我们在没有手动检查摘要的情况下所掌握的唯一信息。其他参数允许我们忽略低于等级或权重阈值的社区,但我们在这里不会使用这些参数。我们将使用 LangChain 实现全局检索器,并使用与 GraphRAG 论文中相同的map和reduce提示。由于系统提示非常长,我们将不在这里介绍,也不介绍链的构建。不过,所有代码都可以在Notebook[4]中找到。

def global_retriever(query: str, level: int, response_type: str = response_type) -> str:
    community_data = graph.query(
        """
    MATCH (c:__Community__)
    WHERE c.level = $level
    RETURN c.full_content AS output
    """
,
        params={"level": level},
    )
    intermediate_results = []
    for community in tqdm(community_data, desc="Processing communities"):
        intermediate_response = map_chain.invoke(
            {"question": query, "context_data": community["output"]}
        )
        intermediate_results.append(intermediate_response)
    final_response = reduce_chain.invoke(
        {
            "report_data": intermediate_results,
            "question": query,
            "response_type": response_type,
        }
    )
    return final_response

现在让我们来测试一下。

print(global_retriever("What is the story about?", 2))

故事主要围绕着埃比尼泽-史古治展开,他是一个吝啬鬼,起初对生活抱有玩世不恭的态度,鄙视圣诞节。当他已故生意伙伴雅各布-马利的鬼魂拜访他时,他的转变开始了,随后出现了三个鬼魂,分别代表过去的圣诞节、现在的圣诞节和未来的圣诞节。这些遭遇促使史古治反思自己的生活和行为后果,最终使他接受了圣诞精神,经历了重大的个人成长[数据:报告(32, 17, 99, 86, +更多)]。### 雅各布-马利和鬼魂的作用 雅各布-马利的鬼魂起到了超自然催化剂的作用,他警告史古治三个鬼魂即将来访。每个鬼魂都会引导史古治进行一次自我发现之旅,说明他的选择所产生的影响以及同情心的重要性。鬼魂们向史古治揭示了他的行为不仅影响了他自己的生活,也影响了其他人的生活,尤其突出了救赎和相互关联的主题[数据:报告(86,17,99,+更多)]。### 史古治的关系与转变 史古治与克拉基特一家,尤其是鲍勃-克拉基特和他的儿子小添的关系,对他的转变至关重要。通过鬼魂呈现的幻象,史古治产生了同理心,这促使他采取实际行动,改善了克拉基特一家的境况。叙事强调了个人行为可以对社会产生深远影响,因为史古治新发现的慷慨在他的社区中培养了同情心和社会责任感[数据:报告(25、158、159、+更多)]。### 救赎与希望的主题 总的来说,这个故事是希望的永恒象征,突出了同情、自省和个人改变的潜力等主题。史古治从一个孤独的守财奴到一个仁慈的形象的转变过程说明,任何时候改变都为时不晚;小小的善举可以对个人和更广泛的社会产生重大的积极影响[数据:报告(32,102,126,148,158,159,+更多)]。总之,这个故事概括了圣诞节的变革力量和人与人之间联系的重要性,是一个关于救赎和一个人在节日期间对他人的影响的凄美叙事。

该响应相当长,而且很详尽,因为它符合全局检索器的要求,可以遍历指定层次上的所有社区。您可以测试如果改变社区的层次结构,响应会如何变化。

5 总结

在这篇博文中,我们演示了如何将微软的 GraphRAG 集成到 Neo4j 中,并使用 LangChain 和 LlamaIndex 实现检索器。这样就可以将 GraphRAG 与其他检索器或代理无缝集成。本地检索器将向量相似性搜索与图遍历相结合,而全局检索器则遍历社区摘要以生成综合响应。这种实现方式展示了将结构化知识图谱与语言模型相结合以增强信息检索和问题解答的能力。值得注意的是,这种知识图谱还有定制和实验的空间,我们将在下一篇博文中对此进行探讨。


53AI,企业落地应用大模型首选服务商

产品:大模型应用平台+智能体定制开发+落地咨询服务

承诺:先做场景POC验证,看到效果再签署服务协议。零风险落地应用大模型,已交付160+中大型企业

联系我们

售前咨询
186 6662 7370
预约演示
185 8882 0121

微信扫码

与创始人交个朋友

回到顶部

 
扫码咨询