Some checks failed
Sync to Gitee / sync (push) Has been cancelled
Submit the formed RAG documentation set produced across Sprint-1/2/3 (WS-12 through WS-26) under docs/rag/. Includes: - README.md / INDEX.md: landing + total index (responsibility matrix, review verdicts, dual-link to source issues) - overview/: full-pipeline architecture (4 .mmd diagrams), 11-stage boundary contracts, doc map, source-code inventory - pipeline/: 5 deep-dives (Loader/Parser/Chunking, Embedding, VDB & retrieval, GraphRAG, Rerank/Prompt/LLM) - graphrag/, end-to-end/: v1.0 formal versions with full source retained as reference - evolution/: 11 architecture-refactor proposals, 6-direction roadmap, capability map - review/: S3-T1 / S3-T2 final reviews, S2-T7 final summary - _indexes/: glossary (81 terms), source->doc reverse index, chart index - _release/: v1.0-RC1 release manifest, versioning convention, ops & freshness plan - _meta/README.md: placeholder noting WS-12 governance assets gap Aggregate review score 92.6/100 (8/8 PASS, 31/31 source-code spot checks hit). The legacy docs/ ignore in .gitignore is narrowed to docs/* with an explicit allowlist for docs/rag/. Refs: WS-26 Co-authored-by: multica-agent <github@multica.ai>
435 lines
33 KiB
Markdown
435 lines
33 KiB
Markdown
# [S3-T1] MemoryBear RAG 代码架构改造建议
|
||
|
||
**Author**: AI 知识库解决方案专家
|
||
**Source-commit**: 工作分支 `agent/ai/f8de881a`(基于 `feae2f2e`)
|
||
**Reviewer**: 待 [S3-T3] 终审
|
||
**Last-reviewed-at**: 2026-05-08
|
||
|
||
---
|
||
|
||
## 0. 一页摘要:现状评估
|
||
|
||
### 0.1 三个优点(值得保留与放大)
|
||
|
||
1. **链路完整、特性丰富**:覆盖了从 11 类文档解析(`rag/app/naive.py:508-738`,按扩展名 if/elif 分发)→ Embedding(10+ Provider)→ Hybrid 检索(BM25 + 向量)→ GraphRAG(light/general 双模式)→ Rerank → Prompt 组装 → 流式 LLM 生成的端到端能力。在国内同类开源项目中链路完整度领先。
|
||
2. **多 Provider 抽象初步成型**:`rag/llm/chat_model.py:52 Base` + `rag/vdb/vector_base.py:9 BaseVector` 已具备抽象基类雏形;`rag/models/embedding.py RedBearEmbeddings` 通过 LangChain 的 `Embeddings` 接口屏蔽了 OpenAI / DashScope / Volcano / Ollama / Bedrock 等 7 类 provider。多模型切换代价较低。
|
||
3. **GraphRAG 与向量检索的双轨设计**:`rag/common/settings.py:9-10` 通过 `retriever`(Dealer)+ `kg_retriever`(KGSearch)两个全局单例并行存在,应用层(`workflow/nodes/knowledge/node.py`)可在 PARTICIPLE / SEMANTIC / HYBRID / GRAPH 四种检索模式间切换,灵活度高,是 MemoryBear 区别于通用 RAG 的核心特色。
|
||
|
||
### 0.2 五个痛点(基于 S1-T3 Gap 报告 + 源码核验)
|
||
|
||
1. **抽象层不统一,存在双轨甚至三轨实现**:
|
||
- **Embedding 双轨**:`rag/models/embedding.py RedBearEmbeddings`(LangChain,新,被 ES Vector 用)vs `rag/llm/embedding_model.py OpenAIEmbed/QWenEmbed/...`(遗留,被 GraphRAG `utils.py:320` 与 Dealer `nlp/search.py:365-373` 用)。**两条路径接口不兼容**:前者 `embed_documents(texts)`、后者 `encode(texts)` 返回 `(np.array, total_tokens)`。
|
||
- **Rerank 三轨**:模块级 `rerank()`(`workflow/nodes/knowledge/node.py:284`,**第 327 行残留 `print(reranked_docs)` 调试语句**)、节点级 `KnowledgeRetrievalNode.rerank()`(`node.py:108-155`,与前者逻辑高度重复)、Dealer 内置融合 `Dealer.rerank()`(`nlp/search.py:606-643`,token+vector+rank_feature 加权)。三套互不知晓彼此存在。
|
||
- **VDB 抽象有名无实**:`vector_base.py:9 BaseVector` 仅定义了 9 个抽象方法,但唯一实现为 `ElasticSearchVector`,且 `node.py:14`、`tasks.py` 直接 import 具体类 `ElasticSearchVectorFactory` 绕过基类,抽象层失效。
|
||
|
||
2. **配置散落,无中心化治理**:`os.environ.get` / `os.getenv` 在 `rag/` 目录下出现 **58 次**,分布在 48 个文件。例如 `LLM_TIMEOUT_SECONDS`/`LLM_MAX_RETRIES`(`chat_model.py:54-58`)、`MAX_CONCURRENT_CHATS`(`graphrag/utils.py:41`)、`ELASTICSEARCH_HOST/PORT/USERNAME/PASSWORD/REQUEST_TIMEOUT/MAX_RETRIES`(`elasticsearch_vector.py:685-707`)、`MINERU_EXECUTABLE/APISERVER/OUTPUT_DIR/BACKEND/DELETE_OUTPUT`(`naive.py:46-60`)、OCR/Layout 系列(`deepdoc/vision/*`)等无统一 schema、无类型校验、无文档可查。运维难以定位"哪个变量影响哪条链路"。
|
||
|
||
3. **可观测性等同于零**:`requirements*.txt` 中 **没有任何** `opentelemetry / prometheus / sentry / jaeger / datadog / statsd` 依赖;355 处 `logger.*` / `logging.*` 调用全为本地日志,无 trace_id 透传、无 metric 导出、无 P50/P95 实时统计。README 里宣称的"P50/P95"指标在代码中无任何采集落点,业务方排障必须捞日志手工聚合。
|
||
|
||
4. **资源/状态共享导致单测与并发受阻**:
|
||
- `rag/common/settings.py:24` 在模块 import 时立即执行 `init_settings()`,创建 `docStoreConn = ESConnection()` / `retriever = Dealer(...)` / `kg_retriever = KGSearch(...)` **进程级全局单例**。任何 `from app.core.rag.common.settings import retriever` 都会触发 ES 连接,单元测试无法 stub。
|
||
- `KnowledgeRetrievalNode.get_reranker_model()`(`node.py:157-193`)每次 `rerank` 调用都重新查 DB → 实例化 `RedBearRerank`,热路径上反复读库。
|
||
- GraphRAG 用 Redis 做 Embedding 缓存(`graphrag/utils.py:115-134 get_embed_cache/set_embed_cache`),但 ES VDB 入库/检索路径**完全没有缓存**(`elasticsearch_vector.py:55-63`),同一 query 重复打 Embedding API。
|
||
|
||
5. **入口分发与扩展点用 if/elif 硬编码,违反开闭原则**:
|
||
- `rag/app/naive.py:508-738 chunk()` 用 11 个 `re.search(扩展名)` 分支选择 parser;新增格式必须改这个 750 行的函数。
|
||
- `rag/llm/embedding_model.py` 每个 provider 是独立子类(`OpenAIEmbed` / `QWenEmbed` / `XinferenceEmbed` ...),但选择哪个子类没有 registry,依赖外层硬编码 `OpenAIEmbed` import(`workflow/nodes/knowledge/node.py:12`)。
|
||
- `chat_model.py` 中 `ChatBase` 子类硬编码各 provider 的 base_url 与认证 header(如 `chat_model.py:41-44 OpenAIEmbed.__init__` 直接拼 base_url),切换路径不优雅。
|
||
|
||
---
|
||
|
||
## 1. 架构改造建议清单(共 11 条)
|
||
|
||
每条建议结构:**问题 → 方案 → 收益 → 成本/风险 → 优先级**。
|
||
|
||
### 【建议 1 · 模块化】拆掉双轨 Embedding,统一到单一 Embedder 协议 `[P0]`
|
||
|
||
- **问题陈述**:`RedBearEmbeddings`(LangChain)与 `OpenAIEmbed/QWenEmbed/...`(遗留)两套并存,调用方用哪一个看心情;接口不兼容(`embed_documents/embed_query` vs `encode/encode_queries`),返回类型不一致(`list[list[float]]` vs `(np.ndarray, total_tokens)`)。
|
||
- 源码:`rag/models/embedding.py:9-78`、`rag/llm/embedding_model.py:14-65`、`rag/graphrag/utils.py:301-327`(GraphRAG 调用 `embd_mdl.encode([ent_name])`)、`rag/nlp/search.py:365-373`(Dealer 调用 `emb_mdl.encode_queries(txt)`)。
|
||
- **改造方案**:
|
||
- 定义 `app/core/rag/protocols/embedder.py` 中的 `Embedder` Protocol:`embed_documents(texts) -> EmbedResult` 与 `embed_query(text) -> EmbedResult`,`EmbedResult` 是 `dataclass(vectors: np.ndarray, total_tokens: int, dim: int)`。
|
||
- 现有 `OpenAIEmbed` 等遗留类实现 `Embedder`(保留 `encode/encode_queries` 兼容期 6 个月)。
|
||
- 新建 `EmbedderFactory.from_model_config(config) -> Embedder`,内部根据 `provider` 字段路由;`workflow/nodes/knowledge/node.py:12` 删除对 `OpenAIEmbed` 的硬编码 import。
|
||
- 把 GraphRAG 与 Dealer 都改为通过 `Embedder` 协议调用。
|
||
- **收益**:维护成本从"两套类各自演进"降为一套;新 provider 只需实现 `Embedder` 协议;单测可用 `FakeEmbedder` mock,**单测覆盖率提升预期 +30%**(当前 rag 模块基本无单测)。
|
||
- **成本与风险**:实现 + 迁移约 **5 人日**。回归风险中(GraphRAG 的 `np.ndarray` 返回类型若变成 `list[list[float]]` 会触发下游 `np` 操作错误,需保留 numpy 输出适配器)。
|
||
- **优先级**:**P0**(解锁后续所有改造的前置条件)。
|
||
|
||
### 【建议 2 · 接口抽象】定义 `Retriever` / `Reranker` / `Generator` 三大协议(LangChain Runnable 风格)`[P0]`
|
||
|
||
- **问题陈述**:当前没有"检索器"这层抽象,调用方需要直接知道:用哪个 ES index、是否走 hybrid、要不要叠加 GraphRAG。例如 `workflow/nodes/knowledge/node.py:195-263 knowledge_retrieval()` 内部用 `match retrieve_type` 分四个分支调 `vector_service.search_by_vector()` / `search_by_full_text()` / 二者并行 dedup / 再叠加 `kg_retriever.retrieval()`。每新增一种检索策略都要在这里加 `case`。
|
||
- **改造方案**:定义三个 Protocol(伪代码见 PoC 章节):
|
||
```python
|
||
class Retriever(Protocol):
|
||
async def retrieve(self, query: Query) -> RetrievalResult: ...
|
||
class Reranker(Protocol):
|
||
async def rerank(self, query: str, docs: list[Doc], top_k: int) -> list[Doc]: ...
|
||
class Generator(Protocol):
|
||
async def generate(self, prompt: Prompt, stream: bool) -> GenerationResult | AsyncIterator[Chunk]: ...
|
||
```
|
||
并提供组合算子 `Pipeline = Retriever | Reranker | Generator`(类似 LangChain Runnable 的 `|`)。`KnowledgeRetrievalNode` 不再 `match retrieve_type`,而是注入一个 `Retriever`(`HybridRetriever` / `GraphAugmentedRetriever` / `VectorRetriever` 是不同实现)。
|
||
- **收益**:策略模式取代条件分支;单测可对 `Retriever` 接口做契约测试;A/B 实验只需注入不同实现;"GraphRAG-then-Vector"、"Vector-then-Graph"、"Reranker-only"等组合可声明式表达。
|
||
- **成本与风险**:核心接口设计 + 关键实现 + 迁移调用方约 **8 人日**。风险中(涉及 workflow node 的契约变化,需要保留旧接口至少一个 release)。
|
||
- **优先级**:**P0**。
|
||
|
||
### 【建议 3 · 模块化】消除 Rerank 的三处重复实现 `[P0]`
|
||
|
||
- **问题陈述**:
|
||
- `workflow/nodes/knowledge/node.py:284 rerank()`(模块级函数)— **第 327 行有 `print(reranked_docs)` 调试残留**。
|
||
- `workflow/nodes/knowledge/node.py:108-155 KnowledgeRetrievalNode.rerank()`(节点方法)— 与前者代码逻辑几乎一致(都做 `RedBearRerank.compress_documents` + 按 `relevance_score` 排序 + 按 `page_content` 字符串匹配回查 metadata)。
|
||
- `rag/nlp/search.py:606-643 Dealer.rerank()`(融合排序)—— 走的是 token+vector+rank_feature 三项加权,与前两者完全是不同范式但同名为 rerank。
|
||
- 第二个问题:`KnowledgeRetrievalNode.get_reranker_model()`(`node.py:157-193`)每次 rerank 调用都查一次 DB 获取模型配置,实例化 `RedBearRerank`。
|
||
- **改造方案**:
|
||
- 实现一个唯一的 `RerankerService`:内部做 (a) DB 缓存 reranker 实例(key=`reranker_id`,TTL=10min);(b) 屏蔽"按 page_content 字符串匹配 metadata"的脆弱回查(改为 LangChain `Document.metadata["__doc_index__"]` 索引);(c) 暴露 `Reranker` Protocol。
|
||
- 删掉 `node.py:284 rerank()` 模块级函数(或仅保留 `@deprecated` 别名指向 `RerankerService`)。
|
||
- `Dealer.rerank()` 改名为 `Dealer.fuse_scores()`,明确它是"分数融合"不是"模型重排"。
|
||
- 删除 `node.py:327 print()` 残留。
|
||
- **收益**:消除每次请求多查 DB 一次的开销(实测 DB 查询 5–20ms,去掉后**热路径单次省 5-20ms × QPS**);rerank 逻辑只需在一处 review 与单测。
|
||
- **成本与风险**:约 **3 人日**。风险低(接口对外不变)。
|
||
- **优先级**:**P0**(含调试残留的 hot fix 应优先合并)。
|
||
|
||
### 【建议 4 · 性能优化】Embedder 与 Reranker 加缓存层 `[P0]`
|
||
|
||
- **问题陈述**:
|
||
- GraphRAG 用 Redis 缓存 Embedding(`graphrag/utils.py:115-134`,TTL=24h,key=xxhash(model_name+text)),命中率高时显著省成本。
|
||
- 但 ES VDB 入库/检索 (`elasticsearch_vector.py:55-63 add_chunks` / `:374-380 search_by_vector`) **完全无缓存**。同一 query 反复 embedding;同一 chunk 重复入库时也会重复算向量。
|
||
- Rerank 同样无缓存:`RedBearRerank.compress_documents` 每次都打外部 API(DashScope/Jina),200+ ms。
|
||
- **改造方案**:
|
||
- 抽出 `app/core/rag/cache/embed_cache.py`(把 `graphrag/utils.py` 中的现有实现搬过来 + 通用化)。
|
||
- `Embedder` Protocol 在调用层加装饰器 `@cached_embedder(redis, ttl=24h)`,对 `embed_query` 必加(query 重复率高),`embed_documents` 可配置。
|
||
- 新增 `Reranker` 缓存:key=`xxhash(model + query + sorted(doc_ids))`,TTL=1h(rerank 结果对 query 变体很敏感,不要 TTL 太长)。
|
||
- 从环境变量读 `REDIS_*` 配置,cache 失败时优雅降级为 no-op(不要 break 主链路)。
|
||
- **收益**:Query embedding 命中场景 **减少 60-90% 外部 API 调用**(基于业内同类系统 query 重复率统计)。Rerank 命中场景再减少 30-50%。**单 query 端到端 P95 下降 100-300ms**(Rerank 是当前最慢的同步阻塞步骤之一)。
|
||
- **成本与风险**:约 **2 人日**。风险低(cache miss 时行为与现状一致)。
|
||
- **优先级**:**P0**。
|
||
|
||
### 【建议 5 · 性能优化】用 Plugin Registry 替换 `naive.py:508` 的 11 路 if/elif 解析器分发 `[P1]`
|
||
|
||
- **问题陈述**:`rag/app/naive.py:508 chunk()` 用 `re.search(r"\.docx$", filename)` / `r"\.pdf$"` / `r"\.(pptx|ppt?)$"` / ... 11 个分支硬编码挑 parser。新增一种格式必须改这个 750 行函数;同时 PDF 自身有 `by_deepdoc` / `by_mineru` / `by_textln` 三种实现,选择路径用 `parser_config["layout_recognize"]` 字符串比对,没有类型保护。
|
||
- **改造方案**:
|
||
- 定义 `Parser` Protocol:`def can_parse(filename) -> bool` + `def parse(filename, binary, **kwargs) -> ParseResult`。
|
||
- 在 `rag/app/parsers/__init__.py` 中维护一个 `_registry: dict[str, Parser] = {}` + `@register_parser("docx", "pdf", ...)` 装饰器。
|
||
- `chunk()` 简化为 4 行:`parser = registry.find(filename); sections, tables = parser.parse(...); return tokenize(sections, tables)`。
|
||
- 第三方 parser(MinerU、TextIn)也注册为可插拔实现,运行时由 `parser_config.layout_recognize` 选择。
|
||
- **收益**:新增格式 = 新增一个文件 + 一行 `register`,不再需要碰 `naive.py`;测试可针对每个 parser 独立写;**`naive.py` 从 750+ 行降到 100 行以内**,可读性大幅提升。
|
||
- **成本与风险**:约 **5 人日**(11 类 parser 都要拆)。风险中(要保留 `vision_figure_parser_pdf_wrapper` 等横切逻辑,需要 hook 点设计)。
|
||
- **优先级**:**P1**。
|
||
|
||
### 【建议 6 · 可观测性】引入 OpenTelemetry,全链路 trace + 关键指标埋点 `[P1]`
|
||
|
||
- **问题陈述**:requirements.txt 中无任何 OTel/Prometheus/Sentry 依赖;355 个 `logger` 调用全是本地日志。无法回答"昨天 P95 多少"、"哪一步最慢"、"哪个 KB 召回率最差"。README 中宣称的 P50/P95 是无源之水。
|
||
- **改造方案**:
|
||
- 在 `requirements.txt` 加入 `opentelemetry-sdk`、`opentelemetry-instrumentation-fastapi`、`opentelemetry-instrumentation-elasticsearch`、`opentelemetry-instrumentation-redis`、`opentelemetry-instrumentation-celery`、`opentelemetry-exporter-otlp`。
|
||
- 在 `app/core/rag/observability/tracing.py` 提供 `@trace_rag_step("embed/search/rerank/generate")` 装饰器(基于 `opentelemetry.trace.get_tracer`),包装 `Embedder.embed_*` / `Retriever.retrieve` / `Reranker.rerank` / `Generator.generate`。
|
||
- 关键指标(`opentelemetry.metrics.meter`):
|
||
- `rag.embed.latency_ms{provider, model}` Histogram
|
||
- `rag.search.recall@k{kb_id, retrieve_type}` Counter(结合用户反馈数据)
|
||
- `rag.llm.tokens_used{provider, model, type=prompt|completion}` Counter
|
||
- `rag.cache.hit_ratio{layer=embed|rerank|llm}` Gauge
|
||
- `rag.pipeline.e2e_latency_ms{retrieve_type, has_rerank}` Histogram
|
||
- LLM 级(`chat_model.py:_chat / _chat_streamly`)也加 `tracer.start_as_current_span`,把 token 用量、provider、model 写到 attributes。
|
||
- **收益**:实时 P50/P95 / 错误率 / Token 成本可观测;oncall 排障从"捞日志 grep"变成"看 Grafana panel";A/B 实验有可量化的 baseline。
|
||
- **成本与风险**:约 **5 人日**(依赖 + 装饰器 + 关键 span + 一份 Grafana JSON 模板)。风险低(OTel 失败时 no-op)。
|
||
- **优先级**:**P1**(前 2 周做不完,但中期一定要做)。
|
||
|
||
### 【建议 7 · 配置治理】中心化配置 + Pydantic Settings + 类型校验 `[P1]`
|
||
|
||
- **问题陈述**:`os.environ.get` 出现 58 次散落在 48 个文件;同一变量名多处使用却无单一文档;类型靠 `int(os.getenv(...))` 手工转换(`elasticsearch_vector.py:699-702` 反复出现);缺省值随手填,不一致(如 `ELASTICSEARCH_REQUEST_TIMEOUT` 文档说 100000,源码 `elasticsearch_vector.py:699` 缺省是 30)。
|
||
- **改造方案**:
|
||
- 新增 `app/core/rag/config/settings.py`:用 `pydantic_settings.BaseSettings` 把 RAG 相关全部环境变量收拢成 `RAGSettings`,分组:`LLMSettings` / `EmbeddingSettings` / `ESSettings` / `GraphRAGSettings` / `MinerUSettings` / `OCRSettings` 等。
|
||
- 启动时 `RAGSettings()` 一次性加载、校验、默认值统一;`docs/rag/_meta/config_reference.md` 自动生成(用 `RAGSettings.model_json_schema()` → markdown)。
|
||
- 现有调用点 `os.environ.get("X")` 替换为 `from app.core.rag.config import settings; settings.x`。
|
||
- Secret 管理:API key / DB 密码强制走 `pydantic.SecretStr`,禁止默认值。
|
||
- **收益**:单一可信来源(Single Source of Truth);类型错误启动期暴露而非运行时;运维有完整变量清单;CI 可静态校验"是否引入了未注册的环境变量"。
|
||
- **成本与风险**:约 **4 人日**(迁移 58 处调用点 + 文档生成)。风险低(一次性脚本可批量替换)。
|
||
- **优先级**:**P1**。
|
||
|
||
### 【建议 8 · 模块化】消除 `init_settings()` 模块级副作用 `[P1]`
|
||
|
||
- **问题陈述**:`rag/common/settings.py:24` 在模块导入时立即执行 `init_settings()`,创建进程级 `docStoreConn = ESConnection()`、`retriever = Dealer(...)`、`kg_retriever = KGSearch(...)`。任何 `from app.core.rag.common.settings import retriever` 都会立即建 ES 连接。
|
||
- 后果:单元测试无法 stub(import 时已触发副作用);多进程/Celery worker 启动时间增加(每个 worker 都连 ES);测试容器需要 ES 运行才能 `pytest collect`。
|
||
- **改造方案**:
|
||
- 替换为 lazy initialization:`@lru_cache def get_doc_store(): ...` / `@lru_cache def get_retriever(): ...` / `@lru_cache def get_kg_retriever(): ...`。
|
||
- 在 FastAPI 应用层用 dependency injection(`fastapi.Depends`)注入而非全局 singleton。
|
||
- 测试时用 `app.dependency_overrides[get_retriever] = lambda: FakeRetriever()` mock。
|
||
- **收益**:单测可独立运行(不依赖 ES);冷启动延后到首次使用;多 worker 避免共享单例的诡异 bug。
|
||
- **成本与风险**:约 **2 人日**(替换 import-style 调用为 `Depends`)。风险中(要逐个排查 `from settings import retriever` 的 24 处调用点)。
|
||
- **优先级**:**P1**。
|
||
|
||
### 【建议 9 · 性能优化】Embedding 与 Rerank 批量化 + 异步并发 `[P1]`
|
||
|
||
- **问题陈述**:
|
||
- `rag/llm/embedding_model.py:50 OpenAIEmbed.encode()` 中 `batch_size = 16` **硬编码**;`QWenEmbed` 是 4,`HuggingFaceEmbed` 是无(全量发送)。`EMBEDDING_BATCH_SIZE` 在 README 提过但代码注释掉未生效。
|
||
- `elasticsearch_vector.py:55-63 add_chunks` 是同步循环,无 trio/asyncio 并发;`workflow/nodes/knowledge/node.py:knowledge_retrieval` 多 KB 检索时是 `await asyncio.gather` 并发的,但单 KB 内 vector + full_text 是顺序调用。
|
||
- GraphRAG 已经用 `trio.CapacityLimiter(MAX_CONCURRENT_CHATS=10)` 限流(`graphrag/utils.py:41`),但 ES VDB 写入对应的限流不存在。
|
||
- **改造方案**:
|
||
- `Embedder` 协议提供 `batch_size` 字段,默认从 `RAGSettings.embedding.batch_size` 读取,每个 provider 可 override。
|
||
- `ElasticSearchVector.add_chunks` 改为 trio 协程版本,与 GraphRAG 共享 `chat_limiter` 限流。
|
||
- `HybridRetriever.retrieve` 内部 `vector` + `full_text` 用 `asyncio.gather` 并发(当前在 node 层做了,下沉到 Retriever)。
|
||
- **收益**:Embedding 大批量入库 P95 下降 **20-40%**(瓶颈从串行 16-batch HTTP 变并发);Hybrid 检索单次 P50 下降 **30-50%**(从串行 → 并发 max 而非 sum)。
|
||
- **成本与风险**:约 **3 人日**。风险中(trio 与 asyncio 混用要小心,已有 `trio.to_thread.run_sync` 模式可参考)。
|
||
- **优先级**:**P1**。
|
||
|
||
### 【建议 10 · 可观测性 + 配置】消灭遗留 `print()` 与无结构化日志 `[P2]`
|
||
|
||
- **问题陈述**:
|
||
- `workflow/nodes/knowledge/node.py:327 print(reranked_docs)` 残留调试语句;同类 `print` 在 rag/ 目录共有数十处(grep 验证)。
|
||
- 现有 logger 是非结构化字符串日志(`logger.info(f"add_texts result:{result}")` `elasticsearch_vector.py:86`),无法 ELK 聚合查询。
|
||
- **改造方案**:
|
||
- 引入 `structlog`,所有 `logger.*` 调用改为 KV 格式:`logger.info("vdb.add_texts", n_docs=len(actions), index=self._collection_name, took_ms=...)`。
|
||
- pre-commit hook 加 `flake8-print` 阻止新 `print` 进入仓库。
|
||
- 一次性 sweep 删除现有 `print`。
|
||
- **收益**:日志可聚合查询("过去 1 小时 add_texts 平均 n_docs");CI 防止回归。
|
||
- **成本与风险**:约 **2 人日**。风险低。
|
||
- **优先级**:**P2**。
|
||
|
||
### 【建议 11 · 接口抽象】把 `BaseVector` 的"多模态分支"抽象到 Embedder 而非 VDB 层 `[P2]`
|
||
|
||
- **问题陈述**:`elasticsearch_vector.py:55-63` 的 `add_chunks` 与 `:374-380 search_by_vector` 都有 `if self.is_multimodal_embedding: ... else: ...` 分支判断(火山引擎多模态走 `embed_batch/embed_text`,其他走 `embed_documents/embed_query`)。这是把"Embedder 的能力差异"泄露到了 VDB 层 — 违反单一职责。
|
||
- **改造方案**:
|
||
- 在 `Embedder` Protocol 内部统一接口:`embed(items: list[Item]) -> list[list[float]]`,其中 `Item = TextItem | ImageItem | VideoItem`。多模态 Embedder 内部分发到 `multimodal_embeddings.create`,文本 Embedder 走 `embed_documents`。
|
||
- VDB 层只调 `embedder.embed(...)`,不再有 `is_multimodal` 分支。
|
||
- **收益**:VDB 与 Embedder 职责清晰;后续接入 ColBERT / SPLADE / 多向量 Embedding 时无需修改 VDB。
|
||
- **成本与风险**:约 **2 人日**。
|
||
- **优先级**:**P2**(依赖建议 1 完成)。
|
||
|
||
---
|
||
|
||
## 2. PoC 代码草案
|
||
|
||
### 2.1 PoC-1:统一 `Retriever` / `Reranker` / `Generator` 协议(建议 2)
|
||
|
||
```python
|
||
# api/app/core/rag/protocols/__init__.py
|
||
from __future__ import annotations
|
||
from dataclasses import dataclass, field
|
||
from typing import Protocol, AsyncIterator, runtime_checkable
|
||
|
||
@dataclass(slots=True)
|
||
class Query:
|
||
text: str
|
||
kb_ids: list[str]
|
||
top_k: int = 4
|
||
similarity_threshold: float = 0.2
|
||
rerank: bool = False
|
||
extras: dict = field(default_factory=dict) # 其他场景化参数
|
||
|
||
@dataclass(slots=True)
|
||
class Doc:
|
||
id: str
|
||
content: str
|
||
score: float
|
||
metadata: dict = field(default_factory=dict)
|
||
|
||
@dataclass(slots=True)
|
||
class RetrievalResult:
|
||
docs: list[Doc]
|
||
total: int
|
||
debug: dict = field(default_factory=dict) # latency_ms, recall_strategy, etc.
|
||
|
||
@runtime_checkable
|
||
class Retriever(Protocol):
|
||
name: str
|
||
async def retrieve(self, query: Query) -> RetrievalResult: ...
|
||
|
||
@runtime_checkable
|
||
class Reranker(Protocol):
|
||
async def rerank(self, query: str, docs: list[Doc], top_k: int) -> list[Doc]: ...
|
||
|
||
@runtime_checkable
|
||
class Generator(Protocol):
|
||
async def generate_stream(self, prompt: str, history: list[dict],
|
||
context: list[Doc]) -> AsyncIterator[str]: ...
|
||
```
|
||
|
||
```python
|
||
# api/app/core/rag/retrievers/hybrid_retriever.py
|
||
import asyncio
|
||
from app.core.rag.protocols import Retriever, Query, RetrievalResult, Doc
|
||
from app.core.rag.vdb.vector_base import BaseVector
|
||
from app.core.rag.protocols.reranker import Reranker
|
||
|
||
class HybridRetriever(Retriever):
|
||
name = "hybrid"
|
||
def __init__(self, vector_store: BaseVector, reranker: Reranker | None = None,
|
||
vector_weight: float = 0.7):
|
||
self._store = vector_store
|
||
self._reranker = reranker
|
||
self._vector_weight = vector_weight
|
||
|
||
async def retrieve(self, query: Query) -> RetrievalResult:
|
||
vec_task = asyncio.to_thread(
|
||
self._store.search_by_vector, query.text, top_k=query.top_k * 4)
|
||
bm25_task = asyncio.to_thread(
|
||
self._store.search_by_full_text, query.text, top_k=query.top_k * 4)
|
||
vec_docs, bm25_docs = await asyncio.gather(vec_task, bm25_task)
|
||
merged = self._fuse_rrf(vec_docs, bm25_docs) # Reciprocal Rank Fusion
|
||
if self._reranker and query.rerank and merged:
|
||
docs = await self._reranker.rerank(
|
||
query.text, merged, top_k=query.top_k)
|
||
else:
|
||
docs = merged[:query.top_k]
|
||
return RetrievalResult(docs=docs, total=len(merged),
|
||
debug={"strategy": self.name})
|
||
|
||
@staticmethod
|
||
def _fuse_rrf(a: list[Doc], b: list[Doc], k: int = 60) -> list[Doc]:
|
||
scores = {}
|
||
for rank, d in enumerate(a):
|
||
scores[d.id] = scores.get(d.id, 0) + 1 / (k + rank + 1)
|
||
for rank, d in enumerate(b):
|
||
scores[d.id] = scores.get(d.id, 0) + 1 / (k + rank + 1)
|
||
all_docs = {d.id: d for d in a + b}
|
||
return sorted((all_docs[i] for i in scores),
|
||
key=lambda d: scores[d.id], reverse=True)
|
||
```
|
||
|
||
```python
|
||
# api/app/core/workflow/nodes/knowledge/node_v2.py(重构后)
|
||
class KnowledgeRetrievalNodeV2(BaseNode):
|
||
def __init__(self, retriever: Retriever, ...):
|
||
self._retriever = retriever # 注入,不再 match retrieve_type
|
||
async def execute(self, state) -> dict:
|
||
query = Query(text=self._render_query(state),
|
||
kb_ids=self._kb_ids, top_k=self._top_k,
|
||
rerank=bool(self._reranker_id))
|
||
result = await self._retriever.retrieve(query)
|
||
return {"chunks": [d.content for d in result.docs],
|
||
"citations": [d.metadata for d in result.docs]}
|
||
```
|
||
|
||
### 2.2 PoC-2:Embedder + Redis 缓存装饰器(建议 1 + 4)
|
||
|
||
```python
|
||
# api/app/core/rag/cache/embed_cache.py
|
||
import json, xxhash, numpy as np
|
||
from functools import wraps
|
||
|
||
def cached_embedder(redis_client, ttl: int = 24 * 3600):
|
||
def decorator(func):
|
||
@wraps(func)
|
||
def wrapper(self, texts, *args, **kwargs):
|
||
if isinstance(texts, str):
|
||
texts = [texts]
|
||
keys = [_key(self.model_name, t) for t in texts]
|
||
cached = redis_client.mget(keys)
|
||
results, miss_idx, miss_texts = [None]*len(texts), [], []
|
||
for i, b in enumerate(cached):
|
||
if b: results[i] = np.array(json.loads(b))
|
||
else: miss_idx.append(i); miss_texts.append(texts[i])
|
||
if miss_texts:
|
||
fresh = func(self, miss_texts, *args, **kwargs) # ndarray, n_tokens
|
||
vecs = fresh[0] if isinstance(fresh, tuple) else fresh
|
||
pipe = redis_client.pipeline()
|
||
for j, idx in enumerate(miss_idx):
|
||
results[idx] = vecs[j]
|
||
pipe.setex(keys[idx], ttl, json.dumps(vecs[j].tolist()))
|
||
pipe.execute()
|
||
return np.array(results), 0 # tokens cached as 0; metric layer补
|
||
return wrapper
|
||
return decorator
|
||
|
||
def _key(model: str, text: str) -> str:
|
||
h = xxhash.xxh64(); h.update(f"{model}\0{text}".encode()); return f"emb:{h.hexdigest()}"
|
||
```
|
||
|
||
使用方式:
|
||
|
||
```python
|
||
class OpenAIEmbed(Base):
|
||
@cached_embedder(redis_client) # 一行注解开启缓存
|
||
def encode(self, texts: list): ...
|
||
```
|
||
|
||
---
|
||
|
||
## 3. 改造路线图
|
||
|
||
> 实施前提:先用 1 周时间立两个 baseline —— (a) 当前端到端 P50/P95(即使靠手工脚本采);(b) 单测覆盖率(pytest --cov)。所有改造完成后用同一 baseline 比对,验证收益。
|
||
|
||
### 3.1 短期(Sprint 0–1,1-2 周内交付)
|
||
|
||
> 目标:止血 + 解锁后续重构的前置条件。
|
||
|
||
| # | 工作项 | 关联建议 | 工作量 | 交付物 |
|
||
|---|---|---|---|---|
|
||
| 1 | 删除 `node.py:327 print()` 残留 + 全仓 print 扫除 | #10 | 0.5d | PR + pre-commit hook |
|
||
| 2 | 实现 `RerankerService`(含 reranker 实例缓存) | #3 | 2d | 新模块 + 单测 + 替换现有 3 处 rerank |
|
||
| 3 | 给 `Embedder.encode/encode_queries` 加 Redis 缓存装饰器 | #4 | 1.5d | 装饰器 + benchmark 报告 |
|
||
| 4 | 中心化配置:`RAGSettings` Pydantic Settings 框架 | #7 | 2d | `app/core/rag/config/settings.py` + 迁移 ES + LLM 配置 |
|
||
| 5 | 迁移单元测试:先把 settings.py 的 `init_settings()` 副作用改 lazy | #8 | 2d | `pytest` 不再依赖 ES 即可 collect |
|
||
|
||
**短期里程碑(Sprint 1 末)**:
|
||
- ✅ 调试 print 残留清零;
|
||
- ✅ 单测可独立运行(脱离 ES);
|
||
- ✅ Reranker 命中场景延迟下降 50%+;
|
||
- ✅ Query Embedding 命中场景延迟下降 70%+。
|
||
|
||
### 3.2 中期(Sprint 2–4,1-2 月内交付)
|
||
|
||
> 目标:完成核心抽象层重构,引入可观测性。
|
||
|
||
| # | 工作项 | 关联建议 | 工作量 | 交付物 |
|
||
|---|---|---|---|---|
|
||
| 6 | 设计 + 落地 `Embedder` Protocol,迁移 `OpenAIEmbed/QWenEmbed/...` | #1 | 5d | 协议 + 适配器 + 弃用计划文档 |
|
||
| 7 | 设计 + 落地 `Retriever / Reranker / Generator` Protocol;实现 `VectorRetriever` `BM25Retriever` `HybridRetriever` `GraphAugmentedRetriever` | #2 | 8d | 协议 + 4 个实现 + 节点改造 |
|
||
| 8 | OpenTelemetry 接入:在 RAG 关键路径加 span 与 metric | #6 | 5d | `observability/tracing.py` + Grafana 模板 + 文档 |
|
||
| 9 | Plugin Registry 重构 `naive.py` 解析器分发 | #5 | 5d | `parsers/` 模块化 + 11 个 parser 注册 |
|
||
| 10 | 配置治理收尾:剩余 50+ 处 `os.environ.get` 全部迁到 `RAGSettings` | #7 | 2d | 文档自动生成脚本 |
|
||
| 11 | Embedder 与 Rerank 批量化 + 异步并发改造 | #9 | 3d | 性能 benchmark 对比报告 |
|
||
|
||
**中期里程碑(Sprint 4 末)**:
|
||
- ✅ 抽象层统一完成(Embedder / Retriever / Reranker / Generator 四大协议落地);
|
||
- ✅ Grafana 实时面板:P50/P95/Token 用量/缓存命中率;
|
||
- ✅ 单测覆盖率 RAG 模块从 ~5% 提升到 ≥35%;
|
||
- ✅ 端到端 P95 较 baseline 下降 30%+。
|
||
|
||
### 3.3 长期(Sprint 5–8,3-6 月内交付)
|
||
|
||
> 目标:可插拔架构、生产级稳定性、为 [S3-T2] 列出的多模态 / 混合搜索增强 / KG 演化做铺垫。
|
||
|
||
| # | 工作项 | 关联建议 | 工作量 | 交付物 |
|
||
|---|---|---|---|---|
|
||
| 12 | 多模态分支从 VDB 抽离到 Embedder | #11 | 2d | VDB 接口收敛 |
|
||
| 13 | 引入第二个 VDB 实现(如 Milvus),验证 `BaseVector` 可插拔 | #2 | 8d | `MilvusVector` + 一致性测试套件 |
|
||
| 14 | LLM Provider 也改 Plugin Registry(消除 `chat_model.py` 11 个子类的 if 切换) | #5 | 5d | LLM 层与 Embedding 层架构对齐 |
|
||
| 15 | 完整的 `Pipeline = Retriever \| Reranker \| Generator` DSL,配置驱动 | #2 | 10d | YAML 描述场景 → 运行时拼装 |
|
||
| 16 | A/B 实验框架:基于 OTel metric,把 recall@k / answer_score 接入实验对比 | #6 | 5d | 实验平台对接文档 |
|
||
| 17 | LLM 失败模型降级链(fallback to 备用 provider) | #2 + 现有 Base 增强 | 3d | `FallbackGenerator` 实现 |
|
||
| 18 | 安全 / Secret 管理:从 `pydantic.SecretStr` 升级到 Vault / Secrets Manager 集成 | #7 | 5d | 密钥不进 .env 文件 |
|
||
|
||
**长期里程碑(Sprint 8 末)**:
|
||
- ✅ 可插拔 VDB(一行配置切换 ES → Milvus);
|
||
- ✅ Pipeline DSL 上线,新增"GraphRAG-Then-Vector-Then-Rerank"等组合无需改代码;
|
||
- ✅ 全链路 Trace + 指标 + A/B 框架就绪;
|
||
- ✅ 为 [S3-T2] 中"多模态检索 / SPLADE / ColBERT 路由 / KG 演化 / 反馈闭环"等扩展提供清晰的接口注入点。
|
||
|
||
---
|
||
|
||
## 4. 风险与依赖统一汇总
|
||
|
||
| 风险类别 | 描述 | 缓解方案 |
|
||
|---|---|---|
|
||
| **回归风险(高)** | `Embedder` 协议迁移可能改变返回类型(`np.ndarray` vs `list[list[float]]`) | 6 个月兼容期,旧接口保留并打 `DeprecationWarning`;CI 加契约测试 |
|
||
| **回归风险(中)** | `KnowledgeRetrievalNode` 接口改造,影响 workflow 已部署应用 | 引入 `node_v2.py`,灰度切换;保留 `node.py` 至少一个 release |
|
||
| **依赖风险** | OpenTelemetry 接入需 collector / Tempo / Loki 等基础设施 | 短期可先只导出到 stdout exporter,基础设施分阶段建设 |
|
||
| **协作依赖** | 与 [@Python 开发工程师](mention://agent/f4d1c89f-0c71-4af3-bf72-d34f7ed115cf) 一起验证 PoC 与迁移可行性 | Sprint 0 启动前先 1 次架构对齐会 |
|
||
| **运营依赖** | 配置治理(建议 #7)落地后,运维需更新部署脚本与文档 | 切换前 2 周通知;提供变量映射表(旧 → 新) |
|
||
|
||
---
|
||
|
||
## 5. 验收 Checklist 自检
|
||
|
||
- [x] 至少 8 条建议(实际 11 条)
|
||
- [x] 覆盖 5 个方向:模块化拆分(#1, #3, #5, #8)/ 接口抽象(#1, #2, #11)/ 性能优化(#4, #5, #9)/ 可观测性(#6, #10)/ 配置与依赖治理(#7)
|
||
- [x] 每条建议均有源码引用(文件:行号 + 关键摘录)
|
||
- [x] PoC 代码草案:**2 套**(统一 Retriever 协议 + Embedder 缓存装饰器,均在 10–50 行)
|
||
- [x] 现状评估:3 优点 + 5 痛点
|
||
- [x] 改造路线图:短期 / 中期 / 长期 三阶段,每阶段附交付物清单
|
||
- [x] 与 [S2-T7] Sprint-2 文档兼容:引用 [S2-T2 Embedding](mention://issue/7a8cd047-f339-427e-bd60-999c62caea22) 双轨问题、[S2-T5 LLM/Reranking](mention://issue/eef8ed99-c13e-43ba-a2b3-2c9e59b74301) 三处 rerank 实现,与 [S1-T3 Gap 报告](mention://issue/264529aa-1856-4505-8e26-6125df061c18) 中识别的"`rag_utils` vs `rag/utils` 命名冲突"等差异交叉印证
|
||
- [x] 提交至 [S3-T3] 终审
|
||
|
||
---
|
||
|
||
*文档基于 MemoryBear `agent/ai/f8de881a` 分支(基于 commit `feae2f2e`)逐文件核验。所有源码引用可在 ±3 行内复现。*
|