Skip to content

[P1] 【Bug Request】_search_sync 每次搜索都重建并销毁 embedder,导致模型反复加载 #37

Description

@feng978744573-netizen

一、问题描述

RagSourceReader._search_sync (src/retrieve/pipeline.py:296-376) 每次收到一次搜索请求都 新建一个 embedder,搜索结束又 立即关掉,把模型引用也释放:

# pipeline.py:306
embedder: Embedder = self._embedder_factory()
warnings: list[str] = []
try:
    ...
    # pipeline.py:317 进入 CollectionSession 之前
    with CollectionSession(self._collection_name, read_only=True) as collection:
        ...
        dense = self._profile.search_similar(collection, search_query, embedder)  # 第一次用 embedder
        ...
# pipeline.py:373-376
except Exception as error:
    raise SourceReaderError("原始 RAG 检索不可用") from error
finally:
    embedder.close()  # 每次都关

LocalEmbedder.close() (src/dao/emb/embedder.py:366-368) 实现是:

def close(self) -> None:
    """释放模型引用让 GC 回收。下次 :meth:`embed_dense` 会重新加载。"""
    self._model = None  # 释放引用让 GC 回收

LocalEmbedder._ensure_model (embedder.py:330-338) 实现是:

def _ensure_model(self) -> SentenceTransformer:
    if self._model is None:
        self._model = SentenceTransformer(self._model_name)  # 30s+ 加载
    return self._model

会爆的具体场景:

  1. 每次搜索都重新加载 sentence-transformers 模型

  2. BailianEmbedder 也受影响

    • 每次新建 embedder → 新建 HTTP client(虽然 BailianEmbedder.close 只关 client,HTTP 复用只限同实例)
    • 实际影响:每次搜索多消耗一次 HTTP 握手 / keep-alive 失效
  3. _search_syncasyncio.to_thread 里跑

  4. finallyembedder.close() 隐藏原始错误

    • 如果 self._embedder_factory() 抛异常(line 306),embedder 局部变量未绑定
    • finally: embedder.close() 引用未绑定变量 → UnboundLocalError
    • 真实的 factory 异常被 UnboundLocalError 遮盖,调试时找不到根因

对比 director.fetch 等模块的 embedder 用法 —— 其他模块要么用闭包长生命周期持有 embedder,要么显式 __init__ 时建好。_search_sync 是项目里 唯一 每次调用都重建 embedder 的热路径。

二、相关配置

三、修复建议

方案 A(最小改动):让 RagSourceReader 长生命周期持有 embedder

class RagSourceReader:
    def __init__(
        self,
        ...,
        embedder_factory: Callable[[], Embedder],
        ...,
    ) -> None:
        self._embedder_factory = embedder_factory
        self._embedder: Embedder | None = None
        ...

    def _get_embedder(self) -> Embedder:
        if self._embedder is None:
            self._embedder = self._embedder_factory()
        return self._embedder

    def _search_sync(self, query, options, limit):
        embedder = self._get_embedder()
        warnings: list[str] = []
        try:
            ...
        except Exception as error:
            raise SourceReaderError("原始 RAG 检索不可用") from error
        # 删掉 finally 里的 close() —— embedder 由 reader 生命周期管理

    def close(self) -> None:
        if self._embedder is not None:
            self._embedder.close()
            self._embedder = None

方案 B:修复 finally 的 UnboundLocalError

embedder: Embedder | None = None
try:
    embedder = self._embedder_factory()
    ...
except Exception as error:
    raise SourceReaderError(...) from error
finally:
    if embedder is not None:
        embedder.close()

方案 C(根因):改 LocalEmbedder.close() 不再释放模型

def close(self) -> None:
    """只关 HTTP client / 释放句柄;模型保留在 self._model 供下次复用。"""
    # 故意不重置 _model —— LocalEmbedder 实例级缓存是设计而非泄漏
    pass

推荐 方案 A + 方案 B 同时做。方案 C 单独做也有用,但 model 持有会变成"模块级全局状态",与 embedder 工厂模式冲突。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions