mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-04 23:00:30 +08:00
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:
@@ -180,7 +180,13 @@ class Dealer:
|
||||
else:
|
||||
matchDense = await self.get_vector(qst, emb_mdl, topk, req.get("similarity", 0.1))
|
||||
q_vec = matchDense.embedding_data
|
||||
if not settings.DOC_ENGINE_INFINITY:
|
||||
# ES path no longer fetches chunk vectors here. The clean
|
||||
# cosine score is recovered later via a second KNN-only call
|
||||
# in retrieval(); chunk vectors are fetched on demand for
|
||||
# citations (see Dealer.fetch_chunk_vectors). OceanBase
|
||||
# still relies on local rerank against chunk vectors, so
|
||||
# keep pulling them for that backend.
|
||||
if settings.DOC_ENGINE_OCEANBASE:
|
||||
src.append(f"q_{len(q_vec)}_vec")
|
||||
|
||||
fusionExpr = FusionExpr("weighted_sum", topk, {"weights": "0.05,0.95"})
|
||||
@@ -358,6 +364,113 @@ class Dealer:
|
||||
rank_fea.append(nor / np.sqrt(denor) / q_denor)
|
||||
return np.array(rank_fea) * 10. + pageranks
|
||||
|
||||
async def _knn_scores(self, sres: "Dealer.SearchResult",
|
||||
idx_names: str | list[str],
|
||||
kb_ids: list[str]) -> dict[str, float]:
|
||||
"""
|
||||
Second-pass ES call that returns the cosine similarity between the
|
||||
query embedding and each candidate chunk's embedding, filtered to the
|
||||
chunk ids the original search already surfaced. We rely on ES to do
|
||||
the vector math so the chunk vectors never leave the engine.
|
||||
"""
|
||||
if not sres.ids or not sres.query_vector:
|
||||
return {}
|
||||
dim = len(sres.query_vector)
|
||||
matchDense = MatchDenseExpr(
|
||||
f"q_{dim}_vec",
|
||||
sres.query_vector,
|
||||
"float",
|
||||
"cosine",
|
||||
len(sres.ids),
|
||||
{"similarity": 0.0},
|
||||
)
|
||||
condition = {"id": list(sres.ids)}
|
||||
res = await thread_pool_exec(
|
||||
self.dataStore.search,
|
||||
[], # no _source fields needed; we only want _id and _score
|
||||
[],
|
||||
condition,
|
||||
[matchDense],
|
||||
OrderByExpr(),
|
||||
0,
|
||||
len(sres.ids),
|
||||
idx_names,
|
||||
kb_ids,
|
||||
)
|
||||
return self.dataStore.get_scores(res)
|
||||
|
||||
async def fetch_chunk_vectors(self, chunk_ids: list[str],
|
||||
tenant_ids: str | list[str],
|
||||
kb_ids: list[str],
|
||||
dim: int) -> dict[str, list[float]]:
|
||||
"""
|
||||
Citation-time helper: fetch only the embedding vectors for an
|
||||
explicit set of chunk ids. Used by callers that need to compute
|
||||
answer-vs-chunk similarity locally (e.g. insert_citations) so the
|
||||
main retrieval path can keep skipping vector transport.
|
||||
"""
|
||||
if not chunk_ids:
|
||||
return {}
|
||||
if isinstance(tenant_ids, str):
|
||||
idx_names = [index_name(tid) for tid in tenant_ids.split(",")]
|
||||
else:
|
||||
idx_names = [index_name(tid) for tid in tenant_ids]
|
||||
vec_field = f"q_{dim}_vec"
|
||||
res = await thread_pool_exec(
|
||||
self.dataStore.search,
|
||||
[vec_field],
|
||||
[],
|
||||
{"id": list(chunk_ids)},
|
||||
[],
|
||||
OrderByExpr(),
|
||||
0,
|
||||
len(chunk_ids),
|
||||
idx_names,
|
||||
kb_ids,
|
||||
)
|
||||
fields = self.dataStore.get_fields(res, [vec_field])
|
||||
out: dict[str, list[float]] = {}
|
||||
zero = [0.0] * dim
|
||||
for cid, doc in fields.items():
|
||||
v = doc.get(vec_field)
|
||||
if isinstance(v, str):
|
||||
v = [get_float(x) for x in v.split("\t")]
|
||||
if not isinstance(v, list) or len(v) != dim:
|
||||
v = zero
|
||||
out[cid] = v
|
||||
return out
|
||||
|
||||
def rerank_with_knn(self, sres, query, knn_scores: dict[str, float],
|
||||
tkweight=0.3, vtweight=0.7,
|
||||
cfield="content_ltks",
|
||||
rank_feature: dict | None = None):
|
||||
"""
|
||||
Merge ES-side KNN cosine similarity with locally computed term
|
||||
similarity using the user-configured weights. Replaces the older
|
||||
local-only rerank() for the ES path, which depended on shipping
|
||||
chunk vectors back to the application.
|
||||
"""
|
||||
_, keywords = self.qryr.question(query)
|
||||
|
||||
for i in sres.ids:
|
||||
if isinstance(sres.field[i].get("important_kwd", []), str):
|
||||
sres.field[i]["important_kwd"] = [sres.field[i]["important_kwd"]]
|
||||
ins_tw = []
|
||||
for i in sres.ids:
|
||||
content_ltks = list(OrderedDict.fromkeys(sres.field[i][cfield].split()))
|
||||
title_tks = [t for t in sres.field[i].get("title_tks", "").split() if t]
|
||||
question_tks = [t for t in sres.field[i].get("question_tks", "").split() if t]
|
||||
important_kwd = sres.field[i].get("important_kwd", [])
|
||||
tks = content_ltks + title_tks * 2 + important_kwd * 5 + question_tks * 6
|
||||
ins_tw.append(tks)
|
||||
|
||||
tksim = np.array(self.qryr.token_similarity(keywords, ins_tw), dtype=np.float64)
|
||||
vtsim = np.array([knn_scores.get(chunk_id, 0.0) for chunk_id in sres.ids],
|
||||
dtype=np.float64)
|
||||
rank_fea = self._rank_feature_scores(rank_feature, sres)
|
||||
sim = tkweight * tksim + vtweight * vtsim + rank_fea
|
||||
return sim, tksim, vtsim
|
||||
|
||||
def rerank(self, sres, query, tkweight=0.3,
|
||||
vtweight=0.7, cfield="content_ltks",
|
||||
rank_feature: dict | None = None
|
||||
@@ -491,7 +604,8 @@ class Dealer:
|
||||
if isinstance(tenant_ids, str):
|
||||
tenant_ids = tenant_ids.split(",")
|
||||
|
||||
sres = await self.search(req, [index_name(tid) for tid in tenant_ids], kb_ids, embd_mdl, highlight,
|
||||
idx_names = [index_name(tid) for tid in tenant_ids]
|
||||
sres = await self.search(req, idx_names, kb_ids, embd_mdl, highlight,
|
||||
rank_feature=rank_feature)
|
||||
# Temporary retrieval-side guard: prune chunks whose parent document no
|
||||
# longer exists before reranking and returning results.
|
||||
@@ -516,8 +630,9 @@ class Dealer:
|
||||
sim = [s if s is not None else 0.0 for s in sim]
|
||||
tsim = sim
|
||||
vsim = sim
|
||||
else:
|
||||
# ElasticSearch doesn't normalize each way score before fusion.
|
||||
elif settings.DOC_ENGINE_OCEANBASE:
|
||||
# OceanBase still returns chunk vectors in the result; use
|
||||
# the historical local rerank that depends on them.
|
||||
sim, tsim, vsim = self.rerank(
|
||||
sres,
|
||||
question,
|
||||
@@ -525,6 +640,20 @@ class Dealer:
|
||||
vector_similarity_weight,
|
||||
rank_feature=rank_feature,
|
||||
)
|
||||
else:
|
||||
# ES path: ask ES for the clean cosine score via a second
|
||||
# KNN-only call filtered by the candidate ids, then merge it
|
||||
# with locally computed term similarity using the user's
|
||||
# weight. Chunk vectors stay in the index.
|
||||
knn_scores = await self._knn_scores(sres, idx_names, kb_ids)
|
||||
sim, tsim, vsim = self.rerank_with_knn(
|
||||
sres,
|
||||
question,
|
||||
knn_scores,
|
||||
1 - vector_similarity_weight,
|
||||
vector_similarity_weight,
|
||||
rank_feature=rank_feature,
|
||||
)
|
||||
|
||||
sim_np = np.array(sim, dtype=np.float64)
|
||||
if sim_np.size == 0:
|
||||
@@ -559,6 +688,11 @@ class Dealer:
|
||||
did = chunk.get("doc_id", "")
|
||||
|
||||
position_int = chunk.get("position_int", [])
|
||||
# Chunk vectors are no longer fetched during the main retrieval
|
||||
# call. Fall back to whatever the chunk happens to carry (Infinity
|
||||
# path) and otherwise emit a zero placeholder so the downstream
|
||||
# shape stays stable. Citation callers refill this via
|
||||
# Dealer.fetch_chunk_vectors when needed.
|
||||
d = {
|
||||
"chunk_id": id,
|
||||
"content_ltks": chunk["content_ltks"],
|
||||
|
||||
@@ -69,8 +69,7 @@ class ESConnection(ESConnectionBase):
|
||||
index=index_names,
|
||||
body=query,
|
||||
timeout="600s",
|
||||
track_total_hits=track_total_hits,
|
||||
_source=True,
|
||||
track_total_hits=track_total_hits
|
||||
)
|
||||
|
||||
def _search_with_search_after(self, index_names: list[str], query: dict, offset: int, limit: int):
|
||||
|
||||
Reference in New Issue
Block a user