赞
踩
expanding-the-sec-knowledge-graph
from dotenv import load_dotenv
import os
import textwrap
# Langchain
from langchain_community.graphs import Neo4jGraph
from langchain_community.vectorstores import Neo4jVector
from langchain_openai import OpenAIEmbeddings
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.chains import RetrievalQAWithSourcesChain
from langchain_openai import ChatOpenAI
# Warning control
import warnings
warnings.filterwarnings("ignore")
# Load from environment
load_dotenv('.env', override=True)
NEO4J_URI = os.getenv('NEO4J_URI')
NEO4J_USERNAME = os.getenv('NEO4J_USERNAME')
NEO4J_PASSWORD = os.getenv('NEO4J_PASSWORD')
NEO4J_DATABASE = os.getenv('NEO4J_DATABASE') or 'neo4j'
# Global constants
VECTOR_INDEX_NAME = 'form_10k_chunks'
VECTOR_NODE_LABEL = 'Chunk'
VECTOR_SOURCE_PROPERTY = 'text'
VECTOR_EMBEDDING_PROPERTY = 'textEmbedding'
kg = Neo4jGraph(
url=NEO4J_URI,
username=NEO4J_USERNAME,
password=NEO4J_PASSWORD,
database=NEO4J_DATABASE
)
数据原始表格以XML格式存在,在数据准备过程中,从XML中提取特定字段,并将其添加为CSV文件中的一行。
import csv
all_form13s = []
with open('./data/form13.csv', mode='r') as csv_file:
csv_reader = csv.DictReader(csv_file)
for row in csv_reader: # each row will be a dictionary
all_form13s.append(row)
all_form13s[0:5]
可以看到这些公司每个都投资与同一家公司:NETAPP
所有这些管理公司都有不同的名称,但他们都是NETAPP 的投资者
还可以看到有关公司的详细信息,列如经理姓名、经理地址、以及这个中央索引键或者CIK键,还有关于特定投资者的信息…
[{'source': 'https://sec.gov/Archives/edgar/data/1000275/0001140361-23-039575.txt', 'managerCik': '1000275', 'managerAddress': 'ROYAL BANK PLAZA, 200 BAY STREET, TORONTO, A6, M5J2J5', 'managerName': 'Royal Bank of Canada', 'reportCalendarOrQuarter': '2023-06-30', 'cusip6': '64110D', 'cusip': '64110D104', 'companyName': 'NETAPP INC', 'value': '64395000000.0', 'shares': '842850'}, {'source': 'https://sec.gov/Archives/edgar/data/1002784/0001387131-23-009542.txt', 'managerCik': '1002784', 'managerAddress': '1875 Lawrence Street, Suite 300, Denver, CO, 80202-1805', 'managerName': 'SHELTON CAPITAL MANAGEMENT', 'reportCalendarOrQuarter': '2023-06-30', 'cusip6': '64110D', 'cusip': '64110D104', 'companyName': 'NETAPP INC', 'value': '2989085000.0', 'shares': '39124'}, {'source': 'https://sec.gov/Archives/edgar/data/1007280/0001007280-23-000008.txt', 'managerCik': '1007280', 'managerAddress': '277 E TOWN ST, COLUMBUS, OH, 43215', 'managerName': 'PUBLIC EMPLOYEES RETIREMENT SYSTEM OF OHIO', 'reportCalendarOrQuarter': '2023-06-30', 'cusip6': '64110D', 'cusip': '64110D104', 'companyName': 'Netapp Inc', 'value': '8170000.0', 'shares': '106941'}, {'source': 'https://sec.gov/Archives/edgar/data/1007399/0001007399-23-000004.txt', 'managerCik': '1007399', 'managerAddress': '150 WEST MAIN STREET, SUITE 1700, NORFOLK, VA, 23510', 'managerName': 'WILBANKS SMITH & THOMAS ASSET MANAGEMENT LLC', 'reportCalendarOrQuarter': '2023-06-30', 'cusip6': '64110D', 'cusip': '64110D104', 'companyName': 'NETAPP INC', 'value': '505539000.0', 'shares': '6617'}, {'source': 'https://sec.gov/Archives/edgar/data/1008894/0001172661-23-003025.txt', 'managerCik': '1008894', 'managerAddress': '250 Park Avenue South, Suite 250, Winter Park, FL, 32789', 'managerName': 'DEPRINCE RACE & ZOLLO INC', 'reportCalendarOrQuarter': '2023-06-30', 'cusip6': '64110D', 'cusip': '64110D104', 'companyName': 'NETAPP INC', 'value': '24492389000.0', 'shares': '320581'}]
len(all_form13s) # 561
所以我们会创建561家公司(在图数据中)
# 从创建公司节点开始(前五行只有一个公司 NetApp) # 合并具有根据QSIP6标识符唯一的公司标签的公司节点 first_form13 = all_form13s[0] cypher = """ MERGE (com:Company {cusip6: $cusip6}) ON CREATE SET com.companyName = $companyName, com.cusip = $cusip """ kg.query(cypher, params={ 'cusip6':first_form13['cusip6'], 'companyName':first_form13['companyName'], 'cusip':first_form13['cusip'] })
快速检查下,我们希望创建的公司是 NetApp
cypher = """
MATCH (com:Company)
RETURN com LIMIT 1
"""
kg.query(cypher)
nice!
[{'com': {'cusip': '64110D104',
'companyName': 'NETAPP INC',
'cusip6': '64110D'}}]
之前在知识图谱中有一个NetApp 的Form 10k 表格,可以通过找到基于QSIP6标识符的配对将新创建的公式节点与相关的Form 10K进行匹配。
# 这里做了一个匹配,匹配一对节点,一个公司和一个表格,这两个节点中的QSIP6都是相同的
cypher = """
MATCH (com:Company), (form:Form)
WHERE com.cusip6 = form.cusip6
RETURN com.companyName, form.names
"""
kg.query(cypher)
[{'com.companyName': 'NETAPP INC', 'form.names': ['Netapp Inc', 'NETAPP INC']}]
我们可以再次运行该匹配,但是现在注意:表格中的公司名称不仅显示为Netapp Inc,还有一些变体。
我们可以将这些变体值提取到公司节点中以丰富它。
# 这里基于QSIP6 进行相同的匹配,然后一旦匹配到它,我们将设置公司的名称为表单的名称
cypher = """
MATCH (com:Company), (form:Form)
WHERE com.cusip6 = form.cusip6
SET com.names = form.names
"""
kg.query(cypher)
利用来自公司到表单的相同配对,我们现在实际上要创建一个关系,以便我们知道这家公司提交了这个表单。
FILED
关系在公司节点与Form-10K 节点kg.query("""
MATCH (com:Company), (form:Form)
WHERE com.cusip6 = form.cusip6
MERGE (com)-[:FILED]->(form)
""")
投资管理节点将具有一个经理标签。下面创建它。
# 投资管理节点将具有一个经理标签
# 下面创建一个具有Manager 标签的经理,我们希望它根据经理的CIK标号是唯一的。
# 创建时还设置了经理的姓名和地址
cypher = """
MERGE (mgr:Manager {managerCik: $managerParam.managerCik})
ON CREATE
SET mgr.managerName = $managerParam.managerName,
mgr.managerAddress = $managerParam.managerAddress
"""
kg.query(cypher, params={'managerParam': first_form13})
首先将只针对第一个Form 13 做上面的操作
kg.query("""
MATCH (mgr:Manager)
RETURN mgr LIMIT 1
""")
[{'mgr': {'managerCik': '1000275',
'managerAddress': 'ROYAL BANK PLAZA, 200 BAY STREET, TORONTO, A6, M5J2J5',
'managerName': 'Royal Bank of Canada'}}]
管理公司有很多,上面查看有561家。所以我们创建一个唯一性约束,以避免意外创建重复项。
kg.query("""
CREATE CONSTRAINT unique_manager
IF NOT EXISTS
FOR (n:Manager)
REQUIRE n.managerCik IS UNIQUE
""")
另外,我们可以在管理节点(manager nodes)上创建一个全文索引(full-text index).
全文索引对关键字搜索非常有用:你可以考虑一个向量索引,它允许你基于相似的概念进行索引。
kg.query("""
CREATE FULLTEXT INDEX fullTextManagerNames
IF NOT EXISTS
FOR (mgr:Manager)
ON EACH [mgr.managerName]
""")
全文索引允许基于相似的字符串进行搜索。
# 可以尝试想直接查询向量索引一样查询全文索引
# 这里的 query : royal bank
# 该查询将返回一个节点和一个分数,非常类似于向量搜索的情况
# 如果匹配成功,我们将找到节点的管理者名称,然后还有分数
kg.query("""
CALL db.index.fulltext.queryNodes("fullTextManagerNames",
"royal bank") YIELD node, score
RETURN node.managerName, score
""")
[{'node.managerName': 'Royal Bank of Canada', 'score': 0.2615291476249695}]
cypher = """
MERGE (mgr:Manager {managerCik: $managerParam.managerCik})
ON CREATE
SET mgr.managerName = $managerParam.managerName,
mgr.managerAddress = $managerParam.managerAddress
"""
# loop through all Form 13s
for form13 in all_form13s:
kg.query(cypher, params={'managerParam': form13 })
再次进行健全性检查
kg.query("""
MATCH (mgr:Manager)
RETURN count(mgr)
""")
[{'count(mgr)': 561}]
OWNS_STOCK_IN
关系# 使用来自Form 13 csv 的信息找到经理节点和公司节点的配对
# 在这个查询中我们传递一个名为 `investmentParam` 的查询参数
# 在匹配中,我们将匹配投资参数经理CIK
# 然后,为了找到同一行中的公司,我们将查找投资参数QSIP6
# 因此,此此匹配将基于这两个值找到一名经理和一家与该行匹配的公司
cypher = """
MATCH (mgr:Manager {managerCik: $investmentParam.managerCik}),
(com:Company {cusip6: $investmentParam.cusip6})
RETURN mgr.managerName, com.companyName, $investmentParam as investment
"""
kg.query(cypher, params={
'investmentParam': first_form13
})
此此匹配将基于这两个值找到一名经理和一家与该行匹配的公司:加拿大皇家银行(经理)和NETAPP(公司)
[{'mgr.managerName': 'Royal Bank of Canada',
'com.companyName': 'NETAPP INC',
'investment': {'shares': '842850',
'source': 'https://sec.gov/Archives/edgar/data/1000275/0001140361-23-039575.txt',
'managerName': 'Royal Bank of Canada',
'managerAddress': 'ROYAL BANK PLAZA, 200 BAY STREET, TORONTO, A6, M5J2J5',
'value': '64395000000.0',
'cusip6': '64110D',
'cusip': '64110D104',
'reportCalendarOrQuarter': '2023-06-30',
'companyName': 'NETAPP INC',
'managerCik': '1000275'}}]
可以找到一个经理节点和他们投资的公司了,接下来将这些节点连接起来。
# 第一行 `MATCH` 是和之前完全相同的匹配 # 然后就有了一个经理和他们投资的相关公司 # 然后用 `MERGE` 创建他们之间的关系: `OWNS_STOCK_IN` # 经理拥有所投资的公司的股权关系 # 我们希望拥有的股权是唯一的,以防他们有多次投资: # 回想一下csv文件,一些行有一个报告日历或者季度值,我们将其用作即将创建的拥有股权关系上的唯一属性 # 属性将被称为报告日历或者季度(reportCalendarOrQuarter) # `MERGE` 创建关系的同时(ON CREATE)也将创建(SET )一些额外额值 # 注意, SET的主体是 `owns` ,记得在创建关系时命名关系变量 `owns` # 最后我们将从经理、创建的关系和公司的名称中 返回(RETURN )一些属性 # 当我们用KG 查询调用它时,要传递的参数称为 `ownsParam`,现在只使用first_form13 (第一个 form 13) cypher = """ MATCH (mgr:Manager {managerCik: $ownsParam.managerCik}), (com:Company {cusip6: $ownsParam.cusip6}) MERGE (mgr)-[owns:OWNS_STOCK_IN { reportCalendarOrQuarter: $ownsParam.reportCalendarOrQuarter }]->(com) ON CREATE SET owns.value = toFloat($ownsParam.value), owns.shares = toInteger($ownsParam.shares) RETURN mgr.managerName, owns.reportCalendarOrQuarter, com.companyName """ kg.query(cypher, params={ 'ownsParam': first_form13 })
[{'mgr.managerName': 'Royal Bank of Canada',
'owns.reportCalendarOrQuarter': '2023-06-30',
'com.companyName': 'NETAPP INC'}]
我们再进行一个快速查询来进行健全性检查,确保关系实际存在
# 我们将在这里抓取模式中的关系,然后查看 `owns`关系,最后返回股份和值
kg.query("""
MATCH (mgr:Manager {managerCik: $ownsParam.managerCik})
-[owns:OWNS_STOCK_IN]->
(com:Company {cusip6: $ownsParam.cusip6})
RETURN owns { .shares, .value }
""", params={ 'ownsParam': first_form13 })
[{'owns': {'shares': 842850, 'value': 64395000000.0}}]
做过一次之后,现在可以循环遍历csv文件的所有行来创建管理公司和他们的投资的公司之间的自由股票和关系。
cypher = """
MATCH (mgr:Manager {managerCik: $ownsParam.managerCik}),
(com:Company {cusip6: $ownsParam.cusip6})
MERGE (mgr)-[owns:OWNS_STOCK_IN {
reportCalendarOrQuarter: $ownsParam.reportCalendarOrQuarter
}]->(com)
ON CREATE
SET owns.value = toFloat($ownsParam.value),
owns.shares = toInteger($ownsParam.shares)
"""
#loop through all Form 13s
for form13 in all_form13s:
kg.query(cypher, params={'ownsParam': form13 })
再做一下快速检查,确保上面的操作成功
cypher = """
MATCH (:Manager)-[owns:OWNS_STOCK_IN]->(:Company)
RETURN count(owns) as investments
"""
kg.query(cypher)
正确的话有561个关系被创建成功
[{'investments': 561}]
最初我们只有Form 10K的片段,然后我们链接了这些片段,并创建了一个与这些片段相链接的形式。
现在我们创建了公司和经理,所有的这些都已经链接起来了
让我们看一下知识图谱的结构,以了解我们所做的所有工作产生的内容
# 刷新知识图谱上的模式
# 然后打印出该模式
kg.refresh_schema()
print(textwrap.fill(kg.schema, 60))
利用TextWrap使打印的格式更优美一些
Node properties are the following: Chunk {textEmbedding:
LIST, f10kItem: STRING, chunkSeqId: INTEGER, text: STRING,
cik: STRING, cusip6: STRING, names: LIST, formId: STRING,
source: STRING, chunkId: STRING},Form {cusip6: STRING,
names: LIST, formId: STRING, source: STRING},Company
{cusip6: STRING, names: LIST, companyName: STRING, cusip:
STRING},Manager {managerName: STRING, managerCik: STRING,
managerAddress: STRING} Relationship properties are the
following: SECTION {f10kItem: STRING},OWNS_STOCK_IN {shares:
INTEGER, reportCalendarOrQuarter: STRING, value: FLOAT} The
relationships are the following: (:Chunk)-[:NEXT]-
>(:Chunk),(:Chunk)-[:PART_OF]->(:Form),(:Form)-[:SECTION]-
>(:Chunk),(:Company)-[:FILED]->(:Form),(:Manager)-
[:OWNS_STOCK_IN]->(:Company)
从上面可以看到:
首先我们可以从一个Form通过一个Section到一个chunk,只是我们找到链表开头的方式;最后我们发现经理在一家公司拥有股票,而且这家公司已经提交了一份表格(Form 13)。所有这些加在一起就是我们创建的知识图谱。
# 首先找到一个随机的块以备以后的查询使用
# 所以我们只需要匹配并返回第一个块的ID作为 chunkId ,并将其限制为1个
cypher = """
MATCH (chunk:Chunk)
RETURN chunk.chunkId as chunkId LIMIT 1
"""
chunk_rows = kg.query(cypher)
print(chunk_rows)
这是我们找到的第一个块
#
[{'chunkId': '0000950170-23-027948-item1-chunk0000'}]
这是一个带有字典的列表,所以首先让我们从列表中提取出东西
chunk_first_row = chunk_rows[0]
print(chunk_first_row)
ref_chunk_id = chunk_first_row['chunkId']
ref_chunk_id
从这个块的第一行开始,我们将提取块ID并将其存储在 ref_chunk_id
中以备以后查询使用
我们将从我们已知的块开始构建路径,通过一个表单返回到我们可以一步一步发现其他东西…
chunkId
开始,然后通过PART_OF
关系找到它所属的源(source)cypher = """
MATCH (:Chunk {chunkId: $chunkIdParam})-[:PART_OF]->(f:Form)
RETURN f.source
"""
kg.query(cypher, params={'chunkIdParam': ref_chunk_id})
[{'f.source': 'https://www.sec.gov/Archives/edgar/data/1002047/000095017023027948/0000950170-23-027948-index.htm'}]
在做一步,在这个模式中,我们将它实际上分成两行:
cypher = """
MATCH (:Chunk {chunkId: $chunkIdParam})-[:PART_OF]->(f:Form),
(com:Company)-[:FILED]->(f)
RETURN com.companyName as name
"""
kg.query(cypher, params={'chunkIdParam': ref_chunk_id})
[{'name': 'NETAPP INC'}]
再深入一步:
PS:经理就是投资者的意思
cypher = """
MATCH (:Chunk {chunkId: $chunkIdParam})-[:PART_OF]->(f:Form),
(com:Company)-[:FILED]->(f),
(mgr:Manager)-[:OWNS_STOCK_IN]->(com)
RETURN com.companyName,
count(mgr.managerName) as numberOfinvestors
LIMIT 1
"""
kg.query(cypher, params={
'chunkIdParam': ref_chunk_id
})
验证一下,这家NetApp公司,正如预期的一样,有561名投资者(经理)
[{'com.companyName': 'NETAPP INC', 'numberOfinvestors': 561}]
cypher = """
MATCH (:Chunk {chunkId: $chunkIdParam})-[:PART_OF]->(f:Form),
(com:Company)-[:FILED]->(f),
(mgr:Manager)-[owns:OWNS_STOCK_IN]->(com)
RETURN mgr.managerName + " owns " + owns.shares +
" shares of " + com.companyName +
" at a value of $" +
apoc.number.format(toInteger(owns.value)) AS text
LIMIT 10
"""
kg.query(cypher, params={
'chunkIdParam': ref_chunk_id
})
还是之前的相同匹配(MATCH),相同的模式,然后不仅仅是返回那些数据,而是将其中的一些数据转换成一个字符串句子。PS:为了结果更好看做了几点处理:toInteger 将字符串格式化为整数,然后使用APOC数字格式(apoc.number.format)进行格式化,这将添加一些逗号以提高可读性。
[{'text': 'CSS LLC/IL owns 12500 shares of NETAPP INC at a value of $955,000,000'},
{'text': 'BOKF, NA owns 40774 shares of NETAPP INC at a value of $3,115,134,000'},
{'text': 'BANK OF NOVA SCOTIA owns 18676 shares of NETAPP INC at a value of $1,426,847,000'},
{'text': 'Jefferies Financial Group Inc. owns 23200 shares of NETAPP INC at a value of $1,772,480,000'},
{'text': 'DEUTSCHE BANK AG\\ owns 929854 shares of NETAPP INC at a value of $71,040,845,000'},
{'text': 'TORONTO DOMINION BANK owns 183163 shares of NETAPP INC at a value of $13,984,000'},
{'text': 'STATE BOARD OF ADMINISTRATION OF FLORIDA RETIREMENT SYSTEM owns 265756 shares of NETAPP INC at a value of $20,303,759,000'},
{'text': 'NISA INVESTMENT ADVISORS, LLC owns 67848 shares of NETAPP INC at a value of $5,183,587,000'},
{'text': 'ONTARIO TEACHERS PENSION PLAN BOARD owns 7290 shares of NETAPP INC at a value of $556,956,000'},
{'text': 'STATE STREET CORP owns 9321206 shares of NETAPP INC at a value of $712,140,138,000'}]
将结果保存在results
变量中,尝试提取出这些句子
results = kg.query(cypher, params={
'chunkIdParam': ref_chunk_id
})
print(textwrap.fill(results[0]['text'], 60))
CSS LLC/IL owns 12500 shares of NETAPP INC at a value of
$955,000,000
我们将设置两个不同的langchain 的chain:
vector_store = Neo4jVector.from_existing_graph( embedding=OpenAIEmbeddings(), url=NEO4J_URI, username=NEO4J_USERNAME, password=NEO4J_PASSWORD, index_name=VECTOR_INDEX_NAME, node_label=VECTOR_NODE_LABEL, text_node_properties=[VECTOR_SOURCE_PROPERTY], embedding_node_property=VECTOR_EMBEDDING_PROPERTY, ) # Create a retriever from the vector store retriever = vector_store.as_retriever() # Create a chatbot Question & Answer chain from the retriever plain_chain = RetrievalQAWithSourcesChain.from_chain_type( ChatOpenAI(temperature=0), chain_type="stuff", retriever=retriever )
# 投资检索查询 # 从一个特定节点开始经过向量搜索找到一个与问题相似的节点 # 我们从这个节点开始,知道它是表单的一部分 # 从表单中,我们知道它是某个公司提交的 # 有一个经理在那家公司持有股份(箭头反着,所以要反着读) # 从源节点开始,我们还要获取分数、经理、拥有关系、公司 # 我们将按投资股份的降序对所有这些进行排序,限制数量10个 # 然后基于以上获取的字段内容建成一个长文本,并命名为 investment_statements,作为向量搜索返回的文本的前缀 investment_retrieval_query = """ MATCH (node)-[:PART_OF]->(f:Form), (f)<-[:FILED]-(com:Company), (com)<-[owns:OWNS_STOCK_IN]-(mgr:Manager) WITH node, score, mgr, owns, com ORDER BY owns.shares DESC LIMIT 10 WITH collect ( mgr.managerName + " owns " + owns.shares + " shares in " + com.companyName + " at a value of $" + apoc.number.format(toInteger(owns.value)) + "." ) AS investment_statements, node, score RETURN apoc.text.join(investment_statements, "\n") + "\n" + node.text AS text, score, { source: node.source } as metadata """
vector_store_with_investment = Neo4jVector.from_existing_index( OpenAIEmbeddings(), url=NEO4J_URI, username=NEO4J_USERNAME, password=NEO4J_PASSWORD, database="neo4j", index_name=VECTOR_INDEX_NAME, text_node_property=VECTOR_SOURCE_PROPERTY, retrieval_query=investment_retrieval_query, ) # Create a retriever from the vector store(创建一个检索器) retriever_with_investments = vector_store_with_investment.as_retriever() # Create a chatbot Question & Answer chain from the retriever(创建一个chain:investment_chain (投资链)) investment_chain = RetrievalQAWithSourcesChain.from_chain_type( ChatOpenAI(temperature=0), chain_type="stuff", retriever=retriever_with_investments )
先试一个比较明显的问题,我们知道这一切都与NetApp有关。
question = "In a single sentence, tell me about Netapp."
plain_chain(
{"question": question},
return_only_outputs=True,
)
investment_chain(
{"question": question},
return_only_outputs=True,
)
实际上看起来相似,这并不意外。LLM忽略了有关投资的额外信息,因为我们实际上并没有询问那方面的内容。
换个问题,比如问下投资者
question = "In a single sentence, tell me about Netapp investors."
plain_chain(
{"question": question},
return_only_outputs=True,
)
investment_chain(
{"question": question},
return_only_outputs=True,
)
我们可以看到投资者包括KPC集团,一些财务官员、剑桥投资。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。