2026-07-24 18:00:43 +08:00
|
|
|
|
#
|
|
|
|
|
|
# Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
|
|
|
|
|
#
|
|
|
|
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
|
|
# you may not use this file except in compliance with the License.
|
|
|
|
|
|
# You may obtain a copy of the License at
|
|
|
|
|
|
#
|
|
|
|
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
|
|
#
|
|
|
|
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
|
|
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
|
|
# See the License for the specific language governing permissions and
|
|
|
|
|
|
# limitations under the License.
|
|
|
|
|
|
#
|
|
|
|
|
|
|
|
|
|
|
|
"""Shared structure-graph subgraph sampling.
|
|
|
|
|
|
|
|
|
|
|
|
Both the per-document (``/datasets/<id>/documents/<doc>/structure/graph``) and
|
2026-07-31 17:05:57 +08:00
|
|
|
|
the dataset-wide (``/datasets/<id>/artifacts/structure``) endpoints render
|
2026-07-24 18:00:43 +08:00
|
|
|
|
per-template structure graphs. For large graphs we don't return every
|
|
|
|
|
|
entity/relation — we fetch a representative subgraph from the raw
|
|
|
|
|
|
``knowledge_graph_kwd`` rows (which carry ``mention_count_int`` / ``name_kwd`` /
|
|
|
|
|
|
``from_entity_kwd`` / ``to_entity_kwd`` / ``q_<dim>_vec``) so the response — and
|
|
|
|
|
|
the frontend render — stay bounded.
|
|
|
|
|
|
|
|
|
|
|
|
The two endpoints differ only in *scope*: the document endpoint filters raw
|
|
|
|
|
|
rows by ``doc_id``; the dataset endpoint queries KB-wide (dataset-merge
|
|
|
|
|
|
templates dedup entity/relation rows across documents). That difference lives
|
|
|
|
|
|
entirely in the ``scope`` / ``base_entity_condition`` dicts the caller passes —
|
|
|
|
|
|
everything else is shared here.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
|
import logging
|
2026-08-11 13:58:00 +08:00
|
|
|
|
import re
|
2026-07-24 18:00:43 +08:00
|
|
|
|
|
|
|
|
|
|
from common import settings
|
|
|
|
|
|
from common.doc_store.doc_store_base import OrderByExpr
|
|
|
|
|
|
from common.misc_utils import thread_pool_exec
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Below this combined (entities + relations) count for a bucket, return all rows.
|
|
|
|
|
|
GRAPH_FULL_THRESHOLD = 1024
|
|
|
|
|
|
# Size of the top-mention entity seed set (set A) for large buckets.
|
|
|
|
|
|
GRAPH_TOP_ENTITIES = 256
|
2026-08-11 13:58:00 +08:00
|
|
|
|
# Keyword search needs a small, relevant candidate set; the larger sampling
|
|
|
|
|
|
# cap above is intended for rendering an entire large graph bucket.
|
|
|
|
|
|
GRAPH_KEYWORD_CANDIDATES = 16
|
2026-07-24 18:00:43 +08:00
|
|
|
|
# Upper bound on the relation / neighbor-entity expansion so a hub node can't
|
|
|
|
|
|
# blow up the response.
|
|
|
|
|
|
GRAPH_EXPANSION_CAP = 4096
|
|
|
|
|
|
|
2026-08-10 13:52:49 +08:00
|
|
|
|
GRAPH_ENTITY_FIELDS = ["id", "content_with_weight", "name_kwd", "mention_count_int", "source_chunk_ids", "doc_id", "doc_ids_kwd", "source_doc_ids"]
|
|
|
|
|
|
GRAPH_RELATION_FIELDS = ["id", "content_with_weight", "from_entity_kwd", "to_entity_kwd", "doc_id", "doc_ids_kwd", "source_doc_ids"]
|
|
|
|
|
|
GRAPH_ALL_FIELDS = [
|
|
|
|
|
|
"id",
|
|
|
|
|
|
"content_with_weight",
|
|
|
|
|
|
"name_kwd",
|
|
|
|
|
|
"mention_count_int",
|
|
|
|
|
|
"source_chunk_ids",
|
|
|
|
|
|
"from_entity_kwd",
|
|
|
|
|
|
"to_entity_kwd",
|
|
|
|
|
|
"knowledge_graph_kwd",
|
|
|
|
|
|
"doc_id",
|
|
|
|
|
|
"doc_ids_kwd",
|
|
|
|
|
|
"source_doc_ids",
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def graph_search(index_name, kb_id, select_fields, condition, order_by, limit, match_expressions=None, offset=0):
|
2026-07-24 18:00:43 +08:00
|
|
|
|
"""One raw-row search. Returns ``(field_map, total)`` where ``total`` is the
|
|
|
|
|
|
full match count (not the returned slice)."""
|
|
|
|
|
|
res = await thread_pool_exec(
|
|
|
|
|
|
settings.docStoreConn.search,
|
|
|
|
|
|
select_fields,
|
|
|
|
|
|
[],
|
|
|
|
|
|
condition,
|
|
|
|
|
|
match_expressions or [],
|
|
|
|
|
|
order_by,
|
2026-08-10 13:52:49 +08:00
|
|
|
|
offset,
|
2026-07-24 18:00:43 +08:00
|
|
|
|
max(int(limit or 0), 1),
|
|
|
|
|
|
index_name,
|
|
|
|
|
|
[kb_id],
|
|
|
|
|
|
)
|
|
|
|
|
|
field_map = settings.docStoreConn.get_fields(res, select_fields) or {}
|
|
|
|
|
|
total = settings.docStoreConn.get_total(res)
|
|
|
|
|
|
return field_map, int(total or 0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def project_entity(row: dict) -> dict | None:
|
|
|
|
|
|
"""Project a raw ``knowledge_graph_kwd="entity"`` row to the graph-node shape
|
|
|
|
|
|
the frontend already consumes, surfacing ``mention_count_int`` as
|
|
|
|
|
|
``mention_count``."""
|
|
|
|
|
|
from rag.advanced_rag.knowlege_compile.structure import _struct_graph_entity
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
payload = json.loads(row.get("content_with_weight") or "{}")
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return None
|
|
|
|
|
|
if not isinstance(payload, dict):
|
|
|
|
|
|
return None
|
|
|
|
|
|
node = _struct_graph_entity(payload, row.get("source_chunk_ids"))
|
|
|
|
|
|
if not node:
|
|
|
|
|
|
return None
|
|
|
|
|
|
mc = row.get("mention_count_int")
|
|
|
|
|
|
if isinstance(mc, list): # Infinity returns *_int scalars fine, but be defensive
|
|
|
|
|
|
mc = mc[0] if mc else None
|
|
|
|
|
|
try:
|
|
|
|
|
|
if mc is not None:
|
|
|
|
|
|
node["mention_count"] = int(mc)
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
pass
|
|
|
|
|
|
return node
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def project_relation(row: dict) -> dict | None:
|
|
|
|
|
|
"""Project a raw ``knowledge_graph_kwd="relation"`` row to the edge shape.
|
|
|
|
|
|
Prefers the payload (matching the blob projection); falls back to the
|
|
|
|
|
|
authoritative ``*_entity_kwd`` columns."""
|
|
|
|
|
|
from rag.advanced_rag.knowlege_compile.structure import _struct_graph_relation
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
payload = json.loads(row.get("content_with_weight") or "{}")
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
payload = {}
|
|
|
|
|
|
if isinstance(payload, dict):
|
|
|
|
|
|
node = _struct_graph_relation(payload)
|
|
|
|
|
|
if node:
|
|
|
|
|
|
return node
|
|
|
|
|
|
src = str(row.get("from_entity_kwd") or "").strip()
|
|
|
|
|
|
tgt = str(row.get("to_entity_kwd") or "").strip()
|
|
|
|
|
|
if not src or not tgt:
|
|
|
|
|
|
return None
|
|
|
|
|
|
typ = payload.get("type") if isinstance(payload, dict) else None
|
|
|
|
|
|
return {"from": src, "to": tgt, "type": str(typ).strip() if typ else "related"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def dedup_entities(entities: list[dict]) -> list[dict]:
|
|
|
|
|
|
"""Order-preserving dedup by (lowercased name, type)."""
|
|
|
|
|
|
out: list[dict] = []
|
|
|
|
|
|
seen: set[tuple[str, str]] = set()
|
|
|
|
|
|
for e in entities:
|
|
|
|
|
|
key = (str(e.get("name") or "").strip().lower(), str(e.get("type") or "").strip().lower())
|
|
|
|
|
|
if not key[0] or key in seen:
|
|
|
|
|
|
continue
|
|
|
|
|
|
seen.add(key)
|
|
|
|
|
|
out.append(e)
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 18:23:51 +08:00
|
|
|
|
def _entity_response_id(entity: dict) -> str:
|
|
|
|
|
|
for field in ("id", "name", "slug"):
|
|
|
|
|
|
value = entity.get(field)
|
|
|
|
|
|
if isinstance(value, str) and value.strip():
|
|
|
|
|
|
return value.strip()
|
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _endpoint_terms(value: str) -> list[str]:
|
|
|
|
|
|
value = value.strip()
|
|
|
|
|
|
if not value:
|
|
|
|
|
|
return []
|
|
|
|
|
|
return sorted({value, value.lower()})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def normalize_relation_endpoints(entities: list[dict], relations: list[dict]) -> list[dict]:
|
|
|
|
|
|
"""Align relation endpoints to the returned entity ids/names."""
|
|
|
|
|
|
if not entities or not relations:
|
|
|
|
|
|
return relations
|
|
|
|
|
|
|
|
|
|
|
|
lookup: dict[str, str] = {}
|
|
|
|
|
|
ambiguous: set[str] = set()
|
|
|
|
|
|
for entity in entities:
|
|
|
|
|
|
response_id = _entity_response_id(entity)
|
|
|
|
|
|
if not response_id:
|
|
|
|
|
|
continue
|
|
|
|
|
|
for field in ("id", "name", "slug"):
|
|
|
|
|
|
value = entity.get(field)
|
|
|
|
|
|
if not isinstance(value, str) or not value.strip():
|
|
|
|
|
|
continue
|
|
|
|
|
|
key = value.strip().lower()
|
|
|
|
|
|
if key in lookup and lookup[key] != response_id:
|
|
|
|
|
|
ambiguous.add(key)
|
|
|
|
|
|
continue
|
|
|
|
|
|
lookup[key] = response_id
|
|
|
|
|
|
for key in ambiguous:
|
|
|
|
|
|
lookup.pop(key, None)
|
|
|
|
|
|
|
|
|
|
|
|
normalized: list[dict] = []
|
|
|
|
|
|
for relation in relations:
|
|
|
|
|
|
if not isinstance(relation, dict):
|
|
|
|
|
|
continue
|
|
|
|
|
|
item = dict(relation)
|
|
|
|
|
|
for field in ("from", "to"):
|
|
|
|
|
|
value = item.get(field)
|
|
|
|
|
|
if isinstance(value, str):
|
|
|
|
|
|
item[field] = lookup.get(value.strip().lower(), value)
|
|
|
|
|
|
normalized.append(item)
|
|
|
|
|
|
return normalized
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-24 18:00:43 +08:00
|
|
|
|
def filter_entities_with_relations(entities: list[dict], relations: list[dict]) -> list[dict]:
|
|
|
|
|
|
"""Keep only entities that are referenced by at least one relation."""
|
|
|
|
|
|
if not entities or not relations:
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
2026-07-29 18:23:51 +08:00
|
|
|
|
# Match case-insensitively: the dataset-scoped merge lowercases relation
|
|
|
|
|
|
# endpoints while entity names keep their original case, so exact matching
|
|
|
|
|
|
# would drop connected nodes from graph-like views.
|
2026-07-24 18:00:43 +08:00
|
|
|
|
connected: set[str] = set()
|
|
|
|
|
|
for relation in relations:
|
|
|
|
|
|
if not isinstance(relation, dict):
|
|
|
|
|
|
continue
|
|
|
|
|
|
for endpoint_key in ("from", "to"):
|
|
|
|
|
|
endpoint = relation.get(endpoint_key)
|
|
|
|
|
|
if isinstance(endpoint, str):
|
2026-07-29 18:23:51 +08:00
|
|
|
|
endpoint = endpoint.strip().lower()
|
2026-07-24 18:00:43 +08:00
|
|
|
|
if endpoint:
|
|
|
|
|
|
connected.add(endpoint)
|
|
|
|
|
|
|
|
|
|
|
|
if not connected:
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
filtered: list[dict] = []
|
|
|
|
|
|
for entity in entities:
|
|
|
|
|
|
if not isinstance(entity, dict):
|
|
|
|
|
|
continue
|
|
|
|
|
|
keys: set[str] = set()
|
|
|
|
|
|
# Structure-graph nodes are name-keyed and their relations reference
|
|
|
|
|
|
# names; artifact-graph nodes are slug-keyed and their relations
|
|
|
|
|
|
# reference slugs. Check all three identity fields so the same filter
|
|
|
|
|
|
# serves both callers.
|
|
|
|
|
|
for field in ("id", "name", "slug"):
|
|
|
|
|
|
value = entity.get(field)
|
|
|
|
|
|
if isinstance(value, str):
|
2026-07-29 18:23:51 +08:00
|
|
|
|
value = value.strip().lower()
|
2026-07-24 18:00:43 +08:00
|
|
|
|
if value:
|
|
|
|
|
|
keys.add(value)
|
|
|
|
|
|
if keys & connected:
|
|
|
|
|
|
filtered.append(entity)
|
|
|
|
|
|
return filtered
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-10 13:52:49 +08:00
|
|
|
|
def _row_has_enabled_source(row: dict, excluded_doc_ids: set[str]) -> bool:
|
|
|
|
|
|
if not excluded_doc_ids:
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
def _flatten_ids(value) -> set[str]:
|
|
|
|
|
|
if value is None:
|
|
|
|
|
|
return set()
|
|
|
|
|
|
if isinstance(value, str):
|
|
|
|
|
|
raw = value.strip()
|
|
|
|
|
|
if not raw:
|
|
|
|
|
|
return set()
|
|
|
|
|
|
try:
|
|
|
|
|
|
return _flatten_ids(json.loads(raw))
|
|
|
|
|
|
except (json.JSONDecodeError, TypeError):
|
|
|
|
|
|
return {raw}
|
|
|
|
|
|
if isinstance(value, (list, tuple, set)):
|
|
|
|
|
|
result: set[str] = set()
|
|
|
|
|
|
for item in value:
|
|
|
|
|
|
result.update(_flatten_ids(item))
|
|
|
|
|
|
return result
|
|
|
|
|
|
return {str(value)}
|
|
|
|
|
|
|
|
|
|
|
|
source_ids: set[str] = set()
|
|
|
|
|
|
for field in ("doc_ids_kwd", "source_doc_ids"):
|
|
|
|
|
|
source_ids.update(_flatten_ids(row.get(field)))
|
|
|
|
|
|
if source_ids:
|
|
|
|
|
|
return bool(source_ids - excluded_doc_ids)
|
|
|
|
|
|
doc_ids = _flatten_ids(row.get("doc_id"))
|
|
|
|
|
|
return not doc_ids or bool(doc_ids - excluded_doc_ids)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def build_bucket(index_name, kb_id, scope: dict, excluded_doc_ids: set[str] | None = None) -> tuple[list[dict], list[dict]]:
|
2026-07-24 18:00:43 +08:00
|
|
|
|
"""Build one bucket's ``(entities, relations)`` from raw rows.
|
|
|
|
|
|
|
|
|
|
|
|
``scope`` is the filter WITHOUT ``knowledge_graph_kwd`` — e.g.
|
|
|
|
|
|
``{"doc_id":[id], "compilation_template_ids":[tid]}`` (document scope) or
|
|
|
|
|
|
``{"compilation_template_ids":[tid]}`` (dataset scope). Small buckets are
|
|
|
|
|
|
returned whole; large ones are sampled: top-``GRAPH_TOP_ENTITIES`` entities
|
|
|
|
|
|
by ``mention_count_int``, the relations sourced from them, and those
|
|
|
|
|
|
relations' target entities.
|
|
|
|
|
|
"""
|
2026-08-10 13:52:49 +08:00
|
|
|
|
excluded_doc_ids = excluded_doc_ids or set()
|
2026-07-24 18:00:43 +08:00
|
|
|
|
both_cond = dict(scope, knowledge_graph_kwd=["entity", "relation"])
|
|
|
|
|
|
_, total = await graph_search(index_name, kb_id, ["id"], both_cond, OrderByExpr(), 1)
|
|
|
|
|
|
|
|
|
|
|
|
if total < GRAPH_FULL_THRESHOLD:
|
|
|
|
|
|
field_map, _ = await graph_search(index_name, kb_id, GRAPH_ALL_FIELDS, both_cond, OrderByExpr(), total or 1)
|
|
|
|
|
|
entities: list[dict] = []
|
|
|
|
|
|
relations: list[dict] = []
|
|
|
|
|
|
for row in field_map.values():
|
2026-08-10 13:52:49 +08:00
|
|
|
|
if not _row_has_enabled_source(row, excluded_doc_ids):
|
|
|
|
|
|
continue
|
2026-07-24 18:00:43 +08:00
|
|
|
|
if row.get("knowledge_graph_kwd") == "relation":
|
|
|
|
|
|
edge = project_relation(row)
|
|
|
|
|
|
if edge:
|
|
|
|
|
|
relations.append(edge)
|
|
|
|
|
|
else:
|
|
|
|
|
|
node = project_entity(row)
|
|
|
|
|
|
if node:
|
|
|
|
|
|
entities.append(node)
|
2026-07-29 18:23:51 +08:00
|
|
|
|
entities = dedup_entities(entities)
|
|
|
|
|
|
return entities, normalize_relation_endpoints(entities, relations)
|
2026-07-24 18:00:43 +08:00
|
|
|
|
|
|
|
|
|
|
# Large bucket: sample. A = top entities by mention_count_int desc.
|
|
|
|
|
|
order_by = OrderByExpr()
|
|
|
|
|
|
try:
|
|
|
|
|
|
order_by.desc("mention_count_int")
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
order_by = OrderByExpr()
|
2026-08-10 13:52:49 +08:00
|
|
|
|
set_a: list[dict] = []
|
|
|
|
|
|
entity_offset = 0
|
|
|
|
|
|
entity_total = None
|
|
|
|
|
|
while len(set_a) < GRAPH_TOP_ENTITIES and (entity_total is None or entity_offset < entity_total):
|
|
|
|
|
|
ent_a_map, entity_total = await graph_search(
|
|
|
|
|
|
index_name,
|
|
|
|
|
|
kb_id,
|
|
|
|
|
|
GRAPH_ENTITY_FIELDS,
|
|
|
|
|
|
dict(scope, knowledge_graph_kwd=["entity"]),
|
|
|
|
|
|
order_by,
|
|
|
|
|
|
GRAPH_TOP_ENTITIES,
|
|
|
|
|
|
offset=entity_offset,
|
|
|
|
|
|
)
|
|
|
|
|
|
if not ent_a_map:
|
|
|
|
|
|
break
|
|
|
|
|
|
set_a.extend(n for n in (project_entity(r) for r in ent_a_map.values() if _row_has_enabled_source(r, excluded_doc_ids)) if n)
|
|
|
|
|
|
entity_offset += len(ent_a_map)
|
|
|
|
|
|
set_a = set_a[:GRAPH_TOP_ENTITIES]
|
2026-07-24 18:00:43 +08:00
|
|
|
|
a_names = sorted({str(e.get("name") or "").strip() for e in set_a if str(e.get("name") or "").strip()})
|
2026-07-29 18:23:51 +08:00
|
|
|
|
a_name_terms = sorted({term for name in a_names for term in _endpoint_terms(name)})
|
2026-07-24 18:00:43 +08:00
|
|
|
|
|
|
|
|
|
|
# relations whose source is one of A.
|
|
|
|
|
|
relations = []
|
|
|
|
|
|
target_names_lower: set[str] = set()
|
2026-07-29 18:23:51 +08:00
|
|
|
|
if a_name_terms:
|
|
|
|
|
|
rel_map, _ = await graph_search(index_name, kb_id, GRAPH_RELATION_FIELDS, dict(scope, knowledge_graph_kwd=["relation"], from_entity_kwd=a_name_terms), OrderByExpr(), GRAPH_EXPANSION_CAP)
|
2026-07-24 18:00:43 +08:00
|
|
|
|
for row in rel_map.values():
|
2026-08-10 13:52:49 +08:00
|
|
|
|
if not _row_has_enabled_source(row, excluded_doc_ids):
|
|
|
|
|
|
continue
|
2026-07-24 18:00:43 +08:00
|
|
|
|
edge = project_relation(row)
|
|
|
|
|
|
if edge:
|
|
|
|
|
|
relations.append(edge)
|
|
|
|
|
|
tgt = str(edge.get("to") or "").strip().lower()
|
|
|
|
|
|
if tgt:
|
|
|
|
|
|
target_names_lower.add(tgt)
|
|
|
|
|
|
|
|
|
|
|
|
# target entities of those relations (case-insensitive via name_kwd).
|
|
|
|
|
|
set_t = []
|
|
|
|
|
|
if target_names_lower:
|
|
|
|
|
|
tgt_map, _ = await graph_search(index_name, kb_id, GRAPH_ENTITY_FIELDS, dict(scope, knowledge_graph_kwd=["entity"], name_kwd=sorted(target_names_lower)), OrderByExpr(), GRAPH_EXPANSION_CAP)
|
2026-08-10 13:52:49 +08:00
|
|
|
|
set_t = [n for n in (project_entity(r) for r in tgt_map.values() if _row_has_enabled_source(r, excluded_doc_ids)) if n]
|
2026-07-24 18:00:43 +08:00
|
|
|
|
|
2026-07-29 18:23:51 +08:00
|
|
|
|
entities = dedup_entities(set_a + set_t)
|
|
|
|
|
|
return entities, normalize_relation_endpoints(entities, relations)
|
2026-07-24 18:00:43 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-08-10 13:52:49 +08:00
|
|
|
|
async def keyword_subgraph(
|
|
|
|
|
|
index_name,
|
|
|
|
|
|
kb_id,
|
|
|
|
|
|
embd_mdl,
|
|
|
|
|
|
base_entity_condition,
|
|
|
|
|
|
keywords,
|
|
|
|
|
|
scope_for_template,
|
|
|
|
|
|
log_ctx="",
|
|
|
|
|
|
excluded_doc_ids: set[str] | None = None,
|
|
|
|
|
|
) -> tuple[dict | None, list[dict], list[dict]]:
|
2026-08-11 13:58:00 +08:00
|
|
|
|
"""Find matching entity rows and return their focused subgraph.
|
|
|
|
|
|
|
|
|
|
|
|
BM25 provides lexical candidates which are then filtered by entity-name
|
|
|
|
|
|
containment. KNN is used as a semantic fallback when lexical search has
|
|
|
|
|
|
no valid candidates. Matching entities and their touching neighbors are
|
|
|
|
|
|
returned; ``tree`` and ``page_index`` buckets additionally include the
|
|
|
|
|
|
full ancestor path to the root. ``(None, [], [])`` is returned when
|
|
|
|
|
|
nothing matches or embedding is unavailable.
|
2026-07-24 18:00:43 +08:00
|
|
|
|
|
|
|
|
|
|
``base_entity_condition`` scopes the KNN (e.g. ``{"doc_id":[id],
|
|
|
|
|
|
"knowledge_graph_kwd":["entity"]}`` or ``{"compilation_template_ids":[...],
|
|
|
|
|
|
"knowledge_graph_kwd":["entity"]}``). ``scope_for_template(row)`` resolves
|
|
|
|
|
|
``(bucket_meta, scope_filter)`` for the matched row (scope WITHOUT
|
|
|
|
|
|
``knowledge_graph_kwd``).
|
|
|
|
|
|
"""
|
2026-08-11 13:58:00 +08:00
|
|
|
|
from common.doc_store.doc_store_base import MatchDenseExpr, MatchTextExpr
|
2026-07-24 18:00:43 +08:00
|
|
|
|
|
2026-08-10 13:52:49 +08:00
|
|
|
|
excluded_doc_ids = excluded_doc_ids or set()
|
|
|
|
|
|
|
2026-07-24 18:00:43 +08:00
|
|
|
|
top_fields = GRAPH_ENTITY_FIELDS + ["compilation_template_ids", "compile_kwd", "compilation_template_kind_kwd"]
|
2026-08-11 13:58:00 +08:00
|
|
|
|
|
|
|
|
|
|
def _valid_top_nodes(rows):
|
|
|
|
|
|
valid = []
|
|
|
|
|
|
for row in rows.values():
|
|
|
|
|
|
if not _row_has_enabled_source(row, excluded_doc_ids):
|
|
|
|
|
|
continue
|
|
|
|
|
|
node = project_entity(row)
|
|
|
|
|
|
if node and str(node.get("name") or "").strip():
|
|
|
|
|
|
valid.append((row, node))
|
|
|
|
|
|
return valid
|
|
|
|
|
|
|
|
|
|
|
|
def _name_matches_query(node, query):
|
|
|
|
|
|
name = str(node.get("name") or "").strip().lower()
|
|
|
|
|
|
query = query.lower()
|
|
|
|
|
|
if not name or not query:
|
|
|
|
|
|
return False
|
|
|
|
|
|
if query in name:
|
|
|
|
|
|
return True
|
|
|
|
|
|
terms = [term for term in query.split() if term]
|
|
|
|
|
|
return bool(terms) and all(term in name for term in terms)
|
|
|
|
|
|
|
|
|
|
|
|
# Entity names are identifiers in the graph. Prefer lexical matching so
|
|
|
|
|
|
# an exact title/entity name wins over a semantically similar row such as
|
|
|
|
|
|
# a navigation or summary record.
|
|
|
|
|
|
text_query = re.sub(r"[ :|\r\n\t,,。??/`!!&^%()\[\]{}<>*~'\"\\=]+", " ", str(keywords)).strip()
|
|
|
|
|
|
candidates = []
|
|
|
|
|
|
if text_query:
|
|
|
|
|
|
text_expr = MatchTextExpr(
|
|
|
|
|
|
["content_ltks^10", "content_sm_ltks"],
|
|
|
|
|
|
text_query,
|
|
|
|
|
|
GRAPH_KEYWORD_CANDIDATES,
|
|
|
|
|
|
{"original_query": keywords},
|
|
|
|
|
|
)
|
|
|
|
|
|
top_map, _ = await graph_search(index_name, kb_id, top_fields, base_entity_condition, OrderByExpr(), GRAPH_KEYWORD_CANDIDATES, match_expressions=[text_expr])
|
|
|
|
|
|
candidates = [(row, node) for row, node in _valid_top_nodes(top_map) if _name_matches_query(node, text_query)]
|
|
|
|
|
|
# In a hierarchical index, a title containing the keyword is usually an
|
|
|
|
|
|
# ancestor context, not the requested detail. Prefer matching detail
|
|
|
|
|
|
# entities so the title is added only through the path-to-root walk; this
|
|
|
|
|
|
# prevents all of the title's unrelated children from being returned.
|
|
|
|
|
|
detail_candidates = [(row, node) for row, node in candidates if str(node.get("type") or "").strip().lower() != "title"]
|
|
|
|
|
|
if detail_candidates:
|
|
|
|
|
|
candidates = detail_candidates
|
|
|
|
|
|
|
|
|
|
|
|
# Fall back to semantic matching for aliases, paraphrases, and cases
|
|
|
|
|
|
# where the query does not occur in the stored entity name.
|
|
|
|
|
|
if not candidates:
|
|
|
|
|
|
try:
|
|
|
|
|
|
qv, _ = await thread_pool_exec(embd_mdl.encode_queries, keywords)
|
|
|
|
|
|
vec = list(qv)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
logging.exception("structure graph: keyword embedding failed (%s)", log_ctx)
|
|
|
|
|
|
return None, [], []
|
|
|
|
|
|
if not vec:
|
|
|
|
|
|
return None, [], []
|
|
|
|
|
|
|
|
|
|
|
|
match_expr = MatchDenseExpr(
|
|
|
|
|
|
vector_column_name=f"q_{len(vec)}_vec",
|
|
|
|
|
|
embedding_data=vec,
|
|
|
|
|
|
embedding_data_type="float",
|
|
|
|
|
|
distance_type="cosine",
|
|
|
|
|
|
topn=GRAPH_KEYWORD_CANDIDATES,
|
|
|
|
|
|
extra_options={"similarity": 0.3},
|
|
|
|
|
|
)
|
|
|
|
|
|
top_map, _ = await graph_search(index_name, kb_id, top_fields, base_entity_condition, OrderByExpr(), GRAPH_KEYWORD_CANDIDATES, match_expressions=[match_expr])
|
|
|
|
|
|
candidates = _valid_top_nodes(top_map)
|
|
|
|
|
|
|
|
|
|
|
|
if not candidates:
|
2026-07-24 18:00:43 +08:00
|
|
|
|
return None, [], []
|
2026-08-11 13:58:00 +08:00
|
|
|
|
bucket_meta, scope = scope_for_template(candidates[0][0])
|
|
|
|
|
|
|
|
|
|
|
|
# A response represents one template bucket. Keep all matching entities
|
|
|
|
|
|
# from that bucket instead of silently discarding every candidate after
|
|
|
|
|
|
# the first one.
|
|
|
|
|
|
bucket_id = bucket_meta.get("template_id")
|
|
|
|
|
|
matched_nodes = []
|
|
|
|
|
|
for row, node in candidates:
|
|
|
|
|
|
candidate_meta, _ = scope_for_template(row)
|
|
|
|
|
|
if candidate_meta.get("template_id") == bucket_id:
|
|
|
|
|
|
matched_nodes.append(node)
|
|
|
|
|
|
if not matched_nodes:
|
2026-07-24 18:00:43 +08:00
|
|
|
|
return None, [], []
|
|
|
|
|
|
|
2026-08-11 13:58:00 +08:00
|
|
|
|
structure_kind = str(bucket_meta.get("kind") or "").strip().lower().replace("-", "_")
|
|
|
|
|
|
|
|
|
|
|
|
# Relations where a matched entity is source OR target (two term queries).
|
2026-07-24 18:00:43 +08:00
|
|
|
|
relations: list[dict] = []
|
|
|
|
|
|
seen_rel: set[tuple[str, str, str]] = set()
|
|
|
|
|
|
neighbor_names_lower: set[str] = set()
|
2026-08-11 13:58:00 +08:00
|
|
|
|
matched_names = {str(node.get("name") or "").strip().lower() for node in matched_nodes}
|
|
|
|
|
|
if structure_kind not in {"tree", "page_index", "pageindex"}:
|
|
|
|
|
|
for matched_node in matched_nodes:
|
|
|
|
|
|
matched_name = str(matched_node.get("name") or "").strip()
|
|
|
|
|
|
matched_name_terms = _endpoint_terms(matched_name)
|
|
|
|
|
|
for field in ("from_entity_kwd", "to_entity_kwd"):
|
|
|
|
|
|
rel_map, _ = await graph_search(
|
|
|
|
|
|
index_name, kb_id, GRAPH_RELATION_FIELDS, dict(scope, knowledge_graph_kwd=["relation"], **{field: matched_name_terms}), OrderByExpr(), GRAPH_EXPANSION_CAP
|
|
|
|
|
|
)
|
|
|
|
|
|
for row in rel_map.values():
|
|
|
|
|
|
if not _row_has_enabled_source(row, excluded_doc_ids):
|
|
|
|
|
|
continue
|
|
|
|
|
|
edge = project_relation(row)
|
|
|
|
|
|
if not edge:
|
|
|
|
|
|
continue
|
|
|
|
|
|
key = (edge.get("from", ""), edge.get("to", ""), edge.get("type", ""))
|
|
|
|
|
|
if key in seen_rel:
|
|
|
|
|
|
continue
|
|
|
|
|
|
seen_rel.add(key)
|
|
|
|
|
|
relations.append(edge)
|
|
|
|
|
|
for endpoint in (edge.get("from", ""), edge.get("to", "")):
|
|
|
|
|
|
endpoint = str(endpoint).strip()
|
|
|
|
|
|
if endpoint and endpoint.lower() not in matched_names:
|
|
|
|
|
|
neighbor_names_lower.add(endpoint.lower())
|
|
|
|
|
|
if len(relations) >= GRAPH_EXPANSION_CAP:
|
|
|
|
|
|
break
|
|
|
|
|
|
if len(relations) >= GRAPH_EXPANSION_CAP:
|
|
|
|
|
|
break
|
|
|
|
|
|
if len(relations) >= GRAPH_EXPANSION_CAP:
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
# Tree-like structures encode hierarchy as parent -> child. A keyword may
|
|
|
|
|
|
# hit a leaf, but the UI needs the complete path back to the root in order
|
|
|
|
|
|
# to render that leaf in context. Walk the incoming edges until no new
|
|
|
|
|
|
# ancestor is found (or the global expansion cap is reached).
|
|
|
|
|
|
if structure_kind in {"tree", "page_index", "pageindex"} and len(relations) < GRAPH_EXPANSION_CAP:
|
|
|
|
|
|
ancestor_frontier = set(matched_names)
|
|
|
|
|
|
seen_ancestors = set(matched_names)
|
|
|
|
|
|
while ancestor_frontier and len(relations) < GRAPH_EXPANSION_CAP:
|
|
|
|
|
|
next_frontier: set[str] = set()
|
|
|
|
|
|
rel_map, _ = await graph_search(
|
|
|
|
|
|
index_name,
|
|
|
|
|
|
kb_id,
|
|
|
|
|
|
GRAPH_RELATION_FIELDS,
|
|
|
|
|
|
dict(scope, knowledge_graph_kwd=["relation"], to_entity_kwd=sorted(ancestor_frontier)),
|
|
|
|
|
|
OrderByExpr(),
|
|
|
|
|
|
GRAPH_EXPANSION_CAP - len(relations),
|
|
|
|
|
|
)
|
|
|
|
|
|
for row in rel_map.values():
|
|
|
|
|
|
if not _row_has_enabled_source(row, excluded_doc_ids):
|
|
|
|
|
|
continue
|
|
|
|
|
|
edge = project_relation(row)
|
|
|
|
|
|
if not edge:
|
|
|
|
|
|
continue
|
|
|
|
|
|
key = (edge.get("from", ""), edge.get("to", ""), edge.get("type", ""))
|
|
|
|
|
|
if key in seen_rel:
|
|
|
|
|
|
continue
|
|
|
|
|
|
seen_rel.add(key)
|
|
|
|
|
|
relations.append(edge)
|
|
|
|
|
|
parent = str(edge.get("from") or "").strip().lower()
|
|
|
|
|
|
if parent and parent not in seen_ancestors:
|
|
|
|
|
|
seen_ancestors.add(parent)
|
|
|
|
|
|
next_frontier.add(parent)
|
|
|
|
|
|
if len(relations) >= GRAPH_EXPANSION_CAP:
|
|
|
|
|
|
break
|
|
|
|
|
|
ancestor_frontier = next_frontier
|
|
|
|
|
|
neighbor_names_lower.update(next_frontier)
|
|
|
|
|
|
|
|
|
|
|
|
entities = list(matched_nodes)
|
2026-07-24 18:00:43 +08:00
|
|
|
|
if neighbor_names_lower:
|
|
|
|
|
|
nb_map, _ = await graph_search(index_name, kb_id, GRAPH_ENTITY_FIELDS, dict(scope, knowledge_graph_kwd=["entity"], name_kwd=sorted(neighbor_names_lower)), OrderByExpr(), GRAPH_EXPANSION_CAP)
|
2026-08-10 13:52:49 +08:00
|
|
|
|
entities.extend(n for n in (project_entity(r) for r in nb_map.values() if _row_has_enabled_source(r, excluded_doc_ids)) if n)
|
2026-07-24 18:00:43 +08:00
|
|
|
|
|
2026-07-29 18:23:51 +08:00
|
|
|
|
entities = dedup_entities(entities)
|
2026-08-11 13:58:00 +08:00
|
|
|
|
if structure_kind in {"tree", "page_index", "pageindex"}:
|
|
|
|
|
|
entity_names = {str(entity.get("name") or "").strip().lower() for entity in entities}
|
|
|
|
|
|
relations = [relation for relation in relations if str(relation.get("from") or "").strip().lower() in entity_names and str(relation.get("to") or "").strip().lower() in entity_names]
|
2026-07-29 18:23:51 +08:00
|
|
|
|
return bucket_meta, entities, normalize_relation_endpoints(entities, relations)
|