Refactor: Drop the vector fetch for ES (#14970)

## Summary
- Stop pulling chunk vectors (`q_*_vec`) back from Elasticsearch in the
main retrieval path. ES already knows them; shipping them was pure
bandwidth/memory overhead.
- Recover the per-chunk cosine similarity via a second KNN-only ES call
filtered by the candidate chunk ids. The new `_score` is merged with
locally computed term similarity using the user-configured
`vector_similarity_weight`.
- Lazily fetch the chunk embedding only for the chunks
`insert_citations` actually needs.

## Details
**`rag/nlp/search.py`**
- `Dealer.search`: no longer appends `q_*_vec` to the ES select list.
OceanBase still gets it (its rerank path is unchanged).
- New `Dealer._knn_scores(sres, idx_names, kb_ids)`: a `MatchDenseExpr`
over the cached query vector filtered by `id IN sres.ids`, returning
`{chunk_id: cosine_score}` via ES `_score`.
- New `Dealer.rerank_with_knn(...)`: term similarity from
`qryr.token_similarity` plus the ES-supplied KNN score, combined with
`tkweight`/`vtweight` and the existing rank-feature bonus.
- New `Dealer.fetch_chunk_vectors(chunk_ids, tenant_ids, kb_ids, dim)`:
on-demand vector fetch for citation use.
- `Dealer.retrieval` routes Infinity → unchanged, OceanBase → existing
local `rerank`, ES → new KNN-score path.

**`common/doc_store/es_conn_base.py`**
- New `get_scores(res)` helper returning `{_id: _score}` directly from
hit headers (ES doesn't surface `_score` through `get_fields`).

**`api/db/services/dialog_service.py`**
- New top-level `_hydrate_chunk_vectors(...)` helper. On ES it
back-fills `ck["vector"]` from `fetch_chunk_vectors` right before
`insert_citations`. No-op on Infinity / OB (their chunks already carry
vectors).
- Both `decorate_answer` closures became `async` and are `await`-ed at
all call sites in `async_chat` and `async_ask`.

## Backend behavior
| Backend | Returns chunk vec in main search | Sim source | Vectors for
citations |
|---|---|---|---|
| ES | No | second KNN call (`_score`) merged with term sim | fetched on
demand |
| Infinity | No (unchanged) | normalized `_score` | already on chunks |
| OceanBase | Yes (kept) | local hybrid rerank | already on chunks |

## Test plan
This commit is contained in:
Kevin Hu
2026-05-18 14:21:56 +08:00
committed by GitHub
parent 9f2fb4611f
commit 7cdc74bbe5
4 changed files with 212 additions and 11 deletions

View File

@@ -65,6 +65,53 @@ def _chunk_kb_id_for_doc(row_dict, kb_ids, doc_id):
return kb_ids[0]
return row_dict.get("kb_id") or row_dict.get("kb_id_kwd")
async def _hydrate_chunk_vectors(retriever, chunks, tenant_ids, kb_ids):
"""
Citation prep: on the ES backend the main retrieval call deliberately
skips fetching the chunk embedding. insert_citations needs it, so we
pull the vectors for just the candidate chunks right before computing
answer-vs-chunk similarity. Chunks without an ES chunk_id (e.g. web
search results) keep whatever placeholder they were given. Other
backends still carry vectors in the chunk, so we skip the round-trip.
"""
if settings.DOC_ENGINE_INFINITY or settings.DOC_ENGINE_OCEANBASE:
return
if not chunks:
return
dim = 0
for ck in chunks:
v = ck.get("vector")
if isinstance(v, list) and v:
dim = len(v)
break
if not dim:
return
# Skip chunks that already have a non-zero vector (e.g. parent chunks
# produced by retrieval_by_children copy the child vector inline).
chunk_ids = []
for ck in chunks:
cid = ck.get("chunk_id")
if not cid:
continue
v = ck.get("vector") or []
if any(x for x in v):
continue
chunk_ids.append(cid)
if not chunk_ids:
return
try:
vectors = await retriever.fetch_chunk_vectors(chunk_ids, tenant_ids, kb_ids, dim)
except Exception as e: # noqa: BLE001 - degrade gracefully on hydrate failure
logger.warning("fetch_chunk_vectors failed; citations will use placeholders: %s", e)
return
if not vectors:
return
for ck in chunks:
cid = ck.get("chunk_id")
if cid and cid in vectors:
ck["vector"] = vectors[cid]
def _normalize_internet_flag(value):
if isinstance(value, bool):
return value
@@ -735,7 +782,7 @@ async def async_chat(dialog, messages, stream=True, **kwargs):
if "max_tokens" in gen_conf:
gen_conf["max_tokens"] = min(gen_conf["max_tokens"], max_tokens - used_token_count)
def decorate_answer(answer):
async def decorate_answer(answer):
nonlocal embd_mdl, prompt_config, knowledges, kwargs, kbinfos, prompt, retrieval_ts, questions, langfuse_tracer
refs = []
@@ -749,6 +796,9 @@ async def async_chat(dialog, messages, stream=True, **kwargs):
idx = set([])
normalized_answer = normalize_arabic_digits(answer) or ""
if embd_mdl and not CITATION_MARKER_PATTERN.search(normalized_answer):
# Main retrieval no longer ships chunk vectors back from ES.
# Pull them on demand for the chunks we are about to cite.
await _hydrate_chunk_vectors(retriever, kbinfos.get("chunks", []), tenant_ids, dialog.kb_ids)
answer, idx = retriever.insert_citations(
answer,
[ck["content_ltks"] for ck in kbinfos["chunks"]],
@@ -841,7 +891,7 @@ async def async_chat(dialog, messages, stream=True, **kwargs):
yield {"answer": value, "reference": {}, "audio_binary": tts(tts_mdl, value), "final": False}
full_answer = last_state.full_text if last_state else ""
if full_answer:
final = decorate_answer(_extract_visible_answer(thought + full_answer))
final = await decorate_answer(_extract_visible_answer(thought + full_answer))
final["final"] = True
final["audio_binary"] = None
yield final
@@ -852,7 +902,7 @@ async def async_chat(dialog, messages, stream=True, **kwargs):
answer = await chat_mdl.async_chat(prompt + prompt4citation, msg[1:], gen_conf, images=image_files)
user_content = msg[-1].get("content", "[content not available]")
logging.debug("User: {}|Assistant: {}".format(user_content, answer))
res = decorate_answer(answer)
res = await decorate_answer(answer)
res["audio_binary"] = tts(tts_mdl, answer)
yield res
@@ -1542,8 +1592,11 @@ async def async_ask(question, kb_ids, tenant_id, chat_llm_name=None, search_conf
msg = [{"role": "user", "content": question}]
def decorate_answer(answer):
async def decorate_answer(answer):
nonlocal knowledges, kbinfos, sys_prompt
# Main retrieval no longer ships chunk vectors back from ES. Pull
# them on demand for the chunks we are about to cite.
await _hydrate_chunk_vectors(retriever, kbinfos.get("chunks", []), tenant_ids, kb_ids)
answer, idx = retriever.insert_citations(answer, [ck["content_ltks"] for ck in kbinfos["chunks"]], [ck["vector"] for ck in kbinfos["chunks"]], embd_mdl, tkweight=0.7, vtweight=0.3)
idx = set([kbinfos["chunks"][int(i)]["doc_id"] for i in idx])
recall_docs = [d for d in kbinfos["doc_aggs"] if d["doc_id"] in idx]
@@ -1570,7 +1623,7 @@ async def async_ask(question, kb_ids, tenant_id, chat_llm_name=None, search_conf
continue
yield {"answer": value, "reference": {}, "final": False}
full_answer = last_state.full_text if last_state else ""
final = decorate_answer(_extract_visible_answer(full_answer))
final = await decorate_answer(_extract_visible_answer(full_answer))
final["final"] = True
yield final