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:
MkDev11
2026-04-07 18:52:18 -07:00
committed by GitHub
parent 4a2a17c27a
commit cfee2bc9db
11 changed files with 1293 additions and 13 deletions

View File

@@ -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

View File

@@ -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
"""

View File

@@ -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):

View File

@@ -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