mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-13 16:38:26 +08:00
feat: Auto-adjust chunk recall weights based on user feedback (#12689)
### What problem does this PR solve? Implements automatic adjustment of knowledge base chunk recall weights based on user feedback (upvotes/downvotes). When users upvote or downvote a response, the system locates the corresponding knowledge snippets and adjusts their recall weight to improve future retrieval quality. **Closes #12670** **How it works:** 1. User upvotes/downvotes a response via `POST /thumbup` 2. System extracts chunk IDs from the conversation reference 3. For each referenced chunk: - Reads current `pagerank_fea` value from document store - Increments (+1) for upvote or decrements (-1) for downvote - Clamps weight to [0, 100] range - Updates chunk in ES/Infinity/OceanBase 4. Future retrievals score these chunks higher/lower based on accumulated feedback **Files changed:** - `api/db/services/chunk_feedback_service.py` - New service for updating chunk pagerank weights - `api/apps/conversation_app.py` - Integrated feedback service into thumbup endpoint - `test/testcases/test_web_api/test_chunk_feedback/` - Unit tests ### Type of change - [x] New Feature (non-breaking change which adds functionality) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Chat message feedback now updates per-chunk relevance weights (feature-flag gated), with configurable weighting and atomic updates across storage backends. * **Bug Fixes** * Stricter validation for message feedback inputs and more robust handling of feedback transitions. * **Tests** * Expanded test coverage for chunk-feedback behavior, weighting strategies, storage backends, and thumb-flip scenarios. * **Chores** * CI workflow extended to run the new chunk-feedback web API tests. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: mkdev11 <YOUR_GITHUB_ID+MkDev11@users.noreply.github.com> Co-authored-by: mkdev11 <MkDev11@users.noreply.github.com>
This commit is contained in:
@@ -31,6 +31,32 @@ ATTEMPT_TIME = 2
|
||||
MAX_RESULT_WINDOW = 10000
|
||||
SEARCH_AFTER_BATCH_SIZE = 1000
|
||||
|
||||
# Single-document atomic pagerank_fea adjust (chunk feedback). Clamps using params.min_w / max_w;
|
||||
# removes field at zero for rank_feature compatibility.
|
||||
_PAGERANK_FEA_ADJUST_SCRIPT = """
|
||||
double cur = 0.0;
|
||||
if (ctx._source.containsKey(params.pf)) {
|
||||
Object v = ctx._source[params.pf];
|
||||
if (v != null) {
|
||||
if (v instanceof Number) {
|
||||
cur = ((Number)v).doubleValue();
|
||||
} else {
|
||||
try { cur = Double.parseDouble(v.toString()); } catch (Exception e) { cur = 0.0; }
|
||||
}
|
||||
}
|
||||
}
|
||||
double nw = cur + params.delta;
|
||||
if (nw < params.min_w) { nw = params.min_w; }
|
||||
if (nw > params.max_w) { nw = params.max_w; }
|
||||
if (nw <= 0.0) {
|
||||
if (ctx._source.containsKey(params.pf)) {
|
||||
ctx._source.remove(params.pf);
|
||||
}
|
||||
} else {
|
||||
ctx._source[params.pf] = nw;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
@singleton
|
||||
class ESConnection(ESConnectionBase):
|
||||
@@ -303,7 +329,11 @@ class ESConnection(ESConnectionBase):
|
||||
# update specific single document
|
||||
chunk_id = condition["id"]
|
||||
for i in range(ATTEMPT_TIME):
|
||||
for k in doc.keys():
|
||||
doc_part = copy.deepcopy(doc)
|
||||
remove_value = doc_part.pop("remove", None)
|
||||
remove_field = remove_value if isinstance(remove_value, str) else None
|
||||
remove_dict = remove_value if isinstance(remove_value, dict) else None
|
||||
for k in doc_part.keys():
|
||||
if "feas" != k.split("_")[-1]:
|
||||
continue
|
||||
try:
|
||||
@@ -312,8 +342,32 @@ class ESConnection(ESConnectionBase):
|
||||
self.logger.exception(
|
||||
f"ESConnection.update(index={index_name}, id={chunk_id}, doc={json.dumps(condition, ensure_ascii=False)}) got exception")
|
||||
try:
|
||||
self.es.update(index=index_name, id=chunk_id, doc=doc)
|
||||
return True
|
||||
if remove_field is not None:
|
||||
self.es.update(
|
||||
index=index_name,
|
||||
id=chunk_id,
|
||||
script=f"ctx._source.remove('{remove_field}');",
|
||||
)
|
||||
if remove_dict is not None:
|
||||
scripts = []
|
||||
params = {}
|
||||
for kk, vv in remove_dict.items():
|
||||
scripts.append(
|
||||
f"if (ctx._source.containsKey('{kk}') && ctx._source.{kk} != null) "
|
||||
f"{{ int i = ctx._source.{kk}.indexOf(params.p_{kk}); "
|
||||
f"if (i >= 0) {{ ctx._source.{kk}.remove(i); }} }}"
|
||||
)
|
||||
params[f"p_{kk}"] = vv
|
||||
if scripts:
|
||||
self.es.update(
|
||||
index=index_name,
|
||||
id=chunk_id,
|
||||
script={"source": "".join(scripts), "params": params},
|
||||
)
|
||||
if doc_part:
|
||||
self.es.update(index=index_name, id=chunk_id, doc=doc_part)
|
||||
if remove_field is not None or remove_dict is not None or doc_part:
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.exception(
|
||||
f"ESConnection.update(index={index_name}, id={chunk_id}, doc={json.dumps(condition, ensure_ascii=False)}) got exception: " + str(
|
||||
@@ -389,6 +443,61 @@ class ESConnection(ESConnectionBase):
|
||||
break
|
||||
return False
|
||||
|
||||
def adjust_chunk_pagerank_fea(
|
||||
self,
|
||||
chunk_id: str,
|
||||
index_name: str,
|
||||
knowledgebase_id: str,
|
||||
delta: float,
|
||||
min_w: float = 0.0,
|
||||
max_w: float = 100.0,
|
||||
row_id: int | None = None,
|
||||
) -> bool:
|
||||
"""Atomically adjust pagerank_fea on one chunk (painless script)."""
|
||||
_ = row_id
|
||||
for _ in range(ATTEMPT_TIME):
|
||||
try:
|
||||
self.es.update(
|
||||
index=index_name,
|
||||
id=chunk_id,
|
||||
retry_on_conflict=3,
|
||||
script={
|
||||
"source": _PAGERANK_FEA_ADJUST_SCRIPT.strip(),
|
||||
"lang": "painless",
|
||||
"params": {
|
||||
"pf": PAGERANK_FLD,
|
||||
"delta": float(delta),
|
||||
"min_w": float(min_w),
|
||||
"max_w": float(max_w),
|
||||
},
|
||||
},
|
||||
)
|
||||
self.logger.debug(
|
||||
"ESConnection.adjust_chunk_pagerank_fea(index=%s, id=%s, delta=%s) succeeded",
|
||||
index_name,
|
||||
chunk_id,
|
||||
delta,
|
||||
)
|
||||
return True
|
||||
except ConnectionTimeout:
|
||||
self.logger.exception("ES request timeout")
|
||||
time.sleep(3)
|
||||
self._connect()
|
||||
continue
|
||||
except Exception as e:
|
||||
self.logger.exception(
|
||||
"ESConnection.adjust_chunk_pagerank_fea(index=%s, id=%s): %s",
|
||||
index_name,
|
||||
chunk_id,
|
||||
e,
|
||||
)
|
||||
if re.search(r"connection", str(e).lower()):
|
||||
time.sleep(3)
|
||||
self._connect()
|
||||
continue
|
||||
break
|
||||
return False
|
||||
|
||||
def delete(self, condition: dict, index_name: str, knowledgebase_id: str) -> int:
|
||||
assert "_id" not in condition
|
||||
condition["kb_id"] = knowledgebase_id
|
||||
|
||||
@@ -597,6 +597,84 @@ class InfinityConnection(InfinityConnectionBase):
|
||||
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
|
||||
"""
|
||||
|
||||
@@ -1213,6 +1213,32 @@ class OBConnection(OBConnectionBase):
|
||||
logger.error(f"OBConnection.update error: {str(e)}")
|
||||
return False
|
||||
|
||||
def adjust_chunk_pagerank_fea(
|
||||
self,
|
||||
chunk_id: str,
|
||||
index_name: str,
|
||||
knowledgebase_id: str,
|
||||
delta: int,
|
||||
min_w: int = 0,
|
||||
max_w: int = 100,
|
||||
) -> bool:
|
||||
"""Atomically adjust pagerank_fea on one chunk row (single UPDATE)."""
|
||||
if not self._check_table_exists_cached(index_name):
|
||||
return True
|
||||
d = int(delta)
|
||||
sql = (
|
||||
f"UPDATE {index_name} SET {PAGERANK_FLD} = "
|
||||
f"GREATEST({int(min_w)}, LEAST({int(max_w)}, COALESCE({PAGERANK_FLD}, 0) + ({d}))) "
|
||||
f"WHERE id = {get_value_str(chunk_id)} AND kb_id = {get_value_str(knowledgebase_id)}"
|
||||
)
|
||||
logger.debug("OBConnection.adjust_chunk_pagerank_fea sql: %s", sql)
|
||||
try:
|
||||
self.client.perform_raw_text_sql(sql)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("OBConnection.adjust_chunk_pagerank_fea error: %s", e)
|
||||
return False
|
||||
|
||||
def _row_to_entity(self, data: Row, fields: list[str]) -> dict:
|
||||
entity = {}
|
||||
for i, field in enumerate(fields):
|
||||
|
||||
@@ -34,6 +34,30 @@ from common import settings
|
||||
|
||||
ATTEMPT_TIME = 2
|
||||
|
||||
_PAGERANK_FEA_ADJUST_SCRIPT = """
|
||||
double cur = 0.0;
|
||||
if (ctx._source.containsKey(params.pf)) {
|
||||
Object v = ctx._source[params.pf];
|
||||
if (v != null) {
|
||||
if (v instanceof Number) {
|
||||
cur = ((Number)v).doubleValue();
|
||||
} else {
|
||||
try { cur = Double.parseDouble(v.toString()); } catch (Exception e) { cur = 0.0; }
|
||||
}
|
||||
}
|
||||
}
|
||||
double nw = cur + params.delta;
|
||||
if (nw < params.min_w) { nw = params.min_w; }
|
||||
if (nw > params.max_w) { nw = params.max_w; }
|
||||
if (nw <= 0.0) {
|
||||
if (ctx._source.containsKey(params.pf)) {
|
||||
ctx._source.remove(params.pf);
|
||||
}
|
||||
} else {
|
||||
ctx._source[params.pf] = nw;
|
||||
}
|
||||
"""
|
||||
|
||||
logger = logging.getLogger('ragflow.opensearch_conn')
|
||||
|
||||
|
||||
@@ -329,9 +353,37 @@ class OSConnection(DocStoreConnection):
|
||||
# update specific single document
|
||||
chunkId = condition["id"]
|
||||
for i in range(ATTEMPT_TIME):
|
||||
doc_part = copy.deepcopy(doc)
|
||||
remove_value = doc_part.pop("remove", None)
|
||||
remove_field = remove_value if isinstance(remove_value, str) else None
|
||||
remove_dict = remove_value if isinstance(remove_value, dict) else None
|
||||
try:
|
||||
self.os.update(index=indexName, id=chunkId, body={"doc": doc})
|
||||
return True
|
||||
if remove_field is not None:
|
||||
self.os.update(
|
||||
index=indexName,
|
||||
id=chunkId,
|
||||
body={"script": {"source": f"ctx._source.remove('{remove_field}');"}},
|
||||
)
|
||||
if remove_dict is not None:
|
||||
scripts = []
|
||||
params = {}
|
||||
for kk, vv in remove_dict.items():
|
||||
scripts.append(
|
||||
f"if (ctx._source.containsKey('{kk}') && ctx._source.{kk} != null) "
|
||||
f"{{ int i = ctx._source.{kk}.indexOf(params.p_{kk}); "
|
||||
f"if (i >= 0) {{ ctx._source.{kk}.remove(i); }} }}"
|
||||
)
|
||||
params[f"p_{kk}"] = vv
|
||||
if scripts:
|
||||
self.os.update(
|
||||
index=indexName,
|
||||
id=chunkId,
|
||||
body={"script": {"source": "".join(scripts), "params": params}},
|
||||
)
|
||||
if doc_part:
|
||||
self.os.update(index=indexName, id=chunkId, body={"doc": doc_part})
|
||||
if remove_field is not None or remove_dict is not None or doc_part:
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"OSConnection.update(index={indexName}, id={id}, doc={json.dumps(condition, ensure_ascii=False)}) got exception")
|
||||
@@ -405,6 +457,52 @@ class OSConnection(DocStoreConnection):
|
||||
break
|
||||
return False
|
||||
|
||||
def adjust_chunk_pagerank_fea(
|
||||
self,
|
||||
chunk_id: str,
|
||||
indexName: str,
|
||||
knowledgebaseId: str,
|
||||
delta: float,
|
||||
min_w: float = 0.0,
|
||||
max_w: float = 100.0,
|
||||
row_id: int | None = None,
|
||||
) -> bool:
|
||||
"""Atomically adjust pagerank_fea on one chunk (painless script)."""
|
||||
_ = row_id
|
||||
try:
|
||||
self.os.update(
|
||||
index=indexName,
|
||||
id=chunk_id,
|
||||
retry_on_conflict=3,
|
||||
body={
|
||||
"script": {
|
||||
"source": _PAGERANK_FEA_ADJUST_SCRIPT.strip(),
|
||||
"lang": "painless",
|
||||
"params": {
|
||||
"pf": PAGERANK_FLD,
|
||||
"delta": float(delta),
|
||||
"min_w": float(min_w),
|
||||
"max_w": float(max_w),
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
logger.debug(
|
||||
"OSConnection.adjust_chunk_pagerank_fea(index=%s, id=%s, delta=%s) succeeded",
|
||||
indexName,
|
||||
chunk_id,
|
||||
delta,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"OSConnection.adjust_chunk_pagerank_fea(index=%s, id=%s): %s",
|
||||
indexName,
|
||||
chunk_id,
|
||||
e,
|
||||
)
|
||||
return False
|
||||
|
||||
def delete(self, condition: dict, indexName: str, knowledgebaseId: str) -> int:
|
||||
assert "_id" not in condition
|
||||
condition["kb_id"] = knowledgebaseId
|
||||
|
||||
Reference in New Issue
Block a user