From b7e65fe108fc399259c382f7c3aef5fb330c32e6 Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Fri, 6 Sep 2024 21:30:38 +0800 Subject: [PATCH 1/3] fix graph bugs --- src/core/retriever.py | 130 ++++++++++++++++++++----------------- src/views/database_view.py | 2 +- 2 files changed, 70 insertions(+), 62 deletions(-) diff --git a/src/core/retriever.py b/src/core/retriever.py index 2071f3cb..bafa7f84 100644 --- a/src/core/retriever.py +++ b/src/core/retriever.py @@ -1,5 +1,6 @@ from src.models.embedding import Reranker from src.utils.logging_config import setup_logger + logger = setup_logger("server-common") @@ -37,12 +38,14 @@ class Retriever: # 解析图数据库的结果 db_res = refs.get("graph_base").get("results", []) if len(db_res["nodes"]) > 0: - db_text = '\n'.join([f"{edge['source_name']}和{edge['target_name']}的关系是{edge['type']}" for edge in db_res['edges']]) + db_text = "\n".join( + [f"{edge['source_name']}和{edge['target_name']}的关系是{edge['type']}" for edge in db_res["edges"]] + ) external += f"图数据库信息: \n\n{db_text}" # 构造查询 if len(external) > 0: - query = f"以下是参考资料:\n\n\n{external}\n\n\n请根据前面的知识回答:{query}" + query = f"参考资料:\n\n\n{external}\n\n\n请根据前面的知识回答问题。\n\n问题:{query}\n\n回答:" return query @@ -70,7 +73,12 @@ class Retriever: kb_res = [] final_res = [] if not refs["meta"].get("db_name") or not self.config.enable_knowledge_base: - return {"results": final_res, "all_results": kb_res, "rw_query": query, "message": "Knowledge base is disabled"} + return { + "results": final_res, + "all_results": kb_res, + "rw_query": query, + "message": "Knowledge base is disabled", + } rw_query = self.rewrite_query(query, history, refs) @@ -85,7 +93,7 @@ class Retriever: if self.config.enable_reranker: RERANK_THRESHOLD = 0.001 for r in kb_res: - r["rerank_score"] = self.reranker.compute_score([query, r["entity"]["text"]], normalize=True) + r["rerank_score"] = self.reranker.compute_score([rw_query, r["entity"]["text"]], normalize=True) kb_res.sort(key=lambda x: x["rerank_score"], reverse=True) final_res = [_res for _res in kb_res if _res["rerank_score"] > RERANK_THRESHOLD] @@ -101,7 +109,8 @@ class Retriever: rewritten_query = query else: from src.utils.prompts import rewritten_query_prompt_template - history_query = [entry['content'] for entry in history if entry['role'] == 'user'] if history else "" + + history_query = [entry["content"] for entry in history if entry["role"] == "user"] if history else "" rewritten_query_prompt = rewritten_query_prompt_template.format(history=history_query, query=query) rewritten_query = self.model.predict(rewritten_query_prompt).content @@ -118,54 +127,70 @@ class Retriever: entities = [] if refs["meta"].get("use_graph"): from src.utils.prompts import entity_extraction_prompt_template + entity_extraction_prompt = entity_extraction_prompt_template.format(text=query) entities = self.model.predict(entity_extraction_prompt).content.split(",") - entities = [entity for entity in entities if all(char.isalnum() or char in '汉字' for char in entity)] + entities = [entity for entity in entities if all(char.isalnum() or char in "汉字" for char in entity)] return entities - def foramt_general_results(self, results): + def _extract_relationship_info(self, relationship, source_name, target_name): + """ + 提取关系信息并返回格式化的节点和边信息 + """ + rel_id = relationship.element_id + nodes = relationship.nodes + if len(nodes) != 2: + return None, None + + source, target = nodes + source_id = source.element_id + target_id = target.element_id + + relationship_type = relationship._properties.get("type", "unknown") + if relationship_type == "unknown": + relationship_type = relationship.type + + edge_info = { + "id": rel_id, + "type": relationship_type, + "source_id": source_id, + "target_id": target_id, + "source_name": source_name, + "target_name": target_name, + } + + node_info = [ + {"id": source_id, "name": source_name}, + {"id": target_id, "name": target_name}, + ] + + return node_info, edge_info + + def format_general_results(self, results): logger.debug(f"Formatting general results: {results}") formatted_results = {"nodes": [], "edges": []} for item in results: relationship = item[1] - rel_id = relationship.element_id - nodes = relationship.nodes - if len(nodes) != 2: + source_name = item[0]._properties.get("name", "unknown") + target_name = item[2]._properties.get("name", "unknown") if len(item) > 2 else "unknown" + + node_info, edge_info = self._extract_relationship_info(relationship, source_name, target_name) + if node_info is None or edge_info is None: continue - source, target = nodes + for node in node_info: + if node["id"] not in [n["id"] for n in formatted_results["nodes"]]: + formatted_results["nodes"].append(node) - source_id = source.element_id - target_id = target.element_id - source_name = source._properties.get('name', 'unknown') - target_name = target._properties.get('name', 'unknown') - - if source_id not in formatted_results["nodes"]: - formatted_results["nodes"].append({"id": source_id, "name": source_name}) - if target_id not in formatted_results["nodes"]: - formatted_results["nodes"].append({"id": target_id, "name": target_name}) - - relationship_type = relationship._properties.get('type', 'unknown') - if relationship_type == 'unknown': - relationship_type = relationship.type - - formatted_results["edges"].append({ - "id": rel_id, - "type": relationship_type, - "source_id": source_id, - "target_id": target_id, - "source_name": source_name, - "target_name": target_name - }) + formatted_results["edges"].append(edge_info) return formatted_results def format_query_results(self, results): logger.debug(f"Formatting query results: {results}") formatted_results = {"nodes": [], "edges": []} - node_dict = {} for item in results: @@ -173,35 +198,18 @@ class Retriever: continue relationship = item[1][0] - rel_id = relationship.element_id - nodes = relationship.nodes - if len(nodes) != 2: + source_name = item[0] + target_name = item[2] if len(item) > 2 else "unknown" + + node_info, edge_info = self._extract_relationship_info(relationship, source_name, target_name) + if node_info is None or edge_info is None: continue - source, target = nodes + for node in node_info: + if node["id"] not in node_dict: + node_dict[node["id"]] = node - source_id = source.element_id - target_id = target.element_id - source_name = item[0] - target_name = item[2] if len(item) > 2 else 'unknown' - - if source_id not in node_dict: - node_dict[source_id] = {"id": source_id, "name": source_name} - if target_id not in node_dict: - node_dict[target_id] = {"id": target_id, "name": target_name} - - relationship_type = relationship._properties.get('type', 'unknown') - if relationship_type == 'unknown': - relationship_type = relationship.type - - formatted_results["edges"].append({ - "id": rel_id, - "type": relationship_type, - "source_id": source_id, - "target_id": target_id, - "source_name": source_name, - "target_name": target_name - }) + formatted_results["edges"].append(edge_info) formatted_results["nodes"] = list(node_dict.values()) @@ -210,4 +218,4 @@ class Retriever: def __call__(self, query, history, meta): refs = self.retrieval(query, history, meta) query = self.construct_query(query, refs, meta) - return query, refs \ No newline at end of file + return query, refs diff --git a/src/views/database_view.py b/src/views/database_view.py index 98adcfae..5f5057c6 100644 --- a/src/views/database_view.py +++ b/src/views/database_view.py @@ -135,7 +135,7 @@ def get_graph_nodes(): logger.debug(f"Get graph nodes in {kgdb_name} with {num} nodes") result = startup.dbm.graph_base.get_sample_nodes(kgdb_name, num) - return jsonify({'result': startup.retriever.foramt_general_results(result), 'message': 'success'}), 200 + return jsonify({'result': startup.retriever.format_general_results(result), 'message': 'success'}), 200 @db.route('/graph/add', methods=['POST']) def add_graph_entity(): From f9b8d5b2e932ac8fccf3092d401a1a9f9e2db4ed Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Fri, 6 Sep 2024 21:30:49 +0800 Subject: [PATCH 2/3] fix graph bugs --- web/src/components/RefsComponent.vue | 21 +++- web/src/views/GraphView.vue | 154 ++++++++++++++------------- web/src/views/SettingView.vue | 19 +++- 3 files changed, 110 insertions(+), 84 deletions(-) diff --git a/web/src/components/RefsComponent.vue b/web/src/components/RefsComponent.vue index 895411f9..ef2bbd0e 100644 --- a/web/src/components/RefsComponent.vue +++ b/web/src/components/RefsComponent.vue @@ -6,11 +6,11 @@ {{ filename }}
-

文件名: {{ results[0].file.filename }}

+

文件名: {{ filename }}

文件类型: {{ results[0].file.type }}

创建时间: {{ new Date(results[0].file.created_at * 1000).toLocaleString() }}

@@ -47,7 +47,7 @@ diff --git a/web/src/views/GraphView.vue b/web/src/views/GraphView.vue index 53d0d8d5..9f4933f3 100644 --- a/web/src/views/GraphView.vue +++ b/web/src/views/GraphView.vue @@ -10,18 +10,18 @@
-

图数据库 {{ graph?.database_name }}

+

图数据库 {{ graphInfo?.database_name }}

- 加载中 - + 加载中 + - {{ graph?.status }} · - 共 {{ graph?.entity_count }} 实体,{{ graph?.relationship_count }} 个关系 + {{ graphInfo?.status }} · + 共 {{ graphInfo?.entity_count }} 实体,{{ graphInfo?.relationship_count }} 个关系

- 上传文件 + 上传文件

点击或者把文件拖拽到这里上传

- 目前仅支持上传文本文件,如 .pdf, .txt, .md。且同名文件无法重复添加。 + 目前仅支持上传 jsonl 文件。且同名文件无法重复添加。

- 确定 + 获取节点
-
+
{ - state.graphloading = true +const loadGraphInfo = () => { + state.loadingGraphInfo = true fetch('/api/database/graph', { method: "GET", }) .then(response => response.json()) .then(data => { console.log(data) - graph.value = data.graph - state.graphloading = false + graphInfo.value = data.graph + state.loadingGraphInfo = false }) .catch(error => { console.error(error) message.error(error.message) - state.graphloading = false + state.loadingGraphInfo = false }) } -const graphData = computed(() => { +const getGraphData = () => { return { - nodes: subgraph.nodes.map(node => { + nodes: graphData.nodes.map(node => { return { id: node.id, data: { @@ -126,7 +127,7 @@ const graphData = computed(() => { }, } }), - edges: subgraph.edges.map(edge => { + edges: graphData.edges.map(edge => { return { source: edge.source_id, target: edge.target_id, @@ -136,7 +137,7 @@ const graphData = computed(() => { } }), } -}) +} const addDocumentByFile = () => { state.precessing = true @@ -168,13 +169,10 @@ const loadSampleNodes = () => { } }) .then((data) => { - subgraph.nodes = data.result.nodes - subgraph.edges = data.result.edges - console.log(data) - console.log(subgraph) - setTimeout(() => { - randerGraph() - }, 500) + graphData.nodes = data.result.nodes + graphData.edges = data.result.edges + console.log(graphData) + randerGraph() }) .catch((error) => { message.error(error.message); @@ -197,10 +195,10 @@ const onSearch = () => { } }) .then((data) => { - subgraph.nodes = data.result.nodes - subgraph.edges = data.result.edges + graphData.nodes = data.result.nodes + graphData.edges = data.result.edges console.log(data) - console.log(subgraph) + console.log(graphData) randerGraph() }) .catch((error) => { @@ -210,54 +208,58 @@ const onSearch = () => { }; const randerGraph = () => { - graphInstance.setData(graphData.value); + + if (graphInstance) { + graphInstance.destroy(); + } + + initGraph(); + graphInstance.setData(getGraphData()); graphInstance.render(); } +const initGraph = () => { + graphInstance = new Graph({ + container: container.value, + width: container.value.offsetWidth, + height: container.value.offsetHeight, + autoFit: true, + autoResize: true, + layout: { + type: 'd3-force', + preventOverlap: true, + kr: 20, + collide: { + strength: 1.0, + }, + }, + node: { + type: 'circle', + style: { + labelText: (d) => d.data.label, + size: 70, + }, + palette: { + field: 'label', + color: 'tableau', + }, + }, + edge: { + type: 'line', + style: { + labelText: (d) => d.data.label, + labelBackground: '#fff', + endArrow: true, + }, + }, + behaviors: ['drag-element', 'zoom-canvas', 'drag-canvas'], + }); + window.addEventListener('resize', randerGraph); +} + onMounted(() => { - loadGraph(); + loadGraphInfo(); loadSampleNodes(); - setTimeout(() => { - if (state.showPage) { - graphInstance = new Graph({ - container: container.value, - width: container.value.offsetWidth, - height: container.value.offsetHeight, - autoFit: true, - autoResize: true, - layout: { - type: 'd3-force', - preventOverlap: true, - kr: 100, - collide: { - strength: 0.5, - }, - }, - node: { - type: 'circle', - style: { - labelText: (d) => d.data.label, - size: 40, - }, - palette: { - field: 'label', - color: 'tableau', - }, - }, - edge: { - type: 'line', - style: { - labelText: (d) => d.data.label, - labelBackground: '#fff', - }, - }, - behaviors: ['drag-element', 'zoom-canvas', 'drag-canvas'], - }); - graphInstance.setData(graphData.value); - graphInstance.render(); - window.addEventListener('resize', randerGraph); - } - }, 400) }); @@ -303,7 +305,7 @@ const handleDrop = (event) => { justify-content: space-between; margin-bottom: 20px; - .actions-left { + .actions-left, .actions-right { display: flex; align-items: center; gap: 10px; @@ -311,7 +313,6 @@ const handleDrop = (event) => { input { width: 100px; - margin-right: 10px; border-radius: 8px; padding: 4px 12px; border: 2px solid var(--main-300); @@ -324,6 +325,7 @@ const handleDrop = (event) => { } button { + border-width: 2px; height: 40px; box-shadow: none; } diff --git a/web/src/views/SettingView.vue b/web/src/views/SettingView.vue index aef7d244..54b150d2 100644 --- a/web/src/views/SettingView.vue +++ b/web/src/views/SettingView.vue @@ -42,7 +42,8 @@
- {{ items?.embed_model.des }}   + + {{ items?.embed_model.des }}   需要重启 @@ -58,7 +59,8 @@
- {{ items?.reranker.des }}   + + {{ items?.reranker.des }}   需要重启 @@ -89,7 +91,11 @@ />
- {{ items?.enable_knowledge_graph.des }} + {{ items?.enable_knowledge_graph.des }} + + 需要重启 + + { .label { margin-right: 20px; + + button { + margin-left: 10px; + height: 24px; + padding: 0 8px; + font-size: smaller; + } } } From 302e59c2c961a43118884c10dae3bd0bc6c7d520 Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Mon, 9 Sep 2024 17:07:03 +0800 Subject: [PATCH 3/3] update --- .gitignore | 3 +- local_neo4j/test_neo4j.py | 6 +- src/config/__init__.py | 9 +- src/core/graphbase.py | 67 +++++++++- src/core/history.py | 3 +- src/core/knowledgebase.py | 2 +- src/core/retriever.py | 29 +++-- src/models/README.md | 6 +- src/models/__init__.py | 4 + src/models/chat_model.py | 8 ++ src/models/dify_fording.py | 0 src/models/embedding.py | 20 +++ src/plugins/oneke.py | 2 +- src/plugins/pdf2txt.py | 46 ++++++- src/utils/__init__.py | 3 +- src/utils/logging_config.py | 4 +- src/views/common_view.py | 4 +- web/public/icon-narrow.png | Bin 0 -> 11909 bytes web/public/icon.png | Bin 0 -> 7169 bytes web/src/components/ChatComponent.vue | 32 +++-- web/src/components/DebugComponent.vue | 32 +++-- web/src/layouts/AppLayout.vue | 4 +- web/src/views/DataBaseInfoView.vue | 170 +++++++++++++++++++------- web/src/views/GraphView.vue | 20 ++- 24 files changed, 374 insertions(+), 100 deletions(-) delete mode 100644 src/models/dify_fording.py create mode 100644 web/public/icon-narrow.png create mode 100644 web/public/icon.png diff --git a/.gitignore b/.gitignore index 409b49b2..f602ffa0 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,5 @@ local_neo4j/data local_neo4j/logs local_neo4j/import local_neo4j/plugins -local_neo4j/conf \ No newline at end of file +local_neo4j/conf +graphrag \ No newline at end of file diff --git a/local_neo4j/test_neo4j.py b/local_neo4j/test_neo4j.py index a293e4fc..53b967a0 100644 --- a/local_neo4j/test_neo4j.py +++ b/local_neo4j/test_neo4j.py @@ -1,15 +1,17 @@ from neo4j import GraphDatabase from neo4j.exceptions import ServiceUnavailable, AuthError +# sudo ln -s /snap/core22/1586/usr/sbin/iptables /usr/sbin/iptables + def check_neo4j_status(uri="bolt://localhost:7687", username="neo4j", password="0123456789"): """ 检查 Neo4j 数据库是否可以连接并正常工作。 - + 参数: uri (str): Neo4j 的 URI,默认为 "bolt://localhost:7687" username (str): 数据库用户名,默认为 "neo4j" password (str): 数据库密码,默认为 "0123456789" - + 返回: str: "OK" 表示连接成功,"UNAVAILABLE" 表示服务不可用,"AUTH_FAILED" 表示认证失败。 """ diff --git a/src/config/__init__.py b/src/config/__init__.py index 50ae6ce7..1828e406 100644 --- a/src/config/__init__.py +++ b/src/config/__init__.py @@ -49,7 +49,7 @@ class Config(SimpleConfig): # 模型配置 ## 注意这里是模型名,而不是具体的模型路径,默认使用 HuggingFace 的路径 ## 如果需要自定义路径,则在 config/base.yaml 中配置 model_local_paths - self.add_item("model_provider", default="zhipu", des="模型提供商", choices=["qianfan", "vllm", "zhipu", "deepseek", "dashscope"]) + self.add_item("model_provider", default="zhipu", des="模型提供商", choices=["openai", "qianfan", "vllm", "zhipu", "deepseek", "dashscope"]) self.add_item("model_name", default=None, des="模型名称") self.add_item("embed_model", default="zhipu-embedding-3", des="Embedding 模型", choices=list(EMBED_MODEL_INFO.keys())) self.add_item("reranker", default="bge-reranker-v2-m3", des="Re-Ranker 模型", choices=["bge-reranker-v2-m3"]) @@ -145,6 +145,13 @@ class Config(SimpleConfig): MODEL_NAMES = { # https://platform.deepseek.com/api-docs/zh-cn/pricing + "openai": [ + "gpt-4", + "gpt-4o", + "gpt-4-0125-preview", + "gpt-4o-mini", + ], + "deepseek": [ "deepseek-chat", "deepseek-coder" diff --git a/src/core/graphbase.py b/src/core/graphbase.py index 664b4729..f6db98af 100644 --- a/src/core/graphbase.py +++ b/src/core/graphbase.py @@ -15,6 +15,71 @@ logger = setup_logger("server-graphbase") warnings.filterwarnings("ignore", category=UserWarning) + +""" +from neo4j import GraphDatabase +import random + +class KnowledgeGraph: + def __init__(self, uri, user, password): + self._driver = GraphDatabase.driver(uri, auth=(user, password)) + + def close(self): + self._driver.close() + + def use_database(self, kgdb_name): + with self._driver.session() as session: + session.run(f"USE {kgdb_name}") + + def get_sample_nodes(self, kgdb_name='neo4j', num=50): + self.use_database(kgdb_name) + selected_nodes = set() + nodes_to_expand = set() + result_nodes = [] + + with self._driver.session() as session: + while len(selected_nodes) < num: + # 如果需要扩展的节点为空,随机选择一个新节点 + if not nodes_to_expand: + result = session.run("MATCH (n) RETURN n, rand() as r ORDER BY r LIMIT 1") + for record in result: + nodes_to_expand.add(record['n'].id) + result_nodes.append({'n': record['n'], 'r': None, 'm': None}) + + # 从需要扩展的节点中随机选择一个节点 + current_node_id = random.choice(list(nodes_to_expand)) + nodes_to_expand.remove(current_node_id) + + # 获取当前节点的邻居节点,最多5个 + result = session.run( + f"MATCH (n)-[r]-(m) WHERE id(n) = {current_node_id} RETURN n, r, m LIMIT 5" + ) + + for record in result: + neighbor_node_id = record['m'].id + if neighbor_node_id not in selected_nodes: + selected_nodes.add(neighbor_node_id) + nodes_to_expand.add(neighbor_node_id) + result_nodes.append({'n': record['n'], 'r': record['r'], 'm': record['m']}) + + # 如果已经达到最大值,停止扩展 + if len(selected_nodes) >= num: + break + + return result_nodes[:num] + +# 示例用法 +uri = "bolt://localhost:7687" +user = "neo4j" +password = "password" +kg = KnowledgeGraph(uri, user, password) +sample_nodes = kg.get_sample_nodes(num=50) +for node in sample_nodes: + print(f"Node: {node['n'].id}, Relationship: {node['r']}, Neighbor: {node['m'].id}") +kg.close() + +""" + UIE_MODEL = None class GraphDatabase: @@ -38,7 +103,7 @@ class GraphDatabase: self.driver.close() def get_sample_nodes(self, kgdb_name='neo4j', num=50): - """获取指定数据库的前 num 个节点信息""" + """获取指定数据库的 num 个节点信息""" self.use_database(kgdb_name) def query(tx, num): result = tx.run("MATCH (n)-[r]->(m) RETURN n, r, m LIMIT $num", num=int(num)) diff --git a/src/core/history.py b/src/core/history.py index 98faa01c..dae392e6 100644 --- a/src/core/history.py +++ b/src/core/history.py @@ -38,5 +38,6 @@ class HistoryManager(): def __str__(self): history_str = "" for message in self.messages: - history_str += f"{message['role']}: {message['content']}" + msg = message["content"].replace('\n', ' ') + history_str += f"\n{message['role']}: {msg}" return history_str \ No newline at end of file diff --git a/src/core/knowledgebase.py b/src/core/knowledgebase.py index 327384f0..873f925a 100644 --- a/src/core/knowledgebase.py +++ b/src/core/knowledgebase.py @@ -63,7 +63,7 @@ class KnowledgeBase: "id": int(random.random() * 1e12), "vector": vectors[i], "text": docs[i], - "hash": hashstr(docs[i] + str(random.random())), + "hash": hashstr(docs[i], with_salt=True), **kwargs} for i in range(len(vectors))] res = self.client.insert(collection_name=collection_name, data=data) diff --git a/src/core/retriever.py b/src/core/retriever.py index bafa7f84..d2bcd128 100644 --- a/src/core/retriever.py +++ b/src/core/retriever.py @@ -84,28 +84,33 @@ class Retriever: db_name = refs["meta"]["db_name"] kb = self.dbm.metaname2db[db_name] - limit = refs["meta"].get("queryCount", 10) - kb_res = self.dbm.knowledge_base.search(rw_query, db_name, limit=limit) - for r in kb_res: + max_query_count = refs["meta"].get("maxQueryCount", 10) + rerank_threshold = refs["meta"].get("rerankThreshold", 0.1) + distance_threshold = refs["meta"].get("distanceThreshold", 0) + top_k = refs["meta"].get("topK", 5) + + all_kb_res = self.dbm.knowledge_base.search(rw_query, db_name, limit=max_query_count) + for r in all_kb_res: r["file"] = kb.id2file(r["entity"]["file_id"]) + # use distance threshold to filter results + kb_res = [r for r in all_kb_res if r["distance"] > distance_threshold] + if self.config.enable_reranker: - RERANK_THRESHOLD = 0.001 for r in kb_res: r["rerank_score"] = self.reranker.compute_score([rw_query, r["entity"]["text"]], normalize=True) kb_res.sort(key=lambda x: x["rerank_score"], reverse=True) - final_res = [_res for _res in kb_res if _res["rerank_score"] > RERANK_THRESHOLD] + kb_res = [_res for _res in kb_res if _res["rerank_score"] > rerank_threshold] - else: - final_res = kb_res[:5] + kb_res = kb_res[:top_k] - return {"results": final_res, "all_results": kb_res, "rw_query": rw_query} + return {"results": kb_res, "all_results": all_kb_res, "rw_query": rw_query} def rewrite_query(self, query, history, refs): """重写查询""" - rewrite_query_span = refs["meta"].get("rewrite_query", None) - if rewrite_query_span is None or rewrite_query_span == "OFF": + rewrite_query_span = refs["meta"].get("rewriteQuery", "off") + if rewrite_query_span == "off": rewritten_query = query else: from src.utils.prompts import rewritten_query_prompt_template @@ -114,7 +119,7 @@ class Retriever: rewritten_query_prompt = rewritten_query_prompt_template.format(history=history_query, query=query) rewritten_query = self.model.predict(rewritten_query_prompt).content - if rewrite_query_span == "HyDE": + if rewrite_query_span == "hyde": hy_doc = self.model.predict(rewritten_query).content rewritten_query = f"{rewritten_query} {hy_doc}" @@ -168,7 +173,6 @@ class Retriever: return node_info, edge_info def format_general_results(self, results): - logger.debug(f"Formatting general results: {results}") formatted_results = {"nodes": [], "edges": []} for item in results: @@ -189,7 +193,6 @@ class Retriever: return formatted_results def format_query_results(self, results): - logger.debug(f"Formatting query results: {results}") formatted_results = {"nodes": [], "edges": []} node_dict = {} diff --git a/src/models/README.md b/src/models/README.md index 433e24ce..fe1fc347 100644 --- a/src/models/README.md +++ b/src/models/README.md @@ -38,7 +38,7 @@ python -m vllm.entrypoints.openai.api_server \ |`bge-large-zh-v1.5`|`BAAI/bge-large-zh-v1.5`|`bge-large-zh-v1.5`(*修改为本地路径)| |`zhipu`|`embedding-2`|`ZHIPUAPI` (`.env`)| -### 3. 重排序模型支持 + 例如(`config/base.yaml`): @@ -52,3 +52,7 @@ model_name: null # for default model_local_paths: bge-large-zh-v1.5: /models/bge-large-zh-v1.5 ``` + +### 3. 重排序模型支持 + +目前仅支持 `BAAI/bge-reranker-v2-m3`。 diff --git a/src/models/__init__.py b/src/models/__init__.py index 857e0b80..8e1f6592 100644 --- a/src/models/__init__.py +++ b/src/models/__init__.py @@ -28,6 +28,10 @@ def select_model(config): from src.models.chat_model import DashScope return DashScope(model_name) + elif model_provider == "openai": + from src.models.chat_model import OpenModel + return OpenModel(model_name) + elif model_provider is None: raise ValueError("Model provider not specified, please modify `model_provider` in `src/config/base.yaml`") else: diff --git a/src/models/chat_model.py b/src/models/chat_model.py index be612215..2ed63c18 100644 --- a/src/models/chat_model.py +++ b/src/models/chat_model.py @@ -39,6 +39,14 @@ class OpenAIBase(): return response.choices[0].message +class OpenModel(OpenAIBase): + def __init__(self, model_name=None): + model_name = model_name or "gpt-4o-mini" + api_key = os.getenv("OPENAI_API_KEY") + base_url = os.getenv("OPENAI_API_BASE") + super().__init__(api_key=api_key, base_url=base_url, model_name=model_name) + + class DeepSeek(OpenAIBase): def __init__(self, model_name=None): model_name = model_name or "deepseek-chat" diff --git a/src/models/dify_fording.py b/src/models/dify_fording.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/models/embedding.py b/src/models/embedding.py index 9eb81ee2..21d32a03 100644 --- a/src/models/embedding.py +++ b/src/models/embedding.py @@ -1,12 +1,16 @@ import os +import uuid from FlagEmbedding import FlagModel, FlagReranker from src.config import EMBED_MODEL_INFO, RERANKER_LIST from src.utils.logging_config import setup_logger +from src.utils import hashstr logger = setup_logger("EmbeddingModel") +GLOBAL_EMBED_STATE = {} + class EmbeddingModel(FlagModel): def __init__(self, model_info, config, **kwargs): @@ -47,9 +51,21 @@ class ZhipuEmbedding: def predict(self, message): data = [] + if len(message) > 10: + global GLOBAL_EMBED_STATE + task_id = hashstr(message) + logger.info(f"Creating new state for process {task_id}") + GLOBAL_EMBED_STATE[task_id] = { + 'status': 'in-progress', + 'total': len(message), + 'progress': 0 + } + for i in range(0, len(message), 10): if len(message) > 10: logger.info(f"Encoding {i} to {i+10} with {len(message)} messages") + GLOBAL_EMBED_STATE[task_id]['progress'] = i + group_msg = message[i:i+10] response = self.client.embeddings.create( model=self.model_info.default_path, @@ -58,6 +74,10 @@ class ZhipuEmbedding: data.extend([a.embedding for a in response.data]) + if len(message) > 10: + GLOBAL_EMBED_STATE[task_id]['progress'] = len(message) + GLOBAL_EMBED_STATE[task_id]['status'] = 'completed' + return data def encode(self, message): diff --git a/src/plugins/oneke.py b/src/plugins/oneke.py index 9f150d26..aea4c5c8 100644 --- a/src/plugins/oneke.py +++ b/src/plugins/oneke.py @@ -16,7 +16,7 @@ logger = setup_logger("OneKE") dotenv.load_dotenv() -MODEL_NAME_OR_PATH = os.path.join(os.getenv('MODEL_ROOT_DIR'), 'OneKE') +MODEL_NAME_OR_PATH = os.path.join(os.getenv('MODEL_ROOT_DIR', './'), 'OneKE') instruction_mapper = { 'NERzh': "你是专门进行实体抽取的专家。请从input中抽取出符合schema定义的实体,不存在的实体类型返回空列表。请按照JSON字符串的格式回答。", diff --git a/src/plugins/pdf2txt.py b/src/plugins/pdf2txt.py index a0e4f255..ae64ea39 100644 --- a/src/plugins/pdf2txt.py +++ b/src/plugins/pdf2txt.py @@ -1,25 +1,53 @@ import os +import uuid import fitz # fitz就是pip install PyMuPDF import cv2 from copy import deepcopy from tqdm import tqdm +from argparse import ArgumentParser +from src.utils import logger + +GOLBAL_STATE = {} def pdf2txt(pdf_path, return_text=False): + if not os.path.exists(pdf_path): + raise FileNotFoundError(f"File not found: {pdf_path}") + + # Importing these modules here to avoid unnecessary imports in other files from paddleocr import PPStructure, save_structure_res from paddleocr.ppstructure.recovery.recovery_to_doc import sorted_layout_boxes, convert_info_docx - output_dir = os.path.join('tmp', 'pdf2txt', os.path.basename(pdf_path).split('.')[0]) + filename = os.path.basename(pdf_path).split('.')[0] + output_dir = os.path.join('saves', 'data', 'pdf2txt', filename) os.makedirs(output_dir, exist_ok=True) table_engine = PPStructure(recovery=True, lang='ch') imgs = convert_imgs(pdf_path, output_dir) - respath = os.path.join(output_dir, 'res.txt') + respath = os.path.join(output_dir, f'{filename}.txt') + + global GOLBAL_STATE + task_id = str(uuid.uuid4()) + if task_id in GOLBAL_STATE: + logger.info(f"Reusing previous state for process {task_id}") + return GOLBAL_STATE[task_id] + else: + logger.info(f"Creating new state for process {task_id}") + GOLBAL_STATE[task_id] = { + 'pdf_path': pdf_path, + 'return_text': return_text, + 'output_dir': output_dir, + 'status': 'in-progress', + 'total': len(imgs), + 'progress': 0 + } + text = [] for img_name in tqdm(imgs, desc='to txt', ncols=100): img = cv2.imread(img_name) result = table_engine(img) + GOLBAL_STATE[task_id]['progress'] += 1 save_structure_res(result, output_dir, "structure_result") @@ -43,6 +71,9 @@ def pdf2txt(pdf_path, return_text=False): whole_text = ''.join(text) with open(respath, 'w', encoding='utf-8') as f: f.write(whole_text) + logger.info(f"Extracted text saved to {respath}") + + GOLBAL_STATE[task_id]['status'] = 'completed' if return_text: return whole_text @@ -72,6 +103,13 @@ def convert_imgs(pdf_path, output_dir): return imgs +def get_state(task_id): + return GOLBAL_STATE.get(task_id, {}) + if __name__ == "__main__": - pdf_path = r'saves/data/uploads/2e04d5_保健食品.pdf' - print(pdf2txt(pdf_path)) + parser = ArgumentParser() + parser.add_argument('--pdf-path', type=str, required=True, help='Path to the PDF file') + parser.add_argument('--return-text', action='store_true', help='Return the extracted text') + args = parser.parse_args() + + pdf2txt(args.pdf_path, args.return_text) diff --git a/src/utils/__init__.py b/src/utils/__init__.py index 1df9e7e6..5b613e7a 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -1,4 +1,5 @@ import time +import random from src.utils.logging_config import setup_logger, logger def is_text_pdf(pdf_path): @@ -15,7 +16,7 @@ def hashstr(input_string, length=8, with_salt=False): import hashlib # 添加时间戳作为干扰 if with_salt: - input_string += str(time.time()) + input_string += str(time.time() + random.random()) hash = hashlib.md5(str(input_string).encode()).hexdigest() return hash[:length] \ No newline at end of file diff --git a/src/utils/logging_config.py b/src/utils/logging_config.py index 2eb64b50..67a4d1f9 100644 --- a/src/utils/logging_config.py +++ b/src/utils/logging_config.py @@ -5,10 +5,10 @@ from datetime import datetime DATETIME = datetime.now().strftime('%Y-%m-%d-%H%M%S') # DATETIME = "debug" # 为了方便,调试的时候输出到 debug.log 文件 -LOG_FILE = f'log/project-{DATETIME}.log' +LOG_FILE = f'saves/log/project-{DATETIME}.log' def setup_logger(name, level=logging.DEBUG, console=False): - os.makedirs("log", exist_ok=True) + os.makedirs("saves/log", exist_ok=True) """Function to setup logger with the given name and log file.""" logger = logging.getLogger(name) diff --git a/src/views/common_view.py b/src/views/common_view.py index 3eec200f..730ee785 100644 --- a/src/views/common_view.py +++ b/src/views/common_view.py @@ -1,3 +1,4 @@ +import itertools import json from flask import Blueprint, jsonify, request, Response @@ -89,6 +90,7 @@ def restart(): def get_log(): from src.utils.logging_config import LOG_FILE with open(LOG_FILE, 'r') as f: - log = f.read() + log_lines = itertools.islice(f, 1000) + log = ''.join(log_lines) return jsonify({"log": log}) \ No newline at end of file diff --git a/web/public/icon-narrow.png b/web/public/icon-narrow.png new file mode 100644 index 0000000000000000000000000000000000000000..5fa011b2e275cbe481841411e4c144ec884abcaf GIT binary patch literal 11909 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 F{{^cb%bNfI literal 0 HcmV?d00001 diff --git a/web/public/icon.png b/web/public/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..8b200a98eecdc3a9e1ee5dba400b9d856e4364f6 GIT binary patch literal 7169 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

ikJyN%bU!q4^`03L0~=R8bQi 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