Files
ragflow/rag/utils/infinity_conn.py
S c6ba54bc72 fix: stop three recurring errors on doc delete (#17685) (#17686)
Follow-up to #17526 ("Refactor: merge dataset scope graph"), which introduced two code paths that touch Infinity columns the deployed schema does not declare. This PR makes the runtime robust against the old schema while also adding the new column to the new schema so freshly created tables are correct.
2026-08-16 09:25:51 +08:00

977 lines
45 KiB
Python

#
# Copyright 2025 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.
#
import re
import json
import copy
from infinity.common import InfinityException, SortType
from infinity.errors import ErrorCode
from common.decorator import singleton
import pandas as pd
from common.constants import PAGERANK_FLD, TAG_FLD
from common.doc_store.doc_store_base import MatchExpr, MatchTextExpr, MatchDenseExpr, FusionExpr, OrderByExpr
from common.doc_store.infinity_conn_base import InfinityConnectionBase
from common.float_utils import get_float
DENSE_FILTER_FULLTEXT_WEIGHT_THRESHOLD = 0.8
DEFAULT_VECTOR_SIMILARITY_WEIGHT = 0.5
_JSON_LIST_FIELDS = frozenset(
(
"source_chunk_ids",
"source_doc_ids",
"compilation_template_ids",
"doc_ids_kwd",
"entity_names_kwd",
"outlinks_kwd",
"related_kb_pages_kwd",
"rechunked_from_chunk_ids",
)
)
def _vector_similarity_weight(match_expressions: list[MatchExpr]) -> float:
vector_similarity_weight = DEFAULT_VECTOR_SIMILARITY_WEIGHT
for matchExpr in match_expressions:
if not isinstance(matchExpr, FusionExpr) or matchExpr.method != "weighted_sum":
continue
fusion_params = matchExpr.fusion_params or {}
weights = fusion_params.get("weights")
if not weights:
continue
weight_parts = str(weights).split(",")
if len(weight_parts) > 1:
vector_similarity_weight = get_float(weight_parts[1])
return vector_similarity_weight
def _build_dense_filter(filter_cond: str | None, filter_fulltext: str | None, vector_similarity_weight: float) -> str:
if vector_similarity_weight > DENSE_FILTER_FULLTEXT_WEIGHT_THRESHOLD:
return filter_cond or ""
return filter_fulltext or filter_cond or ""
@singleton
class InfinityConnection(InfinityConnectionBase):
"""
Dataframe and fields convert
"""
@staticmethod
def field_keyword(field_name: str):
# Treat "*_kwd" tag-like columns as keyword lists except for the fields in the exclusion list; source_id is also keyword-like.
# source_doc_ids / source_chunk_ids are multi-valued provenance lists (artifact/wiki rows) and must be
# stored/read/updated as keyword lists so the delete-time ref-count (remove one id, drop row when empty) works.
if field_name in ("source_id", "source_doc_ids", "source_chunk_ids") or (
field_name.endswith("_kwd") and field_name not in ["knowledge_graph_kwd", "docnm_kwd", "important_kwd", "question_kwd", "parent_kwd"]
):
return True
return False
def convert_select_fields(self, output_fields: list[str]) -> list[str]:
need_empty_count = "important_kwd" in output_fields
for i, field in enumerate(output_fields):
if field in ["docnm_kwd", "title_tks", "title_sm_tks"]:
output_fields[i] = "docnm"
elif field in ["important_kwd", "important_tks"]:
output_fields[i] = "important_keywords"
elif field in ["question_kwd", "question_tks"]:
output_fields[i] = "questions"
elif field in ["content_with_weight", "content_ltks", "content_sm_ltks"]:
output_fields[i] = "content"
elif field in ["authors_tks", "authors_sm_tks"]:
output_fields[i] = "authors"
if need_empty_count and "important_kwd_empty_count" not in output_fields:
output_fields.append("important_kwd_empty_count")
return list(set(output_fields))
@staticmethod
def convert_matching_field(field_weight_str: str) -> str:
tokens = field_weight_str.split("^")
field = tokens[0]
if field == "docnm_kwd" or field == "title_tks":
field = "docnm@ft_docnm_rag_coarse"
elif field == "title_sm_tks":
field = "docnm@ft_docnm_rag_fine"
elif field == "important_kwd":
field = "important_keywords@ft_important_keywords_rag_coarse"
elif field == "important_tks":
field = "important_keywords@ft_important_keywords_rag_fine"
elif field == "question_kwd":
field = "questions@ft_questions_rag_coarse"
elif field == "question_tks":
field = "questions@ft_questions_rag_fine"
elif field == "content_with_weight" or field == "content_ltks":
field = "content@ft_content_rag_coarse"
elif field == "content_sm_ltks":
field = "content@ft_content_rag_fine"
elif field == "authors_tks":
field = "authors@ft_authors_rag_coarse"
elif field == "authors_sm_tks":
field = "authors@ft_authors_rag_fine"
elif field == "tag_kwd":
field = "tag_kwd@ft_tag_kwd_whitespace__"
tokens[0] = field
return "^".join(tokens)
"""
CRUD operations
"""
def search(
self,
select_fields: list[str],
highlight_fields: list[str],
condition: dict,
match_expressions: list[MatchExpr],
order_by: OrderByExpr,
offset: int,
limit: int,
index_names: str | list[str],
knowledgebase_ids: list[str],
agg_fields: list[str] | None = None,
rank_feature: dict | None = None,
) -> tuple[pd.DataFrame, int]:
"""
BUG: Infinity returns empty for a highlight field if the query string doesn't use that field.
"""
if isinstance(index_names, str):
index_names = index_names.split(",")
assert isinstance(index_names, list) and len(index_names) > 0
inf_conn = self.connPool.get_conn()
try:
db_instance = inf_conn.get_database(self.dbName)
df_list = list()
table_list = list()
output = select_fields.copy()
output = self.convert_select_fields(output)
if agg_fields is None:
agg_fields = []
for essential_field in ["id"] + agg_fields:
if essential_field not in output:
output.append(essential_field)
score_func = ""
score_column = ""
for matchExpr in match_expressions:
if isinstance(matchExpr, MatchTextExpr):
score_func = "score()"
score_column = "SCORE"
break
if not score_func:
for matchExpr in match_expressions:
if isinstance(matchExpr, MatchDenseExpr):
score_func = "similarity()"
score_column = "SIMILARITY"
break
if match_expressions:
if score_func and score_func not in output:
output.append(score_func)
if PAGERANK_FLD not in output:
output.append(PAGERANK_FLD)
output = [f for f in output if f and f != "_score"]
if limit <= 0:
# ElasticSearch default limit is 10000
limit = 10000
# Prepare expressions common to all tables
filter_cond = None
filter_fulltext = ""
if condition:
# For metadata table (ragflow_doc_meta_), keep kb_id filter
# For chunk tables, remove kb_id filter as they use table separation per KB
is_meta_table = any(indexName.startswith("ragflow_doc_meta_") for indexName in index_names)
if not is_meta_table:
condition = {k: v for k, v in condition.items() if k != "kb_id"}
table_found = False
for indexName in index_names:
if indexName.startswith("ragflow_doc_meta_"):
table_names_to_search = [indexName]
else:
table_names_to_search = [f"{indexName}_{kb_id}" for kb_id in knowledgebase_ids]
for table_name in table_names_to_search:
try:
filter_cond = self.equivalent_condition_to_str(condition, db_instance.get_table(table_name))
table_found = True
break
except Exception:
pass
if table_found:
break
if not table_found:
self.logger.error(f"No valid tables found for indexNames {index_names} and knowledgebaseIds {knowledgebase_ids}")
return pd.DataFrame(), 0
# vector_similarity_weight = _vector_similarity_weight(match_expressions)
for matchExpr in match_expressions:
if isinstance(matchExpr, MatchTextExpr):
if filter_cond and "filter" not in matchExpr.extra_options:
matchExpr.extra_options.update({"filter": filter_cond})
matchExpr.fields = [self.convert_matching_field(field) for field in matchExpr.fields]
fields = ",".join(matchExpr.fields)
filter_fulltext = f"filter_fulltext('{fields}', '{matchExpr.matching_text}')"
if filter_cond:
filter_fulltext = f"({filter_cond}) AND {filter_fulltext}"
minimum_should_match = matchExpr.extra_options.get("minimum_should_match", 0.0)
if isinstance(minimum_should_match, float):
str_minimum_should_match = str(int(minimum_should_match * 100)) + "%"
matchExpr.extra_options["minimum_should_match"] = str_minimum_should_match
# Add rank_feature support
if rank_feature and "rank_features" not in matchExpr.extra_options:
# Convert rank_feature dict to Infinity's rank_features string format
# Format: "field^feature_name^weight,field^feature_name^weight"
rank_features_list = []
for feature_name, weight in rank_feature.items():
# Use TAG_FLD as the field containing rank features
rank_features_list.append(f"{TAG_FLD}^{feature_name}^{weight}")
if rank_features_list:
matchExpr.extra_options["rank_features"] = ",".join(rank_features_list)
for k, v in matchExpr.extra_options.items():
if not isinstance(v, str):
matchExpr.extra_options[k] = str(v)
self.logger.debug(f"INFINITY search MatchTextExpr: {json.dumps(matchExpr.__dict__)}")
elif isinstance(matchExpr, MatchDenseExpr):
if filter_fulltext and "filter" not in matchExpr.extra_options:
matchExpr.extra_options.update({"filter": filter_fulltext})
# dense_filter = _build_dense_filter(filter_cond, filter_fulltext, vector_similarity_weight)
# if dense_filter and "filter" not in matchExpr.extra_options:
# matchExpr.extra_options.update({"filter": dense_filter})
for k, v in matchExpr.extra_options.items():
if not isinstance(v, str):
matchExpr.extra_options[k] = str(v)
similarity = matchExpr.extra_options.get("similarity")
if similarity:
matchExpr.extra_options["threshold"] = similarity
del matchExpr.extra_options["similarity"]
self.logger.debug(f"INFINITY search MatchDenseExpr: {json.dumps(matchExpr.__dict__)}")
elif isinstance(matchExpr, FusionExpr):
if matchExpr.method == "weighted_sum":
# The default is "minmax" which gives a zero score for the last doc.
matchExpr.fusion_params["normalize"] = "atan"
self.logger.debug(f"INFINITY search FusionExpr: {json.dumps(matchExpr.__dict__)}")
order_by_expr_list = list()
if order_by.fields:
for order_field in order_by.fields:
if order_field[1] == 0:
order_by_expr_list.append((order_field[0], SortType.Asc))
else:
order_by_expr_list.append((order_field[0], SortType.Desc))
total_hits_count = 0
# Scatter search tables and gather the results
for indexName in index_names:
if indexName.startswith("ragflow_doc_meta_"):
table_names_to_search = [indexName]
else:
table_names_to_search = [f"{indexName}_{kb_id}" for kb_id in knowledgebase_ids]
for table_name in table_names_to_search:
try:
table_instance = db_instance.get_table(table_name)
except Exception:
continue
table_list.append(table_name)
builder = table_instance.output(output)
if len(match_expressions) > 0:
for matchExpr in match_expressions:
if isinstance(matchExpr, MatchTextExpr):
fields = ",".join(matchExpr.fields)
self.logger.info(f"INFINITY search match_text: {matchExpr.matching_text}")
builder = builder.match_text(
fields,
matchExpr.matching_text,
matchExpr.topn,
matchExpr.extra_options.copy(),
)
elif isinstance(matchExpr, MatchDenseExpr):
builder = builder.match_dense(
matchExpr.vector_column_name,
matchExpr.embedding_data,
matchExpr.embedding_data_type,
matchExpr.distance_type,
matchExpr.topn,
matchExpr.extra_options.copy(),
)
elif isinstance(matchExpr, FusionExpr):
builder = builder.fusion(matchExpr.method, matchExpr.topn, matchExpr.fusion_params)
else:
if filter_cond and len(filter_cond) > 0:
builder.filter(filter_cond)
if order_by.fields:
builder.sort(order_by_expr_list)
builder.offset(offset).limit(limit)
kb_res, extra_result = builder.option({"total_hits_count": True}).to_df()
if extra_result:
total_hits_count += int(extra_result["total_hits_count"])
self.logger.debug(f"INFINITY search table: {str(table_name)}, result: {str(kb_res)}")
df_list.append(kb_res)
res = self.concat_dataframes(df_list, output)
if match_expressions and score_column:
res["_score"] = res[score_column] + res[PAGERANK_FLD]
res = res.sort_values(by="_score", ascending=False).reset_index(drop=True)
res = res.head(limit)
self.logger.debug(f"INFINITY search final result: {str(res)}")
return res, total_hits_count
finally:
self.connPool.release_conn(inf_conn)
def get(self, chunk_id: str, index_name: str, knowledgebase_ids: list[str]) -> dict | None:
# Doc-meta tables are per-tenant, not per-kb: they have no `_kb_id`
# suffix. Match the special-casing used by index_exist/insert/delete
# in InfinityConnectionBase so callers can pass either a chunk
# index (``ragflow_<tenant>``) or a doc-meta index
# (``ragflow_doc_meta_<tenant>``) without us logging a bogus
# "blank knowledgebase_ids" warning.
is_meta_table = index_name.startswith("ragflow_doc_meta_")
# Validate the per-kb list BEFORE acquiring a connection — the
# blank-list case is a caller bug and shouldn't burn a connection
# from the pool. For meta tables the list is unused, so an empty
# list is fine.
if not is_meta_table:
if not knowledgebase_ids:
self.logger.warning("INFINITY get called with empty knowledgebase_ids for index %s", index_name)
return None
kb_table_names = [f"{index_name}_{kb_id}" for kb_id in knowledgebase_ids if kb_id]
if not kb_table_names:
self.logger.warning("INFINITY get has only blank knowledgebase_ids for index %s", index_name)
return None
inf_conn = self.connPool.get_conn()
try:
db_instance = inf_conn.get_database(self.dbName)
df_list = list()
assert isinstance(knowledgebase_ids, list)
table_list = list()
if is_meta_table:
table_names_to_search = [index_name]
else:
table_names_to_search = kb_table_names
for table_name in table_names_to_search:
table_list.append(table_name)
try:
table_instance = db_instance.get_table(table_name)
except Exception:
self.logger.warning(f"Table not found: {table_name}, this dataset isn't created in Infinity. Maybe it is created in other document engine.")
continue
kb_res, _ = table_instance.output(["*"]).filter(f"id = '{chunk_id}'").to_df()
self.logger.debug(f"INFINITY get table: {str(table_list)}, result: {str(kb_res)}")
df_list.append(kb_res)
finally:
self.connPool.release_conn(inf_conn)
res = self.concat_dataframes(df_list, ["id"])
fields = set(res.columns.tolist())
for field in [
"docnm_kwd",
"title_tks",
"title_sm_tks",
"important_kwd",
"important_tks",
"question_kwd",
"question_tks",
"content_with_weight",
"content_ltks",
"content_sm_ltks",
"authors_tks",
"authors_sm_tks",
]:
fields.add(field)
res_fields = self.get_fields(res, list(fields))
chunk = res_fields.get(chunk_id, None)
if chunk is not None:
chunk["id"] = chunk_id
return chunk
def ensure_columns(self, index_name: str, knowledgebase_id: str, column_defs: dict) -> None:
"""Make sure the per-KB chunk table carries the given columns.
Infinity's ``add_columns`` is idempotent for already-present columns,
so this is safe to call repeatedly. Used by callers that write new
marker rows referencing columns which were introduced after the
chunk-table schema was last updated (e.g. ``deleted_doc_id``, added
in #17685). For per-tenant doc-meta tables, pass ``knowledgebase_id``
as the empty string.
Logs and swallows any failure — this is a best-effort upgrade helper,
not a hard requirement of the calling write path. New tables are
created with the current ``conf/infinity_mapping.json`` schema so
callers will not need to invoke this in the steady state.
"""
if index_name.startswith("ragflow_doc_meta_"):
table_name = index_name
else:
table_name = f"{index_name}_{knowledgebase_id}" if knowledgebase_id else None
if not table_name:
return
inf_conn = self.connPool.get_conn()
try:
db_instance = inf_conn.get_database(self.dbName)
try:
table_instance = db_instance.get_table(table_name)
except InfinityException as e:
# src/common/status.cppm, kTableNotExist = 3022
if e.error_code != ErrorCode.TABLE_NOT_EXIST:
raise
# Table doesn't exist yet — the next insert() will create it
# with the current schema, so we have nothing to upgrade.
return
existing = {n for n, *_ in table_instance.show_columns().rows()}
missing = {c: d for c, d in column_defs.items() if c not in existing}
if not missing:
return
self.logger.info(
"INFINITY adding %d missing column(s) [%s] to %s",
len(missing),
", ".join(sorted(missing)),
table_name,
)
table_instance.add_columns(missing)
except Exception:
self.logger.exception(
"INFINITY failed to upgrade columns on %s; the next insert() may fail",
table_name,
)
finally:
self.connPool.release_conn(inf_conn)
def insert(self, documents: list[dict], index_name: str, knowledgebase_id: str = None, refresh: str | bool = "wait_for") -> list[str]:
"""
# Save input to file to test inserting from file in GO
import datetime
import os
debug_file = os.path.join("/var/infinity/tmp", f"insert_{index_name}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.json")
with open(debug_file, 'w') as f:
json.dump({
"table_name": index_name,
"knowledgebase_id": knowledgebase_id,
"chunks": documents
}, f, indent=2)
self.logger.debug(f"Saved insert input to {debug_file}")
"""
inf_conn = self.connPool.get_conn()
try:
db_instance = inf_conn.get_database(self.dbName)
if index_name.startswith("ragflow_doc_meta_"):
table_name = index_name
else:
table_name = f"{index_name}_{knowledgebase_id}"
try:
table_instance = db_instance.get_table(table_name)
except InfinityException as e:
# src/common/status.cppm, kTableNotExist = 3022
if e.error_code != ErrorCode.TABLE_NOT_EXIST:
raise
vector_size = 0
patt = re.compile(r"q_(?P<vector_size>\d+)_vec")
for k in documents[0].keys():
m = patt.match(k)
if m:
vector_size = int(m.group("vector_size"))
break
if vector_size == 0:
raise ValueError("Cannot infer vector size from documents")
# Determine parser_id from document structure
# Table parser documents have 'chunk_data' field
parser_id = None
if "chunk_data" in documents[0] and isinstance(documents[0].get("chunk_data"), dict):
from common.constants import ParserType
parser_id = ParserType.TABLE.value
self.logger.debug("Detected TABLE parser from document structure")
# Fallback: Create table with base schema (shouldn't normally happen as init_kb() creates it)
self.logger.debug(f"Fallback: Creating table {table_name} with base schema, parser_id: {parser_id}")
self.create_idx(index_name, knowledgebase_id, vector_size, parser_id)
table_instance = db_instance.get_table(table_name)
# embedding fields can't have a default value....
embedding_clmns = []
clmns = table_instance.show_columns().rows()
for n, ty, _, _ in clmns:
r = re.search(r"Embedding\([a-z]+,([0-9]+)\)", ty)
if not r:
continue
embedding_clmns.append((n, int(r.group(1))))
docs = copy.deepcopy(documents)
for d in docs:
assert "_id" not in d
assert "id" in d
for k, v in list(d.items()):
if k == "docnm_kwd":
d["docnm"] = v
elif k == "title_kwd":
if not d.get("docnm_kwd"):
d["docnm"] = self.list2str(v)
elif k == "title_sm_tks":
if not d.get("docnm_kwd"):
d["docnm"] = self.list2str(v)
elif k == "important_kwd":
if isinstance(v, list):
empty_count = sum(1 for kw in v if kw == "")
tokens = [kw for kw in v if kw != ""]
d["important_keywords"] = self.list2str(tokens, ",")
d["important_kwd_empty_count"] = empty_count
else:
d["important_keywords"] = self.list2str(v, ",")
elif k == "important_tks":
if not d.get("important_kwd"):
d["important_keywords"] = v
elif k == "content_with_weight":
d["content"] = v
elif k == "content_ltks":
if not d.get("content_with_weight"):
d["content"] = v
elif k == "content_sm_ltks":
if not d.get("content_with_weight"):
d["content"] = v
elif k == "authors_tks":
d["authors"] = v
elif k == "authors_sm_tks":
if not d.get("authors_tks"):
d["authors"] = v
elif k == "question_kwd":
d["questions"] = self.list2str(v, "\n")
elif k == "question_tks":
if not d.get("question_kwd"):
d["questions"] = self.list2str(v)
elif k in _JSON_LIST_FIELDS:
d[k] = json.dumps(list(v) if isinstance(v, (list, tuple, set)) else [], ensure_ascii=False)
elif self.field_keyword(k):
if isinstance(v, list):
d[k] = "###".join(v)
else:
d[k] = v
elif re.search(r"_feas$", k):
d[k] = json.dumps(v)
elif k == "chunk_data":
# Convert data dict to JSON string for storage
if isinstance(v, dict):
d[k] = json.dumps(v)
else:
d[k] = v
elif k == "extra":
# RAPTOR writes {"raptor_method": ...} as a dict; Infinity's
# `extra` column is varchar so we serialize on the write path.
# The read path (raptor_utils._as_extra_dict) already accepts
# both dict and JSON-string. Other backends (OceanBase JSON
# column, ES/OpenSearch) keep dict shape — this is Infinity-only.
if isinstance(v, dict):
d[k] = json.dumps(v)
else:
d[k] = v if v else ""
elif k == "kb_id":
if isinstance(d[k], list):
d[k] = d[k][0] # since d[k] is a list, but we need a str
elif k == "position_int":
assert isinstance(v, list)
arr = [num for row in v for num in row]
d[k] = "_".join(f"{num:08x}" for num in arr)
elif k in ["page_num_int", "top_int"]:
assert isinstance(v, list)
d[k] = "_".join(f"{num:08x}" for num in v)
elif k == "meta_fields":
if isinstance(v, dict):
d[k] = json.dumps(v, ensure_ascii=False)
else:
d[k] = v if v else "{}"
else:
d[k] = v
# Infinity thrift client does not accept None values.
for k in list(d.keys()):
if d[k] is None:
del d[k]
for k in [
"docnm_kwd",
"title_tks",
"title_sm_tks",
"important_kwd",
"important_tks",
"content_with_weight",
"content_ltks",
"content_sm_ltks",
"authors_tks",
"authors_sm_tks",
"question_kwd",
"question_tks",
]:
if k in d:
del d[k]
for n, vs in embedding_clmns:
if n in d:
continue
d[n] = [0] * vs
ids = ["'{}'".format(d["id"]) for d in docs]
str_ids = ", ".join(ids)
str_filter = f"id IN ({str_ids})"
table_instance.delete(str_filter)
# for doc in documents:
# logger.info(f"insert position_int: {doc['position_int']}")
# logger.info(f"InfinityConnection.insert {json.dumps(documents)}")
table_instance.insert(docs)
finally:
self.connPool.release_conn(inf_conn)
self.logger.debug(f"INFINITY inserted into {table_name} {str_ids}.")
return []
def update(self, condition: dict, new_value: dict, index_name: str, knowledgebase_id: str) -> bool:
# if 'position_int' in newValue:
# logger.info(f"update position_int: {newValue['position_int']}")
inf_conn = self.connPool.get_conn()
try:
db_instance = inf_conn.get_database(self.dbName)
if index_name.startswith("ragflow_doc_meta_"):
table_name = index_name
else:
table_name = f"{index_name}_{knowledgebase_id}"
try:
table_instance = db_instance.get_table(table_name)
except InfinityException as e:
# src/common/status.cppm, kTableNotExist = 3022
if e.error_code == ErrorCode.TABLE_NOT_EXIST:
self.logger.warning(f"Table {table_name} does not exist, skipping update.")
return False
raise
# if "exists" in condition:
# del condition["exists"]
clmns = {}
if table_instance:
for n, ty, de, _ in table_instance.show_columns().rows():
clmns[n] = (ty, de)
filter = self.equivalent_condition_to_str(condition, table_instance)
removeValue = {}
for k, v in list(new_value.items()):
if k == "docnm_kwd":
new_value["docnm"] = self.list2str(v)
elif k == "title_kwd":
if not new_value.get("docnm_kwd"):
new_value["docnm"] = self.list2str(v)
elif k == "title_sm_tks":
if not new_value.get("docnm_kwd"):
new_value["docnm"] = v
elif k == "important_kwd":
if isinstance(v, list):
empty_count = sum(1 for kw in v if kw == "")
tokens = [kw for kw in v if kw != ""]
new_value["important_keywords"] = self.list2str(tokens, ",")
new_value["important_kwd_empty_count"] = empty_count
else:
new_value["important_keywords"] = self.list2str(v, ",")
elif k == "important_tks":
if not new_value.get("important_kwd"):
new_value["important_keywords"] = v
elif k == "content_with_weight":
new_value["content"] = v
elif k == "content_ltks":
if not new_value.get("content_with_weight"):
new_value["content"] = v
elif k == "content_sm_ltks":
if not new_value.get("content_with_weight"):
new_value["content"] = v
elif k == "authors_tks":
new_value["authors"] = v
elif k == "authors_sm_tks":
if not new_value.get("authors_tks"):
new_value["authors"] = v
elif k == "question_kwd":
new_value["questions"] = "\n".join(v)
elif k == "question_tks":
if not new_value.get("question_kwd"):
new_value["questions"] = self.list2str(v)
elif k in _JSON_LIST_FIELDS:
new_value[k] = json.dumps(list(v) if isinstance(v, (list, tuple, set)) else [], ensure_ascii=False)
elif self.field_keyword(k):
if isinstance(v, list):
new_value[k] = "###".join(v)
else:
new_value[k] = v
elif re.search(r"_feas$", k):
new_value[k] = json.dumps(v)
elif k == "kb_id":
if isinstance(new_value[k], list):
new_value[k] = new_value[k][0] # since d[k] is a list, but we need a str
elif k == "position_int":
assert isinstance(v, list)
arr = [num for row in v for num in row]
new_value[k] = "_".join(f"{num:08x}" for num in arr)
elif k in ["page_num_int", "top_int"]:
assert isinstance(v, list)
new_value[k] = "_".join(f"{num:08x}" for num in v)
elif k == "remove":
if isinstance(v, str):
assert v in clmns, f"'{v}' should be in '{clmns}'."
ty, de = clmns[v]
if ty.lower().find("cha"):
if not de:
de = ""
new_value[v] = de
else:
for kk, vv in v.items():
removeValue[kk] = vv
del new_value[k]
else:
new_value[k] = v
for k in [
"docnm_kwd",
"title_tks",
"title_sm_tks",
"important_kwd",
"important_tks",
"content_with_weight",
"content_ltks",
"content_sm_ltks",
"authors_tks",
"authors_sm_tks",
"question_kwd",
"question_tks",
]:
if k in new_value:
del new_value[k]
# The Infinity Python client inspects value[0] for list values
# while building an update expression. An empty list therefore
# raises IndexError before the request reaches Infinity. Keep
# JSON and keyword-list columns clearable, but do not send empty
# values for other columns (for example, an empty vector returned
# by a partial row read).
for k, v in list(new_value.items()):
if not isinstance(v, list) or v:
continue
if k in _JSON_LIST_FIELDS:
new_value[k] = json.dumps([], ensure_ascii=False)
elif self.field_keyword(k):
new_value[k] = ""
else:
del new_value[k]
remove_opt = {} # "[k,new_value]": [id_to_update, ...]
if removeValue:
col_to_remove = list(removeValue.keys())
row_to_opt = table_instance.output(col_to_remove + ["id"]).filter(filter).to_df()
self.logger.debug(f"INFINITY search table {str(table_name)}, filter {filter}, result: {str(row_to_opt[0])}")
row_to_opt = self.get_fields(row_to_opt, col_to_remove)
for id, old_v in row_to_opt.items():
for k, remove_v in removeValue.items():
if remove_v in old_v[k]:
new_v = old_v[k].copy()
new_v.remove(remove_v)
kv_key = json.dumps([k, new_v])
if kv_key not in remove_opt:
remove_opt[kv_key] = [id]
else:
remove_opt[kv_key].append(id)
self.logger.debug(f"INFINITY update table {table_name}, filter {filter}, newValue {new_value}.")
for update_kv, ids in remove_opt.items():
k, v = json.loads(update_kv)
table_instance.update(filter + " AND id in ({0})".format(",".join([f"'{id}'" for id in ids])), {k: "###".join(v)})
table_instance.update(filter, new_value)
finally:
self.connPool.release_conn(inf_conn)
return True
def adjust_chunk_pagerank_fea(
self,
chunk_id: str,
index_name: str,
knowledgebase_id: str,
delta: int,
min_weight: int,
max_weight: int,
row_id: int | None = None,
max_retries: int = 2,
) -> bool:
"""Adjust pagerank_fea on one chunk row in Infinity.
Uses row_id for a targeted update when available. If the row_id is
stale (concurrent update changed it), re-reads the current row_id and
retries up to *max_retries* times.
"""
table_name = f"{index_name}_{knowledgebase_id}"
for attempt in range(max_retries + 1):
inf_conn = self.connPool.get_conn()
try:
db_instance = inf_conn.get_database(self.dbName)
table_instance = db_instance.get_table(table_name)
if row_id is None:
df, _ = table_instance.output([PAGERANK_FLD, "row_id()"]).filter(f"id = '{chunk_id}'").to_df()
if df.empty:
self.logger.warning(
"adjust_chunk_pagerank_fea: chunk %s not found in %s",
chunk_id,
table_name,
)
return False
current_weight = int(float(df[PAGERANK_FLD].iloc[0] or 0))
row_id = int(df["row_id"].iloc[0])
else:
df, _ = table_instance.output([PAGERANK_FLD]).filter(f"id = '{chunk_id}'").to_df()
if df.empty:
return False
current_weight = int(float(df[PAGERANK_FLD].iloc[0] or 0))
new_weight = max(min_weight, min(max_weight, current_weight + delta))
table_instance.update(
f"_row_id = {row_id}",
{PAGERANK_FLD: new_weight},
)
self.logger.info(
"adjust_chunk_pagerank_fea(chunk=%s, table=%s): %s -> %s via row_id=%s",
chunk_id,
table_name,
current_weight,
new_weight,
row_id,
)
return True
except InfinityException as e:
if attempt < max_retries:
self.logger.warning(
"adjust_chunk_pagerank_fea stale row_id=%s for chunk %s (attempt %s/%s): %s",
row_id,
chunk_id,
attempt + 1,
max_retries,
e,
)
row_id = None
continue
self.logger.error(
"adjust_chunk_pagerank_fea failed for chunk %s after %s attempts: %s",
chunk_id,
max_retries + 1,
e,
)
return False
except Exception as e:
self.logger.error(
"adjust_chunk_pagerank_fea error for chunk %s: %s",
chunk_id,
e,
)
return False
finally:
self.connPool.release_conn(inf_conn)
return False
"""
Helper functions for search result
"""
def get_fields(self, res: tuple[pd.DataFrame, int] | pd.DataFrame, fields: list[str]) -> dict[str, dict]:
if isinstance(res, tuple):
res = res[0]
if not fields:
return {}
fields_all = fields.copy()
fields_all.append("id")
fields_all = set(fields_all)
if "docnm" in res.columns:
for field in ["docnm_kwd", "title_tks", "title_sm_tks"]:
if field in fields_all:
res[field] = res["docnm"]
if "important_keywords" in res.columns:
if "important_kwd" in fields_all:
if "important_kwd_empty_count" in res.columns:
base = res["important_keywords"].apply(lambda raw: raw.split(",") if raw else [])
counts = res["important_kwd_empty_count"].fillna(0).astype(int)
res["important_kwd"] = [tokens + [""] * empty_count for tokens, empty_count in zip(base.tolist(), counts.tolist())]
else:
res["important_kwd"] = res["important_keywords"].apply(lambda v: v.split(",") if v else [])
if "important_tks" in fields_all:
res["important_tks"] = res["important_keywords"]
if "questions" in res.columns:
if "question_kwd" in fields_all:
res["question_kwd"] = res["questions"].apply(lambda v: v.splitlines())
if "question_tks" in fields_all:
res["question_tks"] = res["questions"]
if "content" in res.columns:
for field in ["content_with_weight", "content_ltks", "content_sm_ltks"]:
if field in fields_all:
res[field] = res["content"]
if "authors" in res.columns:
for field in ["authors_tks", "authors_sm_tks"]:
if field in fields_all:
res[field] = res["authors"]
column_map = {col.lower(): col for col in res.columns}
# row_id() is returned by infinity as "row_id", add mapping for lookup
if "row_id()" in fields_all and "row_id" in column_map:
column_map["row_id()"] = column_map["row_id"]
matched_columns = {column_map[col.lower()]: col for col in fields_all if col.lower() in column_map}
none_columns = [col for col in fields_all if col.lower() not in column_map]
res2 = res[matched_columns.keys()]
res2 = res2.rename(columns=matched_columns)
res2.drop_duplicates(subset=["id"], inplace=True)
for column in list(res2.columns):
k = column.lower()
if k in _JSON_LIST_FIELDS:
def parse_json_list(value):
if isinstance(value, list):
return value
if not value:
return []
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, list) else [parsed]
except (TypeError, json.JSONDecodeError):
# Read rows written by the previous varchar encoding.
return [item for item in str(value).split("###") if item]
res2[column] = res2[column].apply(parse_json_list)
elif self.field_keyword(k):
res2[column] = res2[column].apply(lambda v: [kwd for kwd in v.split("###") if kwd])
elif re.search(r"_feas$", k):
res2[column] = res2[column].apply(lambda v: json.loads(v) if v else {})
elif k == "chunk_data":
# Parse JSON data back to dict for table parser fields
res2[column] = res2[column].apply(lambda v: json.loads(v) if v and isinstance(v, str) else v)
elif k == "position_int":
def to_position_int(v):
if v:
arr = [int(hex_val, 16) for hex_val in v.split("_")]
v = [arr[i : i + 5] for i in range(0, len(arr), 5)]
else:
v = []
return v
res2[column] = res2[column].apply(to_position_int)
elif k in ["page_num_int", "top_int"]:
res2[column] = res2[column].apply(lambda v: [int(hex_val, 16) for hex_val in v.split("_")] if v else [])
else:
pass
for column in ["docnm", "important_keywords", "questions", "content", "authors"]:
if column in res2:
del res2[column]
for column in none_columns:
res2[column] = None
return res2.set_index("id").to_dict(orient="index")