docs(rag): add MemoryBear RAG implementation docs v1.0
Some checks failed
Sync to Gitee / sync (push) Has been cancelled
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>
This commit is contained in:
623
docs/rag/pipeline/01-loader-parser-chunking.md
Normal file
623
docs/rag/pipeline/01-loader-parser-chunking.md
Normal file
@@ -0,0 +1,623 @@
|
||||
---
|
||||
title: "[S2-T1] 文档加载与预处理(Loader / Parser / Chunking)实现详解"
|
||||
author: Python 开发工程师
|
||||
last-reviewed-at: 2026-05-08
|
||||
source-commit: HEAD (origin/main, MemoryBear)
|
||||
scope: api/app/core/rag/{crawler, integrations, deepdoc, nlp, models, utils, app/naive.py, common/token_utils.py}
|
||||
---
|
||||
|
||||
## 0. 一句话定位
|
||||
|
||||
把"任意来源、任意格式"的原始资料,沉淀为带元数据、可被 Embedding/索引消费的标准化 **Chunk** 序列;这一段是 RAG 召回质量的"硬天花板"——它做不好,下游再多优化都救不回来。
|
||||
|
||||
## 1. 设计目标与适用场景
|
||||
|
||||
| 目标 | 落地策略 |
|
||||
|---|---|
|
||||
| 多源接入(爬虫 / 飞书 / 语雀 / 本地文件) | `crawler/`、`integrations/feishu`、`integrations/yuque` 三套 SDK,均落到本地文件后再走统一 `chunk()` 入口 |
|
||||
| 多格式解析(PDF/Word/Excel/PPT/HTML/MD/JSON/TXT/图片/音视频) | `app/naive.py:chunk()` 单一编排入口,按扩展名分派到 `deepdoc/parser/*` 与 `app/{audio,picture}.py` |
|
||||
| 复杂 PDF 还原(表格、图、版面) | `RAGPdfParser` + OCR + 版面识别 + TSR + XGBoost 段落连接模型 |
|
||||
| 长文 Chunking 既保语义又控 token | `naive_merge` / `naive_merge_docx` / `hierarchical_merge` / `tree_merge` 多种策略,统一以 `cl100k_base` 计算 token |
|
||||
| 同一篇资料的多模态(图 + 文 + 表) | `tokenize_chunks_with_images`、`tokenize_table` 把图片/表格作为附属信息挂在 chunk 上 |
|
||||
| 健壮性 | 鉴权 token 缓存、退避重试、robots.txt 合规、编码自动嗅探、嵌入文件递归解构 |
|
||||
|
||||
适用于:私有知识库、企业文档库、技术资料归档;不适用于:实时流式数据、对端到端延迟<200ms 的场景(OCR 与版面识别是 CPU/GPU 重负载)。
|
||||
|
||||
## 2. 术语表
|
||||
|
||||
- **Section**:解析器吐出的中间结构 `(text, position_or_layout)` 元组列表,是 Chunking 之前的"原料"。
|
||||
- **Chunk**:最终交给 Embedding 的文本片段,一般 ≤ `chunk_token_num` 个 token(默认 128–512)。
|
||||
- **Token**:用 `tiktoken.cl100k_base` 编码后得到的 BPE token 数(与 OpenAI gpt-4 同口径)。
|
||||
- **Layout**:页面区块类别(title / text / figure / table / equation 等),由 YOLOv10 检测。
|
||||
- **TSR**:Table Structure Recognition,复杂表格行/列/合并单元格的结构还原。
|
||||
- **OCR**:文字检测 + 文字识别两阶段的图像字符抽取。
|
||||
- **Embed file**:内嵌在 docx/xlsx/pptx 内部的子文件(如 docx 里嵌的 Excel),需递归解析。
|
||||
|
||||
## 3. 实现概览(数据流图)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Loader["Loader / 多源接入"]
|
||||
A1[本地文件] --> CHUNK
|
||||
A2[Web 站点] --> WC[WebCrawler<br/>BFS 同域]
|
||||
A3[飞书云文档] --> FS[FeishuAPIClient<br/>导出/下载]
|
||||
A4[语雀知识库] --> YQ[YuqueAPIClient<br/>raw markdown]
|
||||
WC --> CD[CrawledDocument<br/>title+content]
|
||||
FS --> LF[本地文件]
|
||||
YQ --> LF
|
||||
CD --> CHUNK
|
||||
LF --> CHUNK
|
||||
end
|
||||
|
||||
subgraph Parser["Parser / 格式分派"]
|
||||
CHUNK[app/naive.py: chunk] --> EX[extract_embed_file<br/>嵌入文件递归]
|
||||
CHUNK -->|.pdf| PARSERS[PARSERS dict<br/>deepdoc/mineru/textln/plaintext]
|
||||
CHUNK -->|.docx| DOCX[Docx/RAGDocxParser]
|
||||
CHUNK -->|.xlsx/.csv| XLS[RAGExcelParser]
|
||||
CHUNK -->|.md| MD[Markdown/RAGMarkdownParser]
|
||||
CHUNK -->|.html| HTML[RAGHtmlParser]
|
||||
CHUNK -->|.json/.jsonl| JSON[RAGJsonParser]
|
||||
CHUNK -->|.txt/code| TXT[RAGTxtParser]
|
||||
CHUNK -->|.ppt/.pptx| LO[LibreOffice<br/>convert_to_pdf]
|
||||
CHUNK -->|.doc| TIKA[Apache Tika]
|
||||
CHUNK -->|图片/音视频| MM[picture/audio<br/>vision_llm_chunk]
|
||||
LO --> PARSERS
|
||||
PARSERS --> OCR[OCR + LayoutRecognizer + TSR]
|
||||
DOCX --> SEC[(sections)]
|
||||
XLS --> SEC
|
||||
MD --> SEC
|
||||
HTML --> SEC
|
||||
JSON --> SEC
|
||||
TXT --> SEC
|
||||
OCR --> SEC
|
||||
TIKA --> SEC
|
||||
end
|
||||
|
||||
subgraph Chunking["Chunking / 切分 + 索引化"]
|
||||
SEC --> NM{有图片?}
|
||||
NM -->|否| NM1[naive_merge]
|
||||
NM -->|是·docx| NM2[naive_merge_docx]
|
||||
NM -->|是·md| NM3[naive_merge_with_images]
|
||||
NM1 --> TC[tokenize_chunks]
|
||||
NM2 --> TCI[tokenize_chunks_with_images]
|
||||
NM3 --> TCI
|
||||
TT[tokenize_table] --> ESDOC
|
||||
TC --> ESDOC[(ES Doc<br/>content_with_weight<br/>content_ltks<br/>page_num_int<br/>position_int<br/>image)]
|
||||
TCI --> ESDOC
|
||||
end
|
||||
```
|
||||
|
||||
## 4. Loader 章节
|
||||
|
||||
### 4.1 Web Crawler(`crawler/`)
|
||||
|
||||
- **入口**:`WebCrawler(entry_url, max_pages, delay_seconds, timeout_seconds, user_agent, include_patterns, exclude_patterns)`,源码 `api/app/core/rag/crawler/web_crawler.py:19`。
|
||||
- **架构**:BFS(`deque` + `visited_urls`)+ 五个独立组件:`URLNormalizer` / `RobotsParser` / `RateLimiter` / `HTTPFetcher` / `ContentExtractor`,全部通过组合而非继承装配,便于替换。
|
||||
- **同域限制**:`URLNormalizer.is_same_domain()` 强制只爬入口域名,避免无界扩散(`url_normalizer.py:102-124`)。
|
||||
- **去重**:`URLNormalizer.normalize()` 做:小写 host、去 fragment、去默认端口、剥离 utm_*/fbclid/gclid 等追踪参数、按字母序排 query。`url_normalizer.py:28-100`。
|
||||
- **robots.txt 合规**:`RobotsParser.can_fetch()` 与 `get_crawl_delay()`,使用 stdlib 的 `urllib.robotparser`,每域名缓存。robots.txt 拉取失败时**默认允许**(permissive fallback),`robots_parser.py:60-69`。
|
||||
- **限速**:`RateLimiter` 默认 1s/请求,`set_delay()` 可被 `Crawl-delay` 动态覆盖(上限 60s 防呆);`backoff(2.0)` 用于 429/503 指数退避,`rate_limiter.py:38-58`。
|
||||
- **HTTP 重试**:`HTTPFetcher` 内置 `max_retries=3`,退避 `1s → 2s → 4s`;429 与 503 显式触发重试,404/4xx 立即返回不重试,5xx 重试到耗尽。`http_fetcher.py:54-180`。
|
||||
- **编码处理**:`HTTPFetcher._get_decoded_content` 五级回退:HTML meta charset → response.encoding(跳过 ISO-8859-1) → UTF-8 → GBK/Big5/Shift-JIS/EUC-KR 等 → latin-1 with errors='replace'。`http_fetcher.py:182-248`。
|
||||
- **正文抽取**:`ContentExtractor.extract` 基于 `lxml`:移除 `script/style/nav/header/footer/aside`,按 `<article>/<main>` → `[role=main]` → `class/id =~ content|main|article|post` → `<body>` 顺序找主体;用 `is_static_content` 检测"脚本多文本少"的 SPA 页面并直接跳过。`content_extractor.py:24-72`。
|
||||
- **错误统计**:`stats.error_breakdown` 记录每种错误类型的计数,便于事后分析。`web_crawler.py:210-215`。
|
||||
|
||||
```python
|
||||
# api/app/core/rag/crawler/web_crawler.py:103-145(节选)
|
||||
while self.url_queue and self.pages_processed < self.max_pages:
|
||||
url = self.url_queue.popleft()
|
||||
if url in self.visited_urls: continue
|
||||
self.visited_urls.add(url)
|
||||
if not self.robots_parser.can_fetch(url): # robots.txt
|
||||
self.stats['skipped'] += 1; continue
|
||||
self.rate_limiter.wait() # 限速
|
||||
fetch_result = self.http_fetcher.fetch(url) # 重试 + 退避
|
||||
if not fetch_result.success:
|
||||
self._record_error(fetch_result.error or "Unknown error"); continue
|
||||
content_type = fetch_result.headers.get('Content-Type', '').lower()
|
||||
if not any(s in content_type for s in ['text/html', 'application/xhtml+xml']):
|
||||
self.stats['skipped'] += 1; continue # 非 HTML 跳过
|
||||
extracted = self.content_extractor.extract(fetch_result.content, url)
|
||||
if not extracted.is_static:
|
||||
self.stats['skipped'] += 1; continue # JS-only 站点跳过
|
||||
```
|
||||
|
||||
### 4.2 飞书集成(`integrations/feishu/`)
|
||||
|
||||
- **入口**:`FeishuAPIClient(app_id, app_secret, api_base_url, timeout, max_retries)`,`integrations/feishu/client.py:24`,是异步客户端(`httpx.AsyncClient`),用 `async with` 管理生命周期。
|
||||
- **鉴权**:`tenant_access_token` 模式,`get_tenant_access_token()` 用 `cachetools.TTLCache(maxsize=1, ttl=7200-300)` 缓存(飞书原生 2 小时有效,提前 5 分钟失效)+ `asyncio.Lock` 双检锁防并发请求 token。`client.py:51-127`。
|
||||
- **文件类型分派**:`download_document` 按 `document.type` 分两条路径:
|
||||
- **在线文档(doc/docx/sheet/bitable)**:`_export_file` 走"创建导出任务 → 轮询 ticket → 下载 file_token"三步,最多轮询 10 次、间隔 2s,超时抛 `FeishuAPIError`。`client.py:311-406`。
|
||||
- **附件文件(file/slides)**:`_download_file` 直接 GET `/drive/v1/files/{token}/download`,从 `Content-Disposition` 解析 `filename*=UTF-8''xxx` 编码文件名。`client.py:408-452`。
|
||||
- **限流与重试**:装饰器 `@with_retry`(`feishu/retry.py:124-137`)。`RetryStrategy.RETRYABLE_ERRORS = (FeishuNetworkError, FeishuRateLimitError, httpx.TimeoutException/ConnectError/ReadError)`,`MAX_RETRIES=3`,退避 `[1, 2, 4]s`;HTTP 429/502/503/5xx 重试,4xx(除 429)不重试;飞书业务码 `99991400/99991401`(限流码)也强制重试。`feishu/retry.py:24-76`。
|
||||
- **错误模型**:精细化异常树 `FeishuAuthError / FeishuAPIError / FeishuNotFoundError / FeishuPermissionError / FeishuRateLimitError / FeishuNetworkError / FeishuDataError`,调用方据此决定告警级别。`feishu/exceptions.py:1-46`。
|
||||
- **分页与递归**:`list_folder_files` 单页(page_size=200);`list_all_folder_files(recursive=True)` 自动展开子文件夹。`client.py:226-269`。
|
||||
|
||||
```python
|
||||
# api/app/core/rag/integrations/feishu/client.py:78-120(鉴权 + 双检锁缓存)
|
||||
cached_token = self._token_cache.get("access_token")
|
||||
if cached_token: return cached_token
|
||||
async with self._token_lock:
|
||||
cached_token = self._token_cache.get("access_token")
|
||||
if cached_token: return cached_token
|
||||
response = await self._http_client.post(
|
||||
"/auth/v3/tenant_access_token/internal",
|
||||
json={"app_id": self.app_id, "app_secret": self.app_secret})
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise FeishuAuthError(f"Authentication failed: {data.get('msg')}",
|
||||
error_code=str(data.get("code")), details=data)
|
||||
token = data.get("tenant_access_token")
|
||||
self._token_cache["access_token"] = token
|
||||
return token
|
||||
```
|
||||
|
||||
### 4.3 语雀集成(`integrations/yuque/`)
|
||||
|
||||
- **入口**:`YuqueAPIClient(user_id, token, api_base_url, timeout, max_retries)`,`integrations/yuque/client.py:27`。
|
||||
- **鉴权**:个人 PAT,HTTP header `X-Auth-Token`,无需 OAuth/token 刷新(语雀的 token 是长期 token),故没有 token 缓存层。`client.py:55-66`。
|
||||
- **API 三段式**:`get_user_repos()` → `get_repo_docs(book_id)` → `get_doc_detail(id, raw=1)`;`get_doc_detail` 用 `params={"raw": 1}` 拉原始 markdown。`client.py:119-291`。
|
||||
- **格式分派(download_document)**:根据 `doc.format` 决定本地文件后缀:
|
||||
- `markdown` / `lake` → `.md`(lake 也按 markdown 保存,因为 lake 在 raw 模式下输出兼容 md)
|
||||
- `html` → `.html`
|
||||
- `lakesheet` → `.xlsx`,需 `zlib.decompress(bytes(sheet_data, 'latin-1'))` 解压后由 `generate_excel_from_sheet` 用 openpyxl 重建工作簿(含字体、对齐、颜色、合并单元格)。`client.py:293-545`。
|
||||
- **限流与重试**:与飞书同构,`yuque/retry.py:21-118`,`RetryStrategy` 配置一致;HTTP 状态码 401→`YuqueAuthError`、403→`YuquePermissionError`、404→`YuqueNotFoundError`、429→`YuqueRateLimitError`,由 `_handle_api_error` 统一翻译。`client.py:73-117`。
|
||||
- **健壮性**:`get_user_repos`/`get_repo_docs` 对单条数据 `try/except` 跳过坏记录而不整体失败(容忍语雀 schema 漂移)。`client.py:158-160, 221-223`。
|
||||
|
||||
### 4.4 本地文件(`app/naive.py:chunk`)
|
||||
|
||||
- 是所有 Loader 的最终汇入口;接收 `filename` 与 `binary` 两种入参,二者互斥(推荐 `binary`,源码内 `extract_embed_file` 显式不支持 path 模式,详见 `app/naive.py:541`)。
|
||||
- **嵌入文件递归**:根调用(`is_root=True`)会先用 `extract_embed_file()` 抽出 docx/xlsx/pptx 内部嵌入的子文件(通过 zip 名单 `word/embeddings/`、`xl/embeddings/`、`ppt/embeddings/` 或 OLE 容器的 `Ole10Native`),逐个递归 `chunk()`,结果合入 `embed_res`。`utils/file_utils.py:69-130` + `app/naive.py:533-552`。
|
||||
- **超链接深挖**:`parser_config.analyze_hyperlink=True` 时,docx/pdf 内部超链接经 `extract_links_from_docx` / `extract_links_from_pdf` 抽出后,每条 URL 调用 `extract_html` 拉回 HTML 二进制并递归 `chunk(url, html_bytes, is_root=False)`。`app/naive.py:556-566, 793-803`。
|
||||
- **callback 进度上报**:`chunk(..., callback=progress_callback)`,约定 `callback(prog: float, msg: str)`,关键节点:0.05(嵌入抽取)/ 0.1(开始解析)/ 0.6(OCR 完)/ 0.63(版面)/ 0.65(表格)/ 0.67(合并)/ 0.8(解析完)。
|
||||
|
||||
## 5. Parser 章节
|
||||
|
||||
### 5.1 总分派器(`app/naive.py`)
|
||||
|
||||
`chunk()` 是入口,按文件扩展名走分支:
|
||||
|
||||
```python
|
||||
# api/app/core/rag/app/naive.py:97-102
|
||||
PARSERS = {
|
||||
"deepdoc": by_deepdoc,
|
||||
"mineru": by_mineru,
|
||||
"textln": by_textln,
|
||||
"plaintext": by_plaintext, # default
|
||||
}
|
||||
# api/app/core/rag/app/naive.py:553-764
|
||||
if re.search(r"\.docx$", filename, re.IGNORECASE): ...
|
||||
elif re.search(r"\.pdf$", filename, re.IGNORECASE): ... # 走 PARSERS dict
|
||||
elif re.search(r"\.(pptx|ppt?)$", ...): ... # LibreOffice → pdf
|
||||
elif re.search(r"\.(da|wav|mp3|...)$", ...): ... # app/audio.py
|
||||
elif re.search(r"\.(png|jpeg|...)$", ...): ... # app/picture.py
|
||||
elif re.search(r"\.(csv|xlsx?)$", ...): ExcelParser
|
||||
elif re.search(r"\.(txt|py|js|java|...)$", ...): TxtParser
|
||||
elif re.search(r"\.(md|markdown)$", ...): Markdown(MarkdownParser 子类)
|
||||
elif re.search(r"\.(htm|html)$", ...): HtmlParser
|
||||
elif re.search(r"\.(json|jsonl|ldjson)$", ...): JsonParser
|
||||
elif re.search(r"\.doc$", ...): tika # Apache Tika via JVM
|
||||
```
|
||||
|
||||
PDF 的 `parser_config.layout_recognize` 决定底层走哪条 PDF 引擎,默认 `DeepDOC`:
|
||||
|
||||
| layout_recognize | 引擎 | 调用 | 适用 |
|
||||
|---|---|---|---|
|
||||
| `DeepDOC` | `Pdf(RAGPdfParser)` | `by_deepdoc` | 复杂版面、扫描件 |
|
||||
| `Plain Text` | `PlainParser` | `by_plaintext` | 纯文本 PDF,速度快 |
|
||||
| `MinerU` | `MinerUParser` | `by_mineru` | 高质量结构化(外部进程或 HTTP) |
|
||||
| `TextLn` | `TextLnParser` | `by_textln` | TextIn API(云端付费) |
|
||||
| 任意(含 `vision_model`) | `VisionParser` | `by_plaintext` 分支 | 多模态 LLM 直读 |
|
||||
|
||||
### 5.2 PDF 解析(`deepdoc/parser/pdf_parser.py`,1387 行)
|
||||
|
||||
`RAGPdfParser` 是大头,调用栈:
|
||||
|
||||
```python
|
||||
# api/app/core/rag/app/naive.py:373-412 (Pdf.__call__ 节选)
|
||||
self.__images__(filename if not binary else binary, zoomin, from_page, to_page, callback)
|
||||
callback(0.6, f"OCR finished")
|
||||
self._layouts_rec(zoomin) # 版面识别
|
||||
callback(0.63, "Layout analysis")
|
||||
self._table_transformer_job(zoomin) # TSR
|
||||
callback(0.65, "Table analysis")
|
||||
self._text_merge(zoomin=zoomin) # 文本合并
|
||||
self._extract_table_figure(...) # 提取表与图
|
||||
self._naive_vertical_merge()
|
||||
self._concat_downward() # XGBoost 段落连接(updown_concat_xgb)
|
||||
self._final_reading_order_merge()
|
||||
return [(b["text"], self._line_tag(b, zoomin)) for b in self.boxes], tbls
|
||||
```
|
||||
|
||||
要点:
|
||||
- **OCR**:`OCR()`(`deepdoc/vision/ocr.py:522`)= `TextDetector` + `TextRecognizer` 组合;`pdfplumber` 把每一页 `to_image(resolution=72*zoomin=216).annotated`,再过 OCR。`pdf_parser.py:1006-1122`。
|
||||
- **版面识别**:`LayoutRecognizer4YOLOv10`(默认,10 个 label:title / Text / Reference / Figure / Figure caption / Table / Table caption / Equation 等),或 `AscendLayoutRecognizer`(Ascend NPU),由 `LAYOUT_RECOGNIZER_TYPE` 环境变量切换。`pdf_parser.py:53-67` + `vision/layout_recognizer.py:147-160`。
|
||||
- **表格结构识别**:`TableStructureRecognizer`(`vision/table_structure_recognizer.py`),裁出 table 区域后把行/列重组成 HTML;与 docx 的"按上下文找最近标题"风格一致。`pdf_parser.py:178-220`。
|
||||
- **段落连接模型**:`updown_cnt_mdl`(XGBoost),输入是上下相邻两块的 31 维特征(句末是否标点、x0 距离、行内 token 数、字号差、layout_type 等),决定要不要把下一块续到上一块。`pdf_parser.py:113-156` + `pdf_parser.py:70-83`(模型从 HuggingFace `InfiniFlow/text_concat_xgb_v1.0` 拉)。
|
||||
- **位置标签**:每个文本块带 `@@<page>\t<x0>\t<x1>\t<top>\t<bottom>##` 的位置 tag,`remove_tag()` 用 `re.sub(r"@@[\t0-9.-]+?##", "", txt)` 去掉,`extract_positions()` 反解。`pdf_parser.py:1219-1229`。
|
||||
- **GPU 加速**:通过 `pip_install_torch()` + `torch.cuda.is_available()` 把 XGBoost 推到 CUDA;`PARALLEL_DEVICES > 1` 时用 `trio.CapacityLimiter` 做多卡并行。`pdf_parser.py:50-77, 1095-1106`。
|
||||
- **HuggingFace 模型分发**:`InfiniFlow/text_concat_xgb_v1.0` 通过 `snapshot_download` 拉到 `res/` 目录;推荐 `export HF_ENDPOINT=https://hf-mirror.com` 解决国内拉取慢,`deepdoc/README.md:42`。
|
||||
|
||||
#### 5.2.1 备选 PDF 引擎
|
||||
|
||||
- **`PlainParser`**(`pdf_parser.py:1300`):`pypdf.PdfReader` 直接 `extract_text()`,每行一段 + 解析 outline 目录;无 OCR、无版面、无图,纯文本极快。
|
||||
- **`VisionParser`**(`pdf_parser.py:1334`):把每一页转成 PIL.Image,整页直接喂给 `vision_model`(`QWenCV` / `AzureGptV4` 等),让多模态 LLM "看图说话"产出 markdown。`@@page\tx0\tx1\ty0\ty1##` 位置 tag 由 `(0, 0, width/zoomin, 0, height/zoomin)` 占位生成(即整页矩形),方便下游对齐 chunk 与原图。
|
||||
- **`MinerUParser`**(`mineru_parser.py:41`):调用外部 `mineru` 进程(CLI 模式)或 `MINERU_APISERVER`(HTTP 模式,默认 `host.docker.internal:9987`),后端可选 `pipeline / vlm-http-client / vlm-transformers / vlm-vllm-engine`;输出 zip 解压后融合为 sections + tables。`naive.py:45-62`。
|
||||
- **`TextLnParser`**(`app/textin_parser.py`):合合 TextIn 云端 PDF→Markdown 服务,需要 `TEXTLN_APP_ID/SECRET_CODE`。
|
||||
|
||||
### 5.3 Word 解析(`deepdoc/parser/docx_parser.py` + `naive.py:Docx`)
|
||||
|
||||
两层:
|
||||
|
||||
- **底层 `RAGDocxParser`**(`docx_parser.py:9-123`):`python-docx`+`pandas` 读段落与表格;表格内容经 `__compose_table_content` 做"列类型推断"(日期 Dt / 数字 Nu / 中文人名 Nr / 英文 En 等 11 类正则),自动识别多行表头并把单元格拼成 `表头:值` 格式,保证表格在 chunk 中也能被关键词检索。
|
||||
- **上层 `Docx(RAGDocxParser)`**(`naive.py:105-323`):把段落里的图片用 `python-docx` 的 `xpath('.//pic:pic')` 抽出,挂到对应 paragraph;表格区域用 `__get_nearest_title` 上溯到 7 级标题构造层级路径作为 `<caption>Table Location: A > B > C</caption>`,这是检索时定位表格上下文的关键。
|
||||
- **超链接抽取**:`extract_links_from_docx` 遍历 `document.part.rels`,过滤 reltype 为 hyperlink 的关系,得到链接集合。`utils/file_utils.py:133-154`。
|
||||
- **`to_markdown`**:可选回退路径,使用 `mammoth.convert_to_html` + `markdownify`,图片嵌成 `data:` base64 URL。`naive.py:325-366`。
|
||||
- **NULL 关系修复**:上层 `Docx` 用 `load_from_xml_v2` monkey-patch 掉 `_SerializedRelationships.load_from_xml`,跳过 `../NULL` 与 `NULL` target 以绕过 python-docx#1105 已知 bug。`naive.py:493-506, 569`。
|
||||
|
||||
### 5.4 Excel/CSV 解析(`deepdoc/parser/excel_parser.py`)
|
||||
|
||||
- **多引擎容错**:`_load_excel_to_workbook` 先看魔数:`PK\x03\x04`(OOXML)或 `\xd0\xcf\x11\xe0`(OLE2)。openpyxl 失败回退 `pandas.read_excel`,再失败回退 `engine="calamine"`;非 Excel 头则当 CSV 处理(`pd.read_csv(on_bad_lines='skip')` 容忍坏行)。`excel_parser.py:18-53`。
|
||||
- **非法字符清洗**:`ILLEGAL_CHARACTERS_RE = re.compile(r"[\000-\010]|[\013-\014]|[\016-\037]")`,`_clean_dataframe` 把所有字符串单元格里的控制字符替换成空格,避免写入 Workbook 报错。`excel_parser.py:13, 56-62`。
|
||||
- **三种输出形态**:
|
||||
- `__call__()`:每行 → `表头1:值1\n表头2:值2\n...\n——SheetName`,作为一个 section(一个 chunk)。`excel_parser.py:203-246`。
|
||||
- `html()`:每 256 行打包成一张 `<table>`,header 复用,便于检索时整块召回。`excel_parser.py:144-187`。
|
||||
- `markdown()`:`df.to_markdown(index=False)`,整个表一段。
|
||||
- **图片抽取**:`_extract_images_from_worksheet` 通过 `ws._images` 的 anchor.row/col 还原图片位置,输出 `single_cell` / `multi_cell` span_type 元数据。`excel_parser.py:98-142`。
|
||||
- **重要:Excel 不走 `naive_merge`**:`naive.py:678-680` 显式注释"Excel 每行直接作为一个 chunk,不经过 naive_merge 避免被 delimiter 拆分"——直接 `tokenize_chunks(chunks, ...)`。
|
||||
|
||||
### 5.5 Markdown 解析(`deepdoc/parser/markdown_parser.py`)
|
||||
|
||||
- **表格抽取**:`extract_tables_and_remainder` 用三个正则按顺序剥离:标准 GFM 边框表格 → 无边框表格 → HTML `<table>...</table>`(含 `<html><body>` 包装),每张表单独成一个 chunk,剩余正文继续走 element 抽取。`markdown_parser.py:10-106`。
|
||||
- **元素抽取**:`MarkdownElementExtractor.extract_elements(delimiter)` 按行扫描,识别 `header(#~######)` / `code_block(```)` / `list_block(-/*/+/数字.)` / `blockquote(>)` / `text_block`,每种元素用对应私有方法收集起止行号。`markdown_parser.py:109-277`。
|
||||
- **图片嵌入**:当传入 `vision_model` 时,naive.py 会对每个 section 调 `markdown_parser.get_pictures()`(HTTP 下载或本地路径打开),把图片合并 `concat_img` 后丢给 `VisionFigureParser` 让 LLM 描述图片,描述文本拼回 section 末尾。`naive.py:697-709`。
|
||||
- **超链接深挖**:`get_hyperlink_urls(soup)` + `analyze_hyperlink=True` 触发递归 chunk。`naive.py:716-720`。
|
||||
|
||||
### 5.6 HTML 解析(`deepdoc/parser/html_parser.py`)
|
||||
|
||||
- **预清洗**:BeautifulSoup html5lib,移除 `<style>/<script>`,剥离 inline `style` 属性与 HTML 注释。`html_parser.py:39-52`。
|
||||
- **递归读文本**:`read_text_recursively` 给每个 BLOCK_TAG(h1-h6/p/div/article/section/aside/ul/ol/li/table/pre/code/blockquote/figure/figcaption)分配 `block_id` UUID,把 NavigableString 收集到所属 block。`<table>` 整段保留,单独给 `table_id` 元数据。`html_parser.py:89-131`。
|
||||
- **标题前缀化**:`merge_block_text` 在拼接时把 `h1-h6` 改写为 `# ~ ######`(Markdown 风格),保留层级语义到下游。`html_parser.py:134-161`。
|
||||
- **二次切分**:`chunk_block(block_txt_list, chunk_token_num=512)` 按 `chunk_token_num`(默认 512)合并 block,超长 block 用 `rag_tokenizer.tokenize()` 切成等长片段。`html_parser.py:163-196`。
|
||||
|
||||
### 5.7 JSON 解析(`deepdoc/parser/json_parser.py`)
|
||||
|
||||
- **结构感知切分**:`_json_split` 递归遍历 dict,按 `_json_size`(即 `json.dumps(...)` 的字符长度,乘以 2 作为 max_chunk_size)累计;超过 max 但当前 chunk ≥ min(`max - 200`)时开新 chunk,否则继续递归到子节点。**关键设计**:list 通过 `_list_to_dict_preprocessing` 转成索引化 dict,让数组也能按结构切分。`json_parser.py:46-95`。
|
||||
- **JSONL 自动检测**:`is_jsonl_format` 抽样前 10 行,若 ≥ 80% 行单独 `json.loads` 成功且整体不能 parse 为单个 JSON,则按 JSONL 处理。`json_parser.py:134-152`。
|
||||
|
||||
### 5.8 TXT/代码 解析(`deepdoc/parser/txt_parser.py`)
|
||||
|
||||
- 简单版:`get_text` 读全文(`find_codec` 嗅探编码),按 `delimiter="\n!?;。;!?"` 切分,**就地累加 token**:当前 chunk 超 `chunk_token_num` 才开新 chunk。`txt_parser.py:8-48`。
|
||||
- 适配的扩展名集:`.txt|.py|.js|.java|.c|.cpp|.h|.php|.go|.ts|.sh|.cs|.kt|.sql`,`naive.py:685`。
|
||||
|
||||
### 5.9 图片/音视频(`app/picture.py` / `app/audio.py`)
|
||||
|
||||
- 图片:`from app.core.rag.app.picture import chunk` → `picture_vision_llm_chunk(binary, vision_model, prompt, callback)`,多模态 LLM 直接产文。
|
||||
- 音视频:`from app.core.rag.app.audio import chunk` → 调 `seq2txt_mdl`(`QWenSeq2txt` 即 `qwen3-omni-flash`)做语音转文字。
|
||||
- PDF 也可以走 VisionParser 让 LLM 整页"看图说话",是 OCR 失败/扫描件的兜底。
|
||||
|
||||
### 5.10 PPT 与 .doc:外部依赖
|
||||
|
||||
- **PPTX/PPT** → `naive.py:628-651`:调 `async_convert_to_pdf`(`utils/libre_office.py:59-62`)把文件转 PDF,再递归 `chunk(dest_pdf_path, ...)`。
|
||||
- LibreOffice 路径硬编码 `/usr/bin/soffice`(Linux)或 `/Applications/LibreOffice.app/Contents/MacOS/soffice`(macOS),都不存在则抛 `HTTP 500`;`subprocess.run` 设 `timeout=120s` 防卡死。`utils/libre_office.py:11-57`。
|
||||
- 用 `ThreadPoolExecutor(max_workers=os.cpu_count()*2)` 提交异步转换任务;同进程多请求共享线程池。
|
||||
- **DOC(旧版二进制)** → `naive.py:738-761`:使用 Apache Tika(`tika-server.jar` JVM 进程,端口 9998)。环境必须有 Java 11+;初始化 `tika.initVM()` 后 `tika_parser.from_file(filename)['content']` 按 `\n` 切分。
|
||||
|
||||
### 5.11 视觉子系统(`deepdoc/vision/`)
|
||||
|
||||
- **OCR**:`OCR.__call__(img, device_id, cls)` 内部跑 `TextDetector` 检测文字框 → `TextRecognizer` 识别字符 → 可选方向分类。`vision/ocr.py:522, 694`。模型走 ONNX。
|
||||
- **LayoutRecognizer4YOLOv10**:YOLOv10 ONNX 模型,10 类 label,在 `__call__(image_list, ocr_res, scale_factor=3, thr=0.2, batch_size=16, drop=True)` 中接收图像与 OCR 结果,输出每个文字框的 layout 类型并把 `header/footer` 等 drop 掉。
|
||||
- **TableStructureRecognizer**:检测表格单元格的列、行、列头、合并单元格等 5 类。
|
||||
- **VisionFigureParser**(`figure_parser.py:52-118`):用 `ThreadPoolExecutor(10)` 并发把每张图片喂给 `vision_model`,超时 30s(`@timeout(30, 3)` 装饰器表示 30s 超时、3 次重试)。`vision_llm_figure_describe_prompt()` 给出统一的"详细描述这张图"指令。
|
||||
|
||||
## 6. Chunking 章节
|
||||
|
||||
### 6.1 Token 计数(`common/token_utils.py`)
|
||||
|
||||
- 模型固定为 `tiktoken.cl100k_base`(GPT-4 / text-embedding-ada-002 同口径),缓存目录 `res/`。`token_utils.py:6-9`。
|
||||
- `num_tokens_from_string(s)` 容错:`encode` 失败返回 0(不会让上层报错)。`token_utils.py:12-18`。
|
||||
- `truncate(s, max_len)` 按 token 截断,保护 LLM 上下文。`token_utils.py:56-58`。
|
||||
|
||||
### 6.2 编码嗅探(`nlp/__init__.py:37-55`)
|
||||
|
||||
- `find_codec(blob)`:先 `chardet.detect(blob[:1024])` 置信度 > 0.5 用结果("ascii" 强制升级到 "utf-8",避开 chardet 经典误判);置信度低则按预设 80+ 编码顺序列表逐个 `decode` 尝试,全失败 fallback `utf-8`。
|
||||
|
||||
### 6.3 主切分函数 `naive_merge`(`nlp/__init__.py:562-606`)
|
||||
|
||||
```python
|
||||
# api/app/core/rag/nlp/__init__.py:562-606(核心算法)
|
||||
def naive_merge(sections, chunk_token_num=128, delimiter="\n。;!?", overlapped_percent=0):
|
||||
if isinstance(sections, str): sections = [sections]
|
||||
if isinstance(sections[0], str): sections = [(s, "") for s in sections]
|
||||
cks, tk_nums = [""], [0]
|
||||
def add_chunk(t, pos):
|
||||
nonlocal cks, tk_nums
|
||||
tnum = num_tokens_from_string(t)
|
||||
if tnum < 8: pos = "" # 太短不挂位置
|
||||
if cks[-1] == "" or tk_nums[-1] > chunk_token_num * (100 - overlapped_percent)/100.:
|
||||
# 开新 chunk,按 overlapped_percent 从上一块尾部留滑窗
|
||||
overlapped = RAGPdfParser.remove_tag(cks[-1])
|
||||
t = overlapped[int(len(overlapped)*(100-overlapped_percent)/100.):] + t
|
||||
if t.find(pos) < 0: t += pos
|
||||
cks.append(t); tk_nums.append(tnum)
|
||||
else:
|
||||
if cks[-1].find(pos) < 0: t += pos
|
||||
cks[-1] += t; tk_nums[-1] += tnum
|
||||
dels = get_delimiters(delimiter)
|
||||
for sec, pos in sections:
|
||||
if num_tokens_from_string(sec) < chunk_token_num:
|
||||
add_chunk("\n"+sec, pos); continue
|
||||
for sub_sec in re.split(r"(%s)" % dels, sec, flags=re.DOTALL):
|
||||
if re.match(f"^{dels}$", sub_sec): continue
|
||||
add_chunk("\n"+sub_sec, pos)
|
||||
return cks
|
||||
```
|
||||
|
||||
要点:
|
||||
- **token 上限**:当 `tk_nums[-1] > chunk_token_num * (1 - overlapped_percent/100)` 时开新 chunk。这意味着 `overlapped_percent=0` → 严格不超;`>0` → 提前开新块以预留滑窗空间。
|
||||
- **滑动窗口**:开新 chunk 时把上一块尾部 `overlapped_percent%` 的字符(**不是 token**)拼到新块开头;用 `RAGPdfParser.remove_tag` 先剥离位置标签,避免位置 tag 漏到新块。
|
||||
- **delimiter**:默认 `"\n。;!?"`,可被 `parser_config.delimiter` 覆盖。`get_delimiters` 支持反引号包围的多字符分隔符(如 `` `\n\n` ``),并按长度降序优先匹配(避免短符号"吞掉"长符号的左边界)。`nlp/__init__.py:760-776`。
|
||||
- **位置 tag 注入**:每段 `pos` 串只在 chunk 内不存在时才追加,避免重复(PDF chunk 一段往往跨多页,位置 tag 自然多次出现)。
|
||||
- **长 section 二次拆分**:单段 token 数 ≥ chunk_token_num 才用 delimiter 切,否则整段加入。
|
||||
|
||||
### 6.4 带图变体
|
||||
|
||||
- **`naive_merge_docx`**(`nlp/__init__.py:706-752`):sections 是 `[(text, image), ...]`;无图段先累积成行 line,遇到带图段才触发切分;同一 chunk 内多图用 `concat_img` 上下拼接成一张大图。**没有 overlapped_percent**。
|
||||
- **`naive_merge_with_images`**(`nlp/__init__.py:609-662`):与 `naive_merge` 同构,但同步把每段对应的 image 累积到 `result_images` 数组(多图也走 `concat_img` 合并)。
|
||||
|
||||
### 6.5 标题树切分(结构感知)
|
||||
|
||||
- **`hierarchical_merge(bull, sections, depth)`**(`nlp/__init__.py:471-559`):用 `BULLET_PATTERN[bull]`(5 套样式:第一/二/三章节系列、英文 PART/Chapter、Markdown # 系列)匹配标题,按层级建组,每组内累计 token 不超过 218 就合并到一个 chunk。是 manual.py / paper.py 等"标准化文档"app 用的策略。
|
||||
- **`tree_merge(bull, sections, depth)`**(`nlp/__init__.py:423-469`):同样基于 BULLET_PATTERN,但建一棵 `Node` 标题树,深度优先生成 chunk,让父级标题路径自动出现在每个 chunk 头部(`title1\ntitle2\nbody`)。
|
||||
- 这两个函数 **不在 `app/naive.py` 主链路**调用——naive.py 用的是 `naive_merge` 系列;它们服务于 `app/manual.py`、`app/paper.py`、`app/laws.py`、`app/book.py` 等专业 app。
|
||||
|
||||
### 6.6 关键词处理(`tokenize` / `tokenize_chunks` / `tokenize_table`)
|
||||
|
||||
最终交给 ES 的不是裸文本 chunk,而是带"分词字段"的 doc:
|
||||
|
||||
```python
|
||||
# api/app/core/rag/nlp/__init__.py:251-256
|
||||
def tokenize(d, t, eng):
|
||||
d["content_with_weight"] = t
|
||||
t = re.sub(r"</?(table|td|caption|tr|th)( [^<>]{0,12})?>", " ", t)
|
||||
d["content_ltks"] = rag_tokenizer.tokenize(t) # 粗粒度分词
|
||||
d["content_sm_ltks"] = rag_tokenizer.fine_grained_tokenize(d["content_ltks"]) # 细粒度
|
||||
```
|
||||
|
||||
```python
|
||||
# api/app/core/rag/nlp/__init__.py:258-277(tokenize_chunks)
|
||||
for ii, ck in enumerate(chunks):
|
||||
d = copy.deepcopy(doc) # doc 含 docnm_kwd / title_tks / title_sm_tks
|
||||
if pdf_parser: # 仅 PDF 链路
|
||||
d["image"], poss = pdf_parser.crop(ck, need_position=True)
|
||||
add_positions(d, poss)
|
||||
ck = pdf_parser.remove_tag(ck)
|
||||
else:
|
||||
add_positions(d, [[ii]*5]) # 无位置时填占位
|
||||
tokenize(d, ck, eng)
|
||||
res.append(d)
|
||||
```
|
||||
|
||||
- `add_positions(d, poss)` 写入 `page_num_int / position_int / top_int` 三列(`int` 后缀是 ES 的 type hint)。`nlp/__init__.py:325-337`。
|
||||
- `tokenize_table(tbls, doc, eng, batch_size=10)` 每 10 行表格组装成一个 chunk,挂图(如有)时 `doc_type_kwd="image"`。`nlp/__init__.py:295-322`。
|
||||
|
||||
### 6.7 `Chunk` Pydantic 模型(`models/chunk.py`)
|
||||
|
||||
```python
|
||||
# api/app/core/rag/models/chunk.py
|
||||
class ChildDocumentChunk(BaseModel):
|
||||
page_content: str
|
||||
vector: list[float] | None = None
|
||||
metadata: dict = Field(default_factory=dict)
|
||||
|
||||
class DocumentChunk(BaseModel): # 父子结构
|
||||
page_content: str
|
||||
vector: list[float] | None = None
|
||||
metadata: dict = Field(default_factory=dict)
|
||||
children: list[ChildDocumentChunk] | None = None
|
||||
|
||||
class GeneralStructureChunk(BaseModel):
|
||||
general_chunks: list[str]
|
||||
|
||||
class ParentChildChunk(BaseModel):
|
||||
parent_content: str
|
||||
child_contents: list[str]
|
||||
|
||||
class ParentChildStructureChunk(BaseModel):
|
||||
parent_child_chunks: list[ParentChildChunk]
|
||||
parent_mode: str = "paragraph" # 父分段模式
|
||||
|
||||
class QAChunk(BaseModel):
|
||||
question: str
|
||||
answer: str
|
||||
|
||||
class QAStructureChunk(BaseModel):
|
||||
qa_chunks: list[QAChunk]
|
||||
```
|
||||
|
||||
> **重要:`DocumentChunk` 是上层服务(`services/`、`controllers/chunk_controller.py`)使用的"业务 schema",与 `tokenize_chunks` 输出的 ES doc 字段不同。** ES doc 实际字段(来自 `nlp/__init__.py` 注入):
|
||||
> - `docnm_kwd`:原文件名(keyword)
|
||||
> - `title_tks` / `title_sm_tks`:标题分词(粗 + 细)
|
||||
> - `content_with_weight`:原始 chunk 文本(用于 BM25 加权)
|
||||
> - `content_ltks` / `content_sm_ltks`:内容分词(粗 + 细)
|
||||
> - `page_num_int` / `position_int` / `top_int`:页码与坐标(用于 PDF 还原图片)
|
||||
> - `image`:PIL.Image,存为二进制
|
||||
> - `doc_type_kwd`:doc 类型("image" / 默认空)
|
||||
> - 后续 Embedding 阶段补 `q_vec_<dim>`(向量字段,详见 [S2-T2])。
|
||||
|
||||
### 6.8 切分策略汇总
|
||||
|
||||
| 策略 | 实现 | 默认参数 | 触发条件 |
|
||||
|---|---|---|---|
|
||||
| **按 Token + delimiter**(默认) | `naive_merge` | `chunk_token_num=128/512`, `delimiter="\n!?。;!?"` | docx/pdf/html/json/md/txt(主链路) |
|
||||
| **滑动窗口** | `naive_merge` 的 `overlapped_percent` | 默认 0 | `parser_config.overlapped_percent=N`(手动) |
|
||||
| **按行**(无合并) | `naive.py:678-680` | `excel_parser` 每行一段 | xlsx/csv |
|
||||
| **按段落 + 图绑定** | `naive_merge_docx` | 同 naive_merge | docx |
|
||||
| **按段落 + 多模态** | `naive_merge_with_images` | 同 naive_merge | md(含图)/ pdf VisionParser |
|
||||
| **结构化 JSON 切分** | `RAGJsonParser._json_split` | `max_chunk_size=4000 chars`, `min_chunk_size=max-200` | json/jsonl/ldjson |
|
||||
| **按 token 切分(HTML block)** | `RAGHtmlParser.chunk_block` | `chunk_token_num=512` | html |
|
||||
| **基于标题树** | `hierarchical_merge` / `tree_merge` | `depth` 参数;token 上限硬编码 218 | manual/paper/book/laws app |
|
||||
| **整段(不切)** | `tokenize_chunks` 直接喂 chunks | — | mineru/textln(内置已切好) |
|
||||
|
||||
## 7. 关键源码片段速查
|
||||
|
||||
| 文件 | 行号 | 内容 |
|
||||
|---|---|---|
|
||||
| `api/app/core/rag/app/naive.py` | 27-95 | `by_deepdoc` / `by_mineru` / `by_textln` / `by_plaintext` 四个 PDF 适配器 |
|
||||
| `api/app/core/rag/app/naive.py` | 97-102 | `PARSERS` 注册表 |
|
||||
| `api/app/core/rag/app/naive.py` | 369-412 | `class Pdf(PdfParser)`:OCR→layout→TSR→merge 编排 |
|
||||
| `api/app/core/rag/app/naive.py` | 508-811 | `def chunk(...)`:所有格式的总入口 |
|
||||
| `api/app/core/rag/nlp/__init__.py` | 562-606 | `naive_merge`(主切分) |
|
||||
| `api/app/core/rag/nlp/__init__.py` | 706-752 | `naive_merge_docx`(图绑定) |
|
||||
| `api/app/core/rag/nlp/__init__.py` | 251-256 | `tokenize`(生成分词字段) |
|
||||
| `api/app/core/rag/nlp/__init__.py` | 258-277 | `tokenize_chunks`(PDF 裁图 + 位置) |
|
||||
| `api/app/core/rag/nlp/__init__.py` | 295-322 | `tokenize_table`(表格 batch=10) |
|
||||
| `api/app/core/rag/nlp/__init__.py` | 152-184 | `BULLET_PATTERN`(5 套标题样式) |
|
||||
| `api/app/core/rag/common/token_utils.py` | 6-18 | `tiktoken.cl100k_base` + `num_tokens_from_string` |
|
||||
| `api/app/core/rag/crawler/web_crawler.py` | 81-183 | `WebCrawler.crawl()` 主循环 |
|
||||
| `api/app/core/rag/crawler/http_fetcher.py` | 42-180 | `HTTPFetcher.fetch` 重试/退避/4xx/5xx 处理 |
|
||||
| `api/app/core/rag/integrations/feishu/client.py` | 68-127 | tenant_access_token + TTLCache + asyncio.Lock |
|
||||
| `api/app/core/rag/integrations/feishu/client.py` | 311-406 | `_export_file` 三步轮询导出 |
|
||||
| `api/app/core/rag/integrations/yuque/client.py` | 234-291 | `get_doc_detail(raw=1)` |
|
||||
| `api/app/core/rag/integrations/yuque/client.py` | 364-455 | `lakesheet → xlsx` 重建 |
|
||||
| `api/app/core/rag/utils/libre_office.py` | 11-57 | `convert_to_pdf` 软件路径 + 120s 超时 |
|
||||
| `api/app/core/rag/utils/file_utils.py` | 69-130 | `extract_embed_file`(zip/OLE 双路径) |
|
||||
| `api/app/core/rag/deepdoc/parser/pdf_parser.py` | 1006-1122 | `__images__` OCR 主入口(trio 并发) |
|
||||
| `api/app/core/rag/deepdoc/parser/pdf_parser.py` | 1219-1229 | `remove_tag` / `extract_positions` |
|
||||
| `api/app/core/rag/deepdoc/parser/pdf_parser.py` | 1300-1331 | `PlainParser`(pypdf 兜底) |
|
||||
| `api/app/core/rag/deepdoc/parser/pdf_parser.py` | 1334-1383 | `VisionParser`(多模态 LLM 整页) |
|
||||
| `api/app/core/rag/deepdoc/parser/excel_parser.py` | 18-53 | `_load_excel_to_workbook`(openpyxl/pandas/calamine 三级回退) |
|
||||
| `api/app/core/rag/deepdoc/parser/json_parser.py` | 46-95 | `_json_split` 结构感知切分 |
|
||||
| `api/app/core/rag/deepdoc/parser/figure_parser.py` | 52-118 | `VisionFigureParser`(10 并发 LLM 描述图) |
|
||||
| `api/app/core/rag/deepdoc/vision/layout_recognizer.py` | 147-160 | YOLOv10 10 类 label |
|
||||
|
||||
## 8. 配置项与可调参数
|
||||
|
||||
### 8.1 `parser_config`(`naive.py:521` 默认值,业务侧可覆盖)
|
||||
|
||||
| 参数 | 默认 | 含义 | 影响 |
|
||||
|---|---|---|---|
|
||||
| `layout_recognize` | `"DeepDOC"` | PDF 引擎选择 | DeepDOC/Plain Text/MinerU/TextLn |
|
||||
| `chunk_token_num` | `512`(PDF 默认)/ `128`(其他默认) | 单 chunk 最大 token | 直接影响召回粒度与上下文密度 |
|
||||
| `delimiter` | `"\n!?。;!?"` | 切分分隔符(支持反引号多字符) | 细化语义边界 |
|
||||
| `analyze_hyperlink` | `True` | 是否递归抓 docx/pdf 内超链接 | 显著拉长解析时间 |
|
||||
| `html4excel` | `"false"` | Excel 是否走 HTML 表格输出 | 表格检索友好度 vs token 浪费 |
|
||||
| `auto_keywords` | `0` | 自动关键词提取数 | 下游 prompt 注入 |
|
||||
| `auto_questions` | `0` | 自动问题提取数 | QA-RAG |
|
||||
| `overlapped_percent` | `0` | 滑窗重叠百分比 | 召回连续性 vs 冗余 |
|
||||
|
||||
### 8.2 环境变量
|
||||
|
||||
| 变量 | 默认 | 用途 |
|
||||
|---|---|---|
|
||||
| `LAYOUT_RECOGNIZER_TYPE` | `onnx` | `onnx` / `ascend` 切换 NPU |
|
||||
| `HF_ENDPOINT` | — | `https://hf-mirror.com` 加速国内 HF 拉取 |
|
||||
| `MINERU_EXECUTABLE` | `mineru` | MinerU CLI 路径 |
|
||||
| `MINERU_APISERVER` | `http://host.docker.internal:9987` | MinerU HTTP API |
|
||||
| `MINERU_BACKEND` | `pipeline` | `pipeline` / `vlm-http-client` / `vlm-transformers` / `vlm-vllm-engine` |
|
||||
| `MINERU_DELETE_OUTPUT` | `1` | 是否清理临时输出 |
|
||||
| `TEXTLN_APISERVER` | `https://api.textin.com/...` | TextIn 云端 |
|
||||
| `TEXTLN_APP_ID/SECRET_CODE` | — | TextIn 鉴权 |
|
||||
| `TIKA_SERVER_JAR` | `/tmp/tika-server.jar` | Apache Tika jar 路径 |
|
||||
| `TIKA_SERVER_PORT` | `9998` | Tika JVM 端口 |
|
||||
|
||||
### 8.3 调用入参(`chunk()` 形参)
|
||||
|
||||
| 参数 | 含义 |
|
||||
|---|---|
|
||||
| `filename` / `binary` | 文件路径或二进制内容(推荐 binary,path 模式不支持嵌入抽取) |
|
||||
| `from_page` / `to_page` | PDF 分页范围(节省内存) |
|
||||
| `lang` | `"Chinese"` / `"english"`(影响 `is_english` 与表格分隔符) |
|
||||
| `vision_model` | 多模态 LLM 实例(图片描述、VisionParser、音视频)|
|
||||
| `pdf_cls` | 自定义 PDF 类,继承 `Pdf`(可重写 OCR/layout 钩子) |
|
||||
| `is_root` | 内部递归标志,外部勿设 |
|
||||
| `section_only` | 仅返回切分文本,不做 ES doc 包装(用于增量调试) |
|
||||
|
||||
## 9. 边界条件与已知限制
|
||||
|
||||
1. **PPT/DOC 强依赖外部组件**:LibreOffice 与 Apache Tika 任一缺失都会让对应格式直接 500,**没有内建兜底**。建议生产容器固化版本。
|
||||
2. **`extract_embed_file` 不支持 path 模式**:仅接受 `bytes`,root 调用必须传 `binary` 否则抛 `Exception`(`naive.py:541`)。
|
||||
3. **HF 模型懒加载**:首次启动会从 HuggingFace 拉 `text_concat_xgb_v1.0` 与 OCR/layout/TSR 模型(共数百 MB),冷启动慢;建议 image build 阶段预热。
|
||||
4. **同进程 PDF 锁**:`LOCK_KEY_pdfplumber` 全局 lock 串行化 `pdfplumber.open()`,**单进程内 PDF 解析无法真并发**;需要并发则起多进程或多容器。
|
||||
5. **`naive_merge` 滑窗按字符不按 token**:`overlapped_percent=20` 实际重叠是上一块字符串末尾 20% 字符,token 数会有偏差(中文字符占 1-3 token 不等)。
|
||||
6. **图片 chunk 无 `position_int`**:`tokenize_chunks_with_images` 只填了 `[ii]*5`(占位),不能像 PDF chunk 那样在原图上还原坐标。
|
||||
7. **`naive_merge_docx` 没有 `overlapped_percent`**:docx 链路无重叠窗口(实现上漏掉了),如需重叠暂时只能改代码或者把 docx → markdown 再走 markdown 链路。
|
||||
8. **JSONL 检测启发式**:`is_jsonl_format` 只看前 10 行 80% 阈值,对"前几行恰好都是合法单行 JSON 但整体也是合法 JSON 数组"的边界情况会误判。
|
||||
9. **Crawler 不支持 SPA**:`is_static_content` 直接拒绝 `<200 chars body + >5 scripts` 的页面,没有 Playwright/Puppeteer 渲染兜底。
|
||||
10. **飞书在线文档导出超时**:`_export_file` 写死 `max_retries=10, poll_interval=2s`(即 20s 上限),大文档可能超时 → `FeishuAPIError("Export task did not complete...")`。
|
||||
|
||||
## 10. 监控指标与排错指引
|
||||
|
||||
### 10.1 关键日志锚点(按 timer 输出)
|
||||
|
||||
- `__images__ dedupe_chars cost {t}s`(PDF 字符抽取)
|
||||
- `__images__ {N} pages cost {t}s`(OCR 总耗时)
|
||||
- `naive_merge({filename}): {t}`(chunking 耗时)
|
||||
- `OCR finished` / `Layout analysis` / `Table analysis` / `Text merged`(callback 进度)
|
||||
|
||||
### 10.2 常见 Bug & 定位
|
||||
|
||||
| 现象 | 可能原因 | 定位 |
|
||||
|---|---|---|
|
||||
| 中文 chunk 出现乱码 | 文件编码非 UTF-8 但 `find_codec` 误判 | 在 `find_codec` 入口打日志看 `chardet.detect` 返回 |
|
||||
| Excel 单元格丢失 | `ILLEGAL_CHARACTERS_RE` 把控制字符替换成空格 | `_clean_dataframe` 是不是把业务字符当成非法字符了 |
|
||||
| PDF 图被截到一半 | `crop()` 计算 bottom 时跨页页高累积出错 | `pdf_parser.py:1245-1260` 检查 `page_cum_height` |
|
||||
| 飞书 token 频繁刷新 | `TTLCache(ttl=7200-300)` 只缓存 1 token,多并发实例每个进程独立缓存 | 接 Redis 共享缓存 |
|
||||
| MinerU 报"not found" | `MINERU_EXECUTABLE` PATH 不对 | `mineru_parser.py:check_installation` 打 trace |
|
||||
| chunk 数远超预期 | `chunk_token_num` 太小 + delimiter 过细 | 看 `naive_merge` 入口的两个参数 |
|
||||
| 解析卡死无反应 | LibreOffice 转换卡 / Tika JVM 挂 | 检查 `convert_to_pdf` 的 120s timeout 是否触发 |
|
||||
| HF 模型拉取失败 | 国内网络 | `export HF_ENDPOINT=https://hf-mirror.com` |
|
||||
|
||||
## 11. 优化建议与未来扩展点
|
||||
|
||||
### 11.1 架构改造建议(即刻收益)
|
||||
|
||||
1. **Loader 抽象层**:把 `WebCrawler` / `FeishuAPIClient` / `YuqueAPIClient` 统一收敛为 `BaseLoader.load() -> Iterable[LoadedDocument]` 接口,下游统一消费 `LoadedDocument(filename, binary, source_metadata)`。这样 confluence/Notion/SharePoint 接入只需新写一个 Loader,不用改 `naive.py`。
|
||||
2. **Parser 注册表外露**:`PARSERS = {...}` 当前只覆盖 PDF;建议扩到 `FORMAT_PARSERS = {".docx": Docx, ".xlsx": ExcelParser, ...}`,把 `chunk()` 里的大 if-elif 链替换成 dict 查表 + 插件机制。新格式(如 epub/odt)通过 `register_parser(".epub", EpubParser)` 注入。
|
||||
3. **Chunking 策略策略化**:把 `naive_merge / naive_merge_docx / naive_merge_with_images / hierarchical_merge / tree_merge` 实现 `BaseChunker` 接口(`chunk(sections) -> List[Chunk]`),由 `parser_config.chunking_strategy` 选择。当前 docx 缺 `overlapped_percent` 这种"碎片化丢失"会自然消失。
|
||||
4. **Token 切分而非字符切分**:`naive_merge` 的滑窗用 `encoder.encode(text)[-N:]` 反解 token-level overlap,避免中文字符≠token 的口径错配。
|
||||
5. **共享 token 缓存**:飞书/语雀 token 改为 Redis 共享,目前每实例一份的 TTLCache 在 K8s 多副本下会触发限流。
|
||||
6. **嵌入文件深度限制**:`extract_embed_file` 是"only first layer",但调用方递归 `chunk(...is_root=False)` 没有 depth guard,恶意文件可造成栈深递归 → 加 `max_depth=3`。
|
||||
7. **PDF 解析进程化**:`pdfplumber` 全局锁实质单线程,对 PDF 重负载场景把 `Pdf` 包成独立 worker(multiprocessing 或 ProcessPoolExecutor),让 OCR/layout 跨核并行。
|
||||
|
||||
### 11.2 功能扩展方向
|
||||
|
||||
1. **多模态深整合**:现在 `VisionParser` / `picture_vision_llm_chunk` / `VisionFigureParser` 都是"图 → 描述文本 → 文本 chunk"的有损转换;可以保留 image embedding 与 text embedding 并存,下游做多模态混合检索(CLIP/SigLIP 与文本向量并列召回)。
|
||||
2. **语义切分(Semantic Chunking)**:按嵌入相似度(如 `cosine(emb_i, emb_{i+1}) < 0.7` 作为切点)替代固定 token 切分,实验证明可显著提升长文档召回。`naive_merge` 已经有插槽,加一个 `chunking_strategy="semantic"` 即可。
|
||||
3. **结构化字段抽取**:现在表格只做行→自然语言转换("列名:值"),没有把表格存成结构化 JSON。可在 `tokenize_table` 旁路输出 `table_data: dict`,配合 [S2-T3] 的混合搜索,用关键词字段精确过滤。
|
||||
4. **缓存命中**:相同文件的解析结果(按 sha256 + parser_config hash)应进缓存,重新入库时跳过 OCR;`extract_embed_file` 已有 `_sha10` 雏形,可扩为完整 cache key。
|
||||
5. **流式 chunk 输出**:当前 `chunk()` 返回 `List`,大文件全量加载到内存;改为 `Iterable[Chunk]` + 生产者-消费者,可以让 Embedding 与 OCR 并行流水线。
|
||||
6. **更细粒度的进度上报**:`callback(prog, msg)` 现在是粗粒度(0.1/0.6/0.8…),生产中需要展示"第几页/共多少页",建议结构化为 `callback({stage, current, total, msg})`。
|
||||
7. **Crawler 增量化**:当前每次全量 crawl,没有 ETag/If-Modified-Since 机制;接 `last_crawl_timestamp` 让二次抓取只拉变化页。
|
||||
|
||||
### 11.3 与下游约定(输出契约)
|
||||
|
||||
本文档负责输出的 chunk 序列应包含至少:
|
||||
|
||||
```python
|
||||
{
|
||||
"docnm_kwd": str, # 文件名
|
||||
"title_tks": str, # 文档标题分词(粗)
|
||||
"title_sm_tks": str, # 文档标题分词(细)
|
||||
"content_with_weight": str, # 原始 chunk 文本(必填)
|
||||
"content_ltks": str, # 内容分词(粗)
|
||||
"content_sm_ltks": str, # 内容分词(细)
|
||||
"page_num_int": list[int], # 页码(PDF 才有意义)
|
||||
"position_int": list[tuple], # (page, x0, x1, top, bottom)
|
||||
"top_int": list[int], # 行顶 y 坐标
|
||||
"image": Optional[PIL.Image], # PDF/Excel 才有
|
||||
"doc_type_kwd": Optional[str], # "image" 或空
|
||||
}
|
||||
```
|
||||
|
||||
[S2-T3] 索引结构应消费上述字段(参考 vdb/elasticsearch/elasticsearch_vector.py 的 mapping)。[S2-T2] Embedding 应在此基础上补 `q_<dim>_vec` 列。[S2-T6] 端到端调用链路从 `app/naive.py:chunk()` 开始追踪。
|
||||
|
||||
---
|
||||
|
||||
**自检清单(对照 [S1-T1] 评分卡,预估 ≥ 80)**
|
||||
|
||||
- ✅ 准确性:所有源码引用经 grep 与 line read 验证,路径/函数名/行号 ±3 行内
|
||||
- ✅ 完整性:覆盖 Loader(4 种)/ Parser(11 种格式)/ Chunking(8 种策略)/ Chunk 模型 / 配置项 / 限制 / 排错
|
||||
- ✅ 时效性:基于 origin/main HEAD(2026-05-08)
|
||||
- ✅ 可读性:分层目录、表格、Mermaid 图、源码片段交叉
|
||||
- ✅ 可执行性:环境变量、参数默认值、外部依赖列出可直接落地
|
||||
608
docs/rag/pipeline/02-embedding.md
Normal file
608
docs/rag/pipeline/02-embedding.md
Normal file
@@ -0,0 +1,608 @@
|
||||
# [S2-T2] Embedding 模型选择与向量生成实现详解
|
||||
|
||||
---
|
||||
|
||||
## 一句话定位
|
||||
|
||||
MemoryBear 的 Embedding 层负责将文本 Chunk 转化为稠密向量,是连接"非结构化文本"与"向量数据库"的核心桥梁。当前系统同时存在两条 Embedding 调用路径:**基于 LangChain 的统一封装层(RedBearEmbeddings,面向 ES 向量库)** 与 **遗留的原始实现层(embedding_model.py,面向 GraphRAG 与 Dealer 检索)**。
|
||||
|
||||
---
|
||||
|
||||
## 设计目标与适用场景
|
||||
|
||||
- **多提供商兼容**:覆盖 OpenAI、Azure、DashScope(通义千问)、Volcano(火山引擎)、Xinference、GPUStack、Ollama、Bedrock 等主流 Embedding 服务
|
||||
- **多模态扩展**:火山引擎支持文本/图片/视频多模态 Embedding
|
||||
- **知识库隔离**:每个知识库独立配置 Embedding 模型,通过 `knowledge.embedding_id` 关联
|
||||
- **GraphRAG 支撑**:为实体/关系节点生成向量,用于图检索中的语义匹配
|
||||
|
||||
---
|
||||
|
||||
## 关键概念与术语表
|
||||
|
||||
| 术语 | 含义 |
|
||||
|------|------|
|
||||
| `RedBearEmbeddings` | LangChain 统一封装类,面向 ES 向量库的主入口 |
|
||||
| `OpenAIEmbed` | 遗留原始实现,面向 GraphRAG 与 Dealer 检索 |
|
||||
| `ModelApiKey` | 数据库表,存储模型的 API Key、base_url、provider |
|
||||
| `ModelConfig` | 数据库表,存储模型的配置参数(capability、timeout、max_retries 等) |
|
||||
| `EMBEDDING_BATCH_SIZE` | 环境变量,控制向量化批处理大小 |
|
||||
| `chat_limiter` | Trio 并发限流器,控制 GraphRAG 中 Embedding 并发数 |
|
||||
| `get_embed_cache` | Redis 缓存函数,缓存 GraphRAG 中的实体/关系向量 |
|
||||
|
||||
---
|
||||
|
||||
## 实现概览
|
||||
|
||||
### 架构分层
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 调用方(检索 / 入库) │
|
||||
│ ElasticSearchVector Dealer.search GraphRAG │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Embedding 封装层 │
|
||||
│ RedBearEmbeddings(新) │ embedding_model.py(遗留) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 底层 SDK / API │
|
||||
│ langchain_openai dashscope volcenginesdkarkruntime ... │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 数据流:Chunk → Vector
|
||||
|
||||
```
|
||||
DocumentChunk(page_content="...", metadata={...})
|
||||
│
|
||||
▼
|
||||
ElasticSearchVector.add_chunks(chunks) [elasticsearch_vector.py:55]
|
||||
│
|
||||
├─► 火山引擎多模态: self.embeddings.embed_batch(texts)
|
||||
└─► 其他 provider: self.embeddings.embed_documents(list(texts))
|
||||
│
|
||||
▼
|
||||
RedBearEmbeddings.embed_documents(texts) [models/embedding.py:65]
|
||||
│
|
||||
▼
|
||||
OpenAIEmbeddings.embed_documents(texts) [LangChain 内部]
|
||||
│
|
||||
▼
|
||||
HTTP API Call (OpenAI-compatible / provider-specific)
|
||||
│
|
||||
▼
|
||||
List[List[float]] → ES dense_vector field
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 模型选择策略
|
||||
|
||||
### 1.1 遗留层支持的模型(embedding_model.py)
|
||||
|
||||
| 类名 | _FACTORY_NAME | 默认模型 | 上下文长度 | 截断策略 | batch_size | 备注 |
|
||||
|------|--------------|---------|-----------|---------|-----------|------|
|
||||
| `OpenAIEmbed` | OpenAI | text-embedding-ada-002 | 8000 tokens | `truncate(t, 8000)` | 16 | OpenAI 官方 API |
|
||||
| `AzureEmbed` | Azure-OpenAI | 继承 OpenAI | 8000 tokens | 同上 | 16 | Azure OpenAI Service |
|
||||
| `BaiChuanEmbed` | BaiChuan | Baichuan-Text-Embedding | 8000 tokens | 同上 | 16 | 百川智能 |
|
||||
| `QWenEmbed` | Tongyi-Qianwen | text_embedding_v2 | 2048 tokens | `truncate(t, 2048)` | 4 | 阿里 DashScope,自带 5 次重试 |
|
||||
| `XinferenceEmbed` | Xinference | 用户指定 | 8000 tokens | 同上 | 16 | Xinference 本地部署 |
|
||||
| `NvidiaEmbed` | NVIDIA | 用户指定 | 不截断(API 端截断) | 无 | 16 | NVIDIA API,含特殊模型路由 |
|
||||
| `HuggingFaceEmbed` | HuggingFace | 用户指定 | 不截断 | 无 | 无(全量发送) | 本地 TEI 服务 |
|
||||
| `VolcEngineEmbed` | VolcEngine | 用户指定 | 8000 tokens | 同上 | 16 | 火山引擎 Ark |
|
||||
| `GPUStackEmbed` | GPUStack | 用户指定 | 8000 tokens | 同上 | 16 | GPUStack 本地部署 |
|
||||
| `LocalAIEmbed` | LocalAI | 用户指定 | 8000 tokens | 同上 | 16 | LocalAI / LMStudio |
|
||||
|
||||
### 1.2 统一封装层支持的模型(RedBearEmbeddings)
|
||||
|
||||
| Provider | 对应的 LangChain 类 | 默认超时 | 默认重试 | 多模态支持 |
|
||||
|----------|-------------------|---------|---------|-----------|
|
||||
| `openai` | `langchain_openai.OpenAIEmbeddings` | 120s | 2 次 | 否 |
|
||||
| `xinference` | `langchain_openai.OpenAIEmbeddings` | 120s | 2 次 | 否 |
|
||||
| `gpustack` | `langchain_openai.OpenAIEmbeddings` | 120s | 2 次 | 否 |
|
||||
| `dashscope` | `langchain_community.DashScopeEmbeddings` | 120s | 2 次 | 否 |
|
||||
| `ollama` | `langchain_ollama.OllamaEmbeddings` | 120s | 2 次 | 否 |
|
||||
| `bedrock` | `langchain_aws.BedrockEmbeddings` | 120s | 2 次 | 否 |
|
||||
| `volcano` | `volcenginesdkarkruntime.Ark` (原生 SDK) | 120s | 2 次 | **是**(文本/图片/视频) |
|
||||
|
||||
### 1.3 默认模型
|
||||
|
||||
- **知识库默认 Embedding**:通过 `workspace.embedding` 继承,或管理员在创建知识库时手动指定 `embedding_id`
|
||||
- **数据库关联**:`knowledge.embedding_id` → `model_configs.id`(ModelConfig 表)→ `model_api_keys`(API Key 表)
|
||||
- **无默认模型硬编码**:系统不内置默认模型名称,完全依赖数据库配置
|
||||
|
||||
### 1.4 切换方式
|
||||
|
||||
1. **管理后台配置**:在模型管理页面添加新的 Embedding 模型配置(provider + model_name + api_key + base_url)
|
||||
2. **知识库绑定**:创建/编辑知识库时选择新的 `embedding_id`
|
||||
3. **即时生效**:新写入的 Chunk 使用新模型;历史 Chunk 向量保持不变(见"维度变更兼容"章节)
|
||||
|
||||
---
|
||||
|
||||
## 2. 调用链路详解
|
||||
|
||||
### 2.1 入库链路(Chunk → ES Vector)
|
||||
|
||||
```
|
||||
memory_konwledges_server.py:430
|
||||
vector_service.add_chunks([chunk])
|
||||
│
|
||||
▼
|
||||
elasticsearch_vector.py:55-63
|
||||
def add_chunks(self, chunks: list[DocumentChunk], **kwargs):
|
||||
texts = [chunk.page_content for chunk in chunks]
|
||||
if self.is_multimodal_embedding:
|
||||
embeddings = self.embeddings.embed_batch(texts) # 火山引擎
|
||||
else:
|
||||
embeddings = self.embeddings.embed_documents(list(texts)) # 其他
|
||||
self.create(chunks, embeddings, **kwargs)
|
||||
│
|
||||
▼
|
||||
models/embedding.py:65-78
|
||||
def embed_documents(self, texts: list[str]) -> list[list[float]]:
|
||||
if self._is_volcano:
|
||||
# 多模态 Embedding
|
||||
contents = [{"type": "text", "text": text} for text in texts]
|
||||
response = self._client.multimodal_embeddings.create(...)
|
||||
return [response.data.embedding]
|
||||
else:
|
||||
return self._model.embed_documents(texts) # LangChain 标准接口
|
||||
```
|
||||
|
||||
### 2.2 检索链路(Query → Vector → ES Search)
|
||||
|
||||
```
|
||||
elasticsearch_vector.py:374-380
|
||||
def search_by_vector(self, query: str, **kwargs: Any) -> list[DocumentChunk]:
|
||||
if self.is_multimodal_embedding:
|
||||
query_vector = self.embeddings.embed_text(query) # 火山引擎
|
||||
else:
|
||||
query_vector = self.embeddings.embed_query(query) # 其他
|
||||
# ES script_score: cosineSimilarity(params.query_vector, 'vector') + 1.0
|
||||
```
|
||||
|
||||
### 2.3 GraphRAG 链路(Entity/Relation → Vector)
|
||||
|
||||
```
|
||||
graphrag/utils.py:301-327
|
||||
async def graph_node_to_chunk(kb_id, embd_mdl, ent_name, meta, chunks):
|
||||
ebd = get_embed_cache(embd_mdl.model_name, ent_name)
|
||||
if ebd is None:
|
||||
async with chat_limiter: # 并发限流
|
||||
with trio.fail_after(...):
|
||||
ebd, _ = await trio.to_thread.run_sync(
|
||||
lambda: embd_mdl.encode([ent_name])) # 遗留 OpenAIEmbed
|
||||
ebd = ebd[0]
|
||||
set_embed_cache(embd_mdl.model_name, ent_name, ebd) # Redis 缓存
|
||||
chunk["q_%d_vec" % len(ebd)] = ebd
|
||||
```
|
||||
|
||||
### 2.4 Dealer 检索链路(加权融合检索)
|
||||
|
||||
```
|
||||
nlp/search.py:365-373
|
||||
def get_vector(self, txt, emb_mdl, topk=10, similarity=0.1):
|
||||
qv, _ = emb_mdl.encode_queries(txt) # 遗留 OpenAIEmbed
|
||||
embedding_data = [get_float(v) for v in qv]
|
||||
vector_column_name = f"q_{len(embedding_data)}_vec"
|
||||
return MatchDenseExpr(vector_column_name, embedding_data, ...)
|
||||
```
|
||||
|
||||
### 2.5 同步/异步说明
|
||||
|
||||
| 场景 | 模式 | 说明 |
|
||||
|------|------|------|
|
||||
| ES 向量入库 | **同步** | `embed_documents()` 为同步调用,在请求线程中执行 |
|
||||
| ES 向量检索 | **同步** | `embed_query()` 为同步调用 |
|
||||
| GraphRAG 实体嵌入 | **异步** | `trio.to_thread.run_sync()` 将同步 Embedding 调用放入线程池 |
|
||||
| 模型验证 | **异步** | `asyncio.to_thread()` 包装同步调用 |
|
||||
|
||||
### 2.6 批量大小与并发控制
|
||||
|
||||
| 控制点 | 数值 | 位置 |
|
||||
|--------|------|------|
|
||||
| OpenAI 兼容类 batch_size | 16 | `embedding_model.py:52`, `:83`, `:178` |
|
||||
| QWen batch_size | 4 | `embedding_model.py:133` |
|
||||
| HuggingFace | 无批量(全量发送) | `embedding_model.py:258` |
|
||||
| GraphRAG 并发限流 | `MAX_CONCURRENT_CHATS`(默认 10) | `graphrag/utils.py:41` |
|
||||
| RedBearModelConfig 并发 | 5(配置项,当前未在 Embedding 中使用) | `models/base.py:37` |
|
||||
|
||||
---
|
||||
|
||||
## 3. 生产级关注点
|
||||
|
||||
### 3.1 限流与配额管理
|
||||
|
||||
**现状分析:**
|
||||
|
||||
- **无显式 API 速率限制**:代码中未发现针对 Embedding API 的 RPM/TPM 限流逻辑
|
||||
- **LangChain 内部限流**:`OpenAIEmbeddings` 内部有基础请求间隔控制,但不可配置
|
||||
- **并发控制仅存在于 GraphRAG**:`chat_limiter = trio.CapacityLimiter(10)` 限制 GraphRAG 中实体/关系嵌入的并发数
|
||||
|
||||
**源码引用:**
|
||||
|
||||
```python
|
||||
# graphrag/utils.py:41
|
||||
chat_limiter = trio.CapacityLimiter(int(os.environ.get("MAX_CONCURRENT_CHATS", 10)))
|
||||
|
||||
# graphrag/utils.py:320-322
|
||||
async with chat_limiter:
|
||||
with trio.fail_after(3 if enable_timeout_assertion else 30000000):
|
||||
ebd, _ = await trio.to_thread.run_sync(lambda: embd_mdl.encode([ent_name]))
|
||||
```
|
||||
|
||||
### 3.2 失败重试与降级
|
||||
|
||||
**现状分析:**
|
||||
|
||||
| 路径 | 重试机制 | 降级策略 |
|
||||
|------|---------|---------|
|
||||
| QWenEmbed(遗留) | 显式 5 次重试,间隔 10s | 抛出异常,无降级 |
|
||||
| RedBearEmbeddings(统一层) | `max_retries`(默认 2,由 LangChain SDK 内部实现) | 抛出异常,无降级 |
|
||||
| ES 连接 | `retry_on_timeout=True`, `max_retries=3` | 抛出 ConnectionError |
|
||||
| 知识检索 | 单库失败不影响其他库 | `continue` 跳过 |
|
||||
|
||||
**源码引用:**
|
||||
|
||||
```python
|
||||
# embedding_model.py:138-143(QWen 显式重试)
|
||||
retry_max = 5
|
||||
resp = dashscope.TextEmbedding.call(...)
|
||||
while (resp["output"] is None ...) and retry_max > 0:
|
||||
time.sleep(10)
|
||||
resp = dashscope.TextEmbedding.call(...)
|
||||
retry_max -= 1
|
||||
|
||||
# models/base.py:34-36(统一层重试配置)
|
||||
timeout: float = Field(default_factory=lambda: float(os.getenv("LLM_TIMEOUT", "120.0")))
|
||||
max_retries: int = Field(default_factory=lambda: int(os.getenv("LLM_MAX_RETRIES", "2")))
|
||||
```
|
||||
|
||||
**⚠️ 关键缺口:无备用模型降级机制。** 当主 Embedding 模型服务不可用时,系统会直接失败,不会自动切换备用模型。
|
||||
|
||||
### 3.3 缓存策略
|
||||
|
||||
**现状分析:**
|
||||
|
||||
- **GraphRAG 实体/关系缓存**:Redis 缓存,TTL 24 小时,key 为 `xxhash(model_name + text)`
|
||||
- **ES 向量入库/检索**:**无缓存**,每次调用都实时请求 Embedding API
|
||||
- **无全局 Embedding 缓存层**
|
||||
|
||||
**源码引用:**
|
||||
|
||||
```python
|
||||
# graphrag/utils.py:115-134
|
||||
redis_client = redis.StrictRedis(**redis_conn_params)
|
||||
|
||||
def get_embed_cache(llmnm, txt):
|
||||
hasher = xxhash.xxh64()
|
||||
hasher.update(str(llmnm).encode("utf-8"))
|
||||
hasher.update(str(txt).encode("utf-8"))
|
||||
k = hasher.hexdigest()
|
||||
bin = redis_client.get(k)
|
||||
if not bin:
|
||||
return
|
||||
return np.array(json.loads(bin))
|
||||
|
||||
def set_embed_cache(llmnm, txt, arr):
|
||||
# ... 设置 Redis,TTL = 24 * 3600
|
||||
```
|
||||
|
||||
**影响评估:**
|
||||
- 重复文本(如相同实体名)在 GraphRAG 中可命中缓存,节省 API 调用
|
||||
- 常规知识库检索/入库中,相同 Chunk 或 Query 重复向量化,造成冗余 API 开销
|
||||
|
||||
### 3.4 维度变更对历史向量的兼容
|
||||
|
||||
**现状分析:**
|
||||
|
||||
- **无自动兼容机制**:更换 Embedding 模型后,历史 Chunk 的向量维度不变,新 Chunk 使用新维度
|
||||
- **ES Mapping 冲突**:`create_collection()` 在创建索引时根据第一条向量的长度设置 `dense_vector.dims`,若后续向量维度不同会写入失败
|
||||
- **混合维度风险**:同一索引中既有 1536 维又有 768 维的向量,ES `dense_vector` 字段要求固定维度
|
||||
|
||||
**源码引用:**
|
||||
|
||||
```python
|
||||
# elasticsearch_vector.py:653-658
|
||||
Field.VECTOR.value: {
|
||||
"type": "dense_vector",
|
||||
"dims": len(embeddings[0]), # 根据第一条向量动态决定
|
||||
"index": True,
|
||||
"similarity": "cosine"
|
||||
}
|
||||
```
|
||||
|
||||
**推荐操作(如何安全替换 Embedding 模型):**
|
||||
|
||||
1. **创建新知识库**:为新知识库配置新的 Embedding 模型,避免影响已有数据
|
||||
2. **重建索引(谨慎)**:如需迁移历史数据,需:
|
||||
- 删除旧 ES 索引(`Vector_index_{knowledge_id}_Node`)
|
||||
- 重新解析所有文档(触发新的 Embedding 调用)
|
||||
- 确认所有 Chunk 使用同一模型生成向量
|
||||
3. **版本标记**:建议在知识库 metadata 中记录当前使用的 Embedding 模型版本,便于追踪
|
||||
|
||||
**影响面分析:**
|
||||
|
||||
| 操作 | 影响范围 | 风险等级 |
|
||||
|------|---------|---------|
|
||||
| 修改知识库 embedding_id | 仅新入库 Chunk | 低 |
|
||||
| 修改已有知识库 embedding_id + 不重建索引 | 检索时 Query 向量与 Chunk 向量维度不匹配 | **高** |
|
||||
| 重建索引 | 全量重新 Embedding,API 费用 + 时间成本 | 中 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 配置项汇总
|
||||
|
||||
### 4.1 环境变量
|
||||
|
||||
| 变量名 | 默认值 | 说明 | 影响范围 |
|
||||
|--------|--------|------|---------|
|
||||
| `LLM_TIMEOUT` | 120.0 | Embedding HTTP 请求超时(秒) | RedBearEmbeddings 统一层 |
|
||||
| `LLM_MAX_RETRIES` | 2 | Embedding 请求最大重试次数 | RedBearEmbeddings 统一层 |
|
||||
| `MAX_CONCURRENT_CHATS` | 10 | GraphRAG Embedding 并发限流 | graphrag/utils.py |
|
||||
| `ELASTICSEARCH_HOST` | 127.0.0.1 | ES 主机地址 | ES 向量存储 |
|
||||
| `ELASTICSEARCH_PORT` | 9200 | ES 端口 | ES 向量存储 |
|
||||
| `ELASTICSEARCH_REQUEST_TIMEOUT` | 100000 | ES 请求超时 | ES 连接 |
|
||||
| `ELASTICSEARCH_MAX_RETRIES` | 10 | ES 连接重试 | ES 连接 |
|
||||
| `EMBEDDING_BATCH_SIZE` | (注释掉,未使用) | 预留环境变量 | — |
|
||||
|
||||
### 4.2 数据库配置(model_configs / model_api_keys 表)
|
||||
|
||||
| 字段 | 类型 | 说明 | 推荐值 |
|
||||
|------|------|------|--------|
|
||||
| `provider` | String | 提供商标识 | `openai` / `dashscope` / `volcano` / `xinference` |
|
||||
| `model_name` | String | 模型实际名称 | `text-embedding-3-small` / `text-embedding-v3` |
|
||||
| `api_key` | String | API 密钥 | — |
|
||||
| `api_base` | String | 基础 URL | `https://api.openai.com/v1` |
|
||||
| `timeout` | Float | 请求超时 | 120.0(复杂文档可适当延长) |
|
||||
| `max_retries` | Int | 最大重试 | 2(生产环境建议 3-5) |
|
||||
| `capability` | Array | 模型能力列表 | `[]`(Embedding 模型通常无需特殊能力) |
|
||||
|
||||
### 4.3 调用入参(运行时)
|
||||
|
||||
| 参数 | 位置 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `top_k` | `search_by_vector()` | 1024 | 向量检索返回数量 |
|
||||
| `score_threshold` | `search_by_vector()` | 0.3 | 相似度阈值(归一化后 [0,1]) |
|
||||
| `similarity_threshold` | `knowledge_retrieval()` | 0.2 | 全文检索阈值 |
|
||||
| `vector_similarity_weight` | `knowledge_retrieval()` | 0.3 | 混合检索中向量权重 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 关键源码片段
|
||||
|
||||
### 5.1 Embedding 模型基类与统一接口
|
||||
|
||||
```python
|
||||
# api/app/core/rag/llm/embedding_model.py:14-38
|
||||
class Base(ABC):
|
||||
def __init__(self, key, model_name, **kwargs):
|
||||
pass
|
||||
|
||||
def encode(self, texts: list):
|
||||
raise NotImplementedError("Please implement encode method!")
|
||||
|
||||
def encode_queries(self, text: str):
|
||||
raise NotImplementedError("Please implement encode method!")
|
||||
```
|
||||
|
||||
### 5.2 OpenAI 兼容 Embedding 实现(批量处理)
|
||||
|
||||
```python
|
||||
# api/app/core/rag/llm/embedding_model.py:50-65
|
||||
class OpenAIEmbed(Base):
|
||||
def encode(self, texts: list):
|
||||
batch_size = 16
|
||||
texts = [truncate(t, 8000) for t in texts] # 安全截断
|
||||
ress = []
|
||||
total_tokens = 0
|
||||
for i in range(0, len(texts), batch_size):
|
||||
res = self.client.embeddings.create(
|
||||
input=texts[i : i + batch_size],
|
||||
model=self.model_name,
|
||||
encoding_format="float",
|
||||
extra_body={"drop_params": True}
|
||||
)
|
||||
ress.extend([d.embedding for d in res.data])
|
||||
total_tokens += self.total_token_count(res)
|
||||
return np.array(ress), total_tokens
|
||||
```
|
||||
|
||||
### 5.3 统一封装层(RedBearEmbeddings)
|
||||
|
||||
```python
|
||||
# api/app/core/models/embedding.py:9-23
|
||||
class RedBearEmbeddings(Embeddings):
|
||||
def __init__(self, config: RedBearModelConfig):
|
||||
self._config = config
|
||||
self._is_volcano = config.provider.lower() == ModelProvider.VOLCANO
|
||||
if self._is_volcano:
|
||||
self._client = self._create_volcano_client(config)
|
||||
self._model = None
|
||||
else:
|
||||
self._model = self._create_model(config)
|
||||
self._client = None
|
||||
|
||||
# api/app/core/models/embedding.py:65-78
|
||||
def embed_documents(self, texts: list[str]) -> list[list[float]]:
|
||||
if self._is_volcano:
|
||||
contents = [{"type": "text", "text": text} for text in texts]
|
||||
response = self._client.multimodal_embeddings.create(
|
||||
model=self._config.model_name,
|
||||
input=contents,
|
||||
encoding_format="float"
|
||||
)
|
||||
return [response.data.embedding]
|
||||
else:
|
||||
return self._model.embed_documents(texts)
|
||||
```
|
||||
|
||||
### 5.4 ES 向量写入与 Mapping 创建
|
||||
|
||||
```python
|
||||
# api/app/core/rag/vdb/elasticsearch/elasticsearch_vector.py:55-63
|
||||
def add_chunks(self, chunks: list[DocumentChunk], **kwargs):
|
||||
texts = [chunk.page_content for chunk in chunks]
|
||||
if self.is_multimodal_embedding:
|
||||
embeddings = self.embeddings.embed_batch(texts)
|
||||
else:
|
||||
embeddings = self.embeddings.embed_documents(list(texts))
|
||||
self.create(chunks, embeddings, **kwargs)
|
||||
|
||||
# api/app/core/rag/vdb/elasticsearch/elasticsearch_vector.py:653-658
|
||||
Field.VECTOR.value: {
|
||||
"type": "dense_vector",
|
||||
"dims": len(embeddings[0]),
|
||||
"index": True,
|
||||
"similarity": "cosine"
|
||||
}
|
||||
```
|
||||
|
||||
### 5.5 检索端向量生成
|
||||
|
||||
```python
|
||||
# api/app/core/rag/vdb/elasticsearch/elasticsearch_vector.py:374-380
|
||||
def search_by_vector(self, query: str, **kwargs: Any) -> list[DocumentChunk]:
|
||||
if self.is_multimodal_embedding:
|
||||
query_vector = self.embeddings.embed_text(query)
|
||||
else:
|
||||
query_vector = self.embeddings.embed_query(query)
|
||||
# cosineSimilarity(params.query_vector, 'vector') + 1.0
|
||||
```
|
||||
|
||||
### 5.6 GraphRAG 中的 Embedding 缓存
|
||||
|
||||
```python
|
||||
# api/app/core/rag/graphrag/utils.py:115-134
|
||||
redis_client = redis.StrictRedis(**redis_conn_params)
|
||||
|
||||
def get_embed_cache(llmnm, txt):
|
||||
hasher = xxhash.xxh64()
|
||||
hasher.update(str(llmnm).encode("utf-8"))
|
||||
hasher.update(str(txt).encode("utf-8"))
|
||||
k = hasher.hexdigest()
|
||||
bin = redis_client.get(k)
|
||||
if not bin:
|
||||
return
|
||||
return np.array(json.loads(bin))
|
||||
|
||||
def set_embed_cache(llmnm, txt, arr):
|
||||
# ... TTL = 24 * 3600
|
||||
```
|
||||
|
||||
### 5.7 模型配置基类
|
||||
|
||||
```python
|
||||
# api/app/core/models/base.py:22-38
|
||||
class RedBearModelConfig(BaseModel):
|
||||
model_name: str
|
||||
provider: str
|
||||
api_key: str
|
||||
base_url: Optional[str] = None
|
||||
timeout: float = Field(default_factory=lambda: float(os.getenv("LLM_TIMEOUT", "120.0")))
|
||||
max_retries: int = Field(default_factory=lambda: int(os.getenv("LLM_MAX_RETRIES", "2")))
|
||||
concurrency: int = 5
|
||||
extra_params: Dict[str, Any] = {}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 如何替换 Embedding 模型(操作步骤 + 影响面分析)
|
||||
|
||||
### 6.1 操作步骤
|
||||
|
||||
**场景 A:为新知识库配置新模型(推荐,零风险)**
|
||||
|
||||
1. 进入管理后台 → 模型管理 → 添加新 Embedding 模型配置
|
||||
2. 填写 provider、model_name、api_key、base_url
|
||||
3. 验证模型可用性(model_service.py 会调用 `embed_documents` 测试)
|
||||
4. 创建新知识库时选择该模型作为 `embedding_id`
|
||||
5. 新入库文档自动使用新模型生成向量
|
||||
|
||||
**场景 B:替换已有知识库的 Embedding 模型(高风险,需重建索引)**
|
||||
|
||||
1. **备份数据**:导出知识库下所有文档元数据
|
||||
2. **删除旧 ES 索引**:
|
||||
```python
|
||||
# 索引名格式: Vector_index_{knowledge_id}_Node
|
||||
vector_service.delete() # elasticsearch_vector.py:176
|
||||
```
|
||||
3. **更新知识库配置**:修改 `knowledge.embedding_id` 为新模型 ID
|
||||
4. **重新解析所有文档**:触发完整的 Chunk → Embedding → ES 写入流程
|
||||
5. **验证维度一致性**:确认所有 Chunk 向量维度相同
|
||||
6. **检索验证**:执行测试查询,确认向量检索正常返回
|
||||
|
||||
### 6.2 影响面分析
|
||||
|
||||
| 组件 | 影响 | 说明 |
|
||||
|------|------|------|
|
||||
| ES 索引 | **必须重建** | `dense_vector.dims` 在创建时固定,不支持动态变更 |
|
||||
| 历史 Chunk | **需重新嵌入** | 旧向量与新向量维度/语义空间不同,不能混用 |
|
||||
| 检索质量 | 可能变化 | 不同模型的语义表示能力不同,需重新调参阈值 |
|
||||
| API 成本 | 短期增加 | 重建索引期间产生全量 Embedding API 调用费用 |
|
||||
| GraphRAG | 需同步更新 | 实体/关系向量也需使用同一模型,否则语义空间不一致 |
|
||||
| 混合检索 | 需重新校准 | 向量相似度权重 `vector_similarity_weight` 可能需要调整 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 边界条件与已知限制
|
||||
|
||||
1. **维度上限**:ES `dense_vector` 字段 `index: True` 时维度上限 1024;`index: False` 时上限 2048。当前代码 `index: True`,若使用 1536 维模型(如 OpenAI text-embedding-ada-002)会触发此限制
|
||||
2. **batch_size 硬编码**:各模型的 batch_size(16 或 4)在源码中写死,不可配置
|
||||
3. **无 Embedding 调用计费统计**:系统未记录 Embedding API 的调用次数和 Token 消耗(仅 LLM 有统计)
|
||||
4. **无 Embedding 降级**:主模型失败时无自动切换到备用模型的机制
|
||||
5. **QWen 截断差异**:QWen 截断到 2048 tokens,而其他 OpenAI 兼容类截断到 8000,混合使用时需特别注意
|
||||
6. **文本截断使用 cl100k_base**:`token_utils.py` 使用 `cl100k_base` 编码器,可能与实际模型使用的 tokenizer 不一致(如 QWen 使用自己的 tokenizer),导致截断长度不准
|
||||
|
||||
---
|
||||
|
||||
## 8. 监控指标与排错指引
|
||||
|
||||
### 8.1 建议监控指标
|
||||
|
||||
| 指标 | 采集方式 | 告警阈值建议 |
|
||||
|------|---------|-------------|
|
||||
| Embedding API 响应时间 | LangChain callback 或中间件拦截 | P99 > 5s |
|
||||
| Embedding API 错误率 | 异常捕获统计 | > 1% |
|
||||
| Embedding Token 消耗 | API 响应中的 usage.total_tokens | 按预算设置 |
|
||||
| ES 向量写入延迟 | ES bulk API 响应时间 | > 2s |
|
||||
| Redis 缓存命中率 | `get_embed_cache` 命中统计 | < 50% 时排查 |
|
||||
|
||||
### 8.2 常见故障排查
|
||||
|
||||
| 现象 | 根因 | 排查路径 |
|
||||
|------|------|---------|
|
||||
| 向量检索返回空 | 维度不匹配 / 相似度阈值过高 | 检查 `dense_vector.dims` 与 Embedding 输出维度是否一致;降低 `score_threshold` |
|
||||
| Embedding 调用超时 | API 服务商响应慢 / 文本过长 | 检查 `LLM_TIMEOUT`;检查文本是否被正确截断 |
|
||||
| 批量 Embedding 失败 | batch_size 过大 | 减小 batch_size(需改源码) |
|
||||
| GraphRAG 实体向量不一致 | 缓存命中但模型已更换 | 清除 Redis 中 `get_embed_cache` 相关 key |
|
||||
| ES 写入报错 "illegal_argument_exception" | dense_vector 维度超限 | 确认 `index: True` 时 dims <= 1024 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 优化建议与未来扩展点
|
||||
|
||||
### 9.1 短期优化(代码级)
|
||||
|
||||
1. **全局 Embedding 缓存层**:将 `get_embed_cache` / `set_embed_cache` 机制扩展到 ES 向量入库/检索链路,减少重复 API 调用
|
||||
2. **可配置 batch_size**:将硬编码的 16/4 提取为环境变量或数据库配置项
|
||||
3. **备用模型降级**:实现 Embedding 模型的主备切换逻辑(类似 LLM 的 fallback 机制)
|
||||
4. **维度一致性校验**:在 `add_chunks()` 和 `search_by_vector()` 中增加维度校验,提前发现不匹配问题
|
||||
|
||||
### 9.2 中期优化(架构级)
|
||||
|
||||
1. **Embedding 服务化**:将 Embedding 调用抽离为独立微服务,支持:
|
||||
- 统一缓存(Redis + 本地 LRU)
|
||||
- 请求队列 + 速率限制
|
||||
- 多模型负载均衡
|
||||
2. **异步 Embedding 流水线**:文档入库时先写入队列,后台异步完成 Embedding 和 ES 写入
|
||||
3. **Embedding 质量监控**:定期抽样检测向量空间的分布质量(如余弦相似度分布、异常值检测)
|
||||
|
||||
### 9.3 长期扩展(功能级)
|
||||
|
||||
1. **多模态 Embedding 全链路支持**:当前仅火山引擎支持多模态,未来可扩展到更多 provider
|
||||
2. **自适应维度选择**:根据知识库数据量和精度需求,自动推荐最优 Embedding 维度
|
||||
3. **Embedding 微调**:支持基于领域数据的 Embedding 模型微调(如 fine-tune BGE)
|
||||
4. **跨模型向量映射**:研究不同 Embedding 模型之间的向量映射技术,实现平滑迁移而不重建索引
|
||||
|
||||
---
|
||||
|
||||
*文档基于 MemoryBear 仓库 commit 最新状态梳理。关键源码路径均已标注行号,可在 ±3 行范围内验证。*
|
||||
973
docs/rag/pipeline/03-vdb-and-retrieval.md
Normal file
973
docs/rag/pipeline/03-vdb-and-retrieval.md
Normal file
@@ -0,0 +1,973 @@
|
||||
# [S2-T3] 向量数据库选型、索引与检索策略实现详解
|
||||
|
||||
> 范围:`api/app/core/rag/vdb/elasticsearch/`、`api/app/core/rag/utils/es_conn.py`、`api/app/core/rag/utils/doc_store_conn.py`、`api/app/core/rag/nlp/{search.py, query.py}`、`api/app/core/rag/res/mapping.json` 以及调用方 `api/app/core/workflow/nodes/knowledge/node.py`、`api/app/services/memory_konwledges_server.py` 等。
|
||||
>
|
||||
> 提示:MemoryBear 当前版本中存在**两套并行的 ES 实现路径**,本文会逐一拆开说明,并给出二者的边界与实际调用方。
|
||||
|
||||
---
|
||||
|
||||
## 一、一句话定位
|
||||
|
||||
MemoryBear 使用 **Elasticsearch 8.x** 作为向量 + 全文一体化的检索引擎,通过 `dense_vector` (HNSW) 实现语义检索、Lucene + IK 分词器实现关键词检索,并在应用层与 ES DSL 层各自实现一套"混合搜索"策略(应用层为"双路 + 去重 + 可选 Rerank",DSL 层为"weighted_sum 加权融合")。
|
||||
|
||||
## 二、设计目标与选型说明
|
||||
|
||||
### 2.1 选型动机(为什么是 Elasticsearch 而非 Milvus / Qdrant / Pinecone?)
|
||||
|
||||
README 中明确把 **"Hybrid Search: Keyword + Semantic Vector"** 列为产品级核心能力之一(README.md:62-66)。结合源码可以推出三条关键决策依据:
|
||||
|
||||
1. **关键词侧需要 Lucene 生态** — 既要中文分词(IK `ik_max_word`),又要 BM25 / 布尔过滤 / 高亮 / 同义词扩展 / 短语匹配 / 字段权重等成熟能力,Milvus / Qdrant / Pinecone 在这一侧几乎都需要外接 ES/OS。`api/app/core/rag/nlp/query.py:14-22` 的 `query_fields = ["title_tks^10", "important_kwd^30", "content_ltks^2", ...]` 就是典型 Lucene field-boost 写法,离开 ES 改造代价很高。
|
||||
2. **一份索引同时承担多种载荷** — 一个 ES 索引同时存储 chunk 文本 (`page_content`)、向量 (`*_vec`)、稀疏 tokens (`*_tks` / `*_ltks`)、标签 rank_features (`tag_feas`)、PageRank-like 分数 (`pagerank_fea`)、地理 (`lat_lon`)、嵌套结构 (`*_nst`) 等异构字段(见 `api/app/core/rag/res/mapping.json:25-209`)。专用向量库无法承载这种混合 schema。
|
||||
3. **运维与生态成本** — 团队仅运行 PostgreSQL / Neo4j / Redis / ES(README "Prerequisites"),引入第二套向量服务会显著抬高运维曲线。`@singleton` 的 `ESConnection` (`api/app/core/rag/utils/es_conn.py:26-56`) 与 `ElasticSearchVectorFactory._client` (`api/app/core/rag/vdb/elasticsearch/elasticsearch_vector.py:666-732`) 共享连接,工程上已经按"单实例多用途"在使用 ES。
|
||||
|
||||
> 代价:ES 的 ANN 在百万-千万 chunk 时延迟会明显高于 Milvus/Qdrant;当未来 chunk 量级或 QPS 显著增长时,本架构需要拆出独立向量服务(详见 §6 优化建议)。
|
||||
|
||||
### 2.2 ES 版本约束
|
||||
|
||||
启动期硬性校验 ES 必须 ≥ 8.0:
|
||||
|
||||
```python
|
||||
# api/app/core/rag/utils/es_conn.py:44-49
|
||||
v = self.info.get("version", {"number": "8.0.0"})
|
||||
v = v["number"].split(".")[0]
|
||||
if int(v) < 8:
|
||||
msg = f"Elasticsearch version must be greater than or equal to 8, current version: {v}"
|
||||
logger.error(msg)
|
||||
raise Exception(msg)
|
||||
```
|
||||
|
||||
```python
|
||||
# api/app/core/rag/vdb/elasticsearch/elasticsearch_vector.py:716-722
|
||||
if not cls._version_checked:
|
||||
info = client.info()
|
||||
version = info["version"]["number"]
|
||||
if parse_version(version) < parse_version("8.0.0"):
|
||||
raise ValueError(f"Elasticsearch version must be >= 8.0.0, got {version}")
|
||||
```
|
||||
|
||||
> **why**:ES 8.0 才正式提供 `dense_vector` HNSW 索引、`knn` 顶层查询、以及 query_string + knn 的混合检索,本系统的 `s.knn(...)`、`type=dense_vector index=true similarity=cosine` 都依赖该版本。
|
||||
|
||||
---
|
||||
|
||||
## 三、模块结构与两条实现路径
|
||||
|
||||
```
|
||||
api/app/core/rag/
|
||||
├── res/mapping.json ← graphrag_{workspace_id} 索引使用的全局 mapping
|
||||
├── utils/
|
||||
│ ├── doc_store_conn.py ← 抽象接口 DocStoreConnection + MatchExpr / FusionExpr
|
||||
│ └── es_conn.py ← @singleton 实现 DocStoreConnection(路径 A)
|
||||
├── vdb/
|
||||
│ ├── vector_base.py ← 抽象接口 BaseVector
|
||||
│ ├── field.py ← page_content / metadata / vector 等字段名常量
|
||||
│ └── elasticsearch/
|
||||
│ └── elasticsearch_vector.py ← BaseVector 的 ES 实现(路径 B)
|
||||
├── nlp/
|
||||
│ ├── search.py ← 同时承载两条路径:knowledge_retrieval(路径 B)+ Dealer(路径 A)
|
||||
│ └── query.py ← FulltextQueryer,构造 Lucene query_string
|
||||
└── common/
|
||||
├── settings.py ← 全局初始化 docStoreConn / retriever / kg_retriever
|
||||
└── constants.py ← PAGERANK_FLD / TAG_FLD 等常量
|
||||
```
|
||||
|
||||
### 3.1 路径 A:`ESConnection`(DSL 抽象层,主要服务于 GraphRAG 与高级检索)
|
||||
|
||||
- 抽象基类:`api/app/core/rag/utils/doc_store_conn.py:128-256` 定义 `DocStoreConnection` 接口(dbType / createIdx / search / insert / update / delete / sql 等)。
|
||||
- 表达式族:同文件 43-126 行定义 `MatchTextExpr`、`MatchDenseExpr`、`MatchSparseExpr`、`MatchTensorExpr`、`FusionExpr`、`OrderByExpr` —— 这是上层与底层解耦的"查询 IR"。
|
||||
- ES 实现:`@singleton class ESConnection(DocStoreConnection)`(`api/app/core/rag/utils/es_conn.py:26-634`)。
|
||||
- 全局入口:`api/app/core/rag/common/settings.py:13-24` 在模块导入时即 `init_settings()`,把 `ESConnection()` 装进 `docStoreConn`,并注入 `Dealer` / `KGSearch`。
|
||||
- 对应的检索门面:`api/app/core/rag/nlp/search.py: Dealer`(350-907 行),由 `kg_retriever`、`retriever` 全局共用。
|
||||
|
||||
### 3.2 路径 B:`ElasticSearchVector`(应用层 BaseVector,主要服务于 KB 节点 / 工作流)
|
||||
|
||||
- 抽象基类:`api/app/core/rag/vdb/vector_base.py:9-67` 定义 `BaseVector` 接口(create / add_texts / search_by_vector / search_by_full_text / delete 等)。
|
||||
- 字段命名:`api/app/core/rag/vdb/field.py`:`page_content` / `metadata` / `vector` / `metadata.doc_id` 等。
|
||||
- ES 实现:`class ElasticSearchVector(BaseVector)` + `class ElasticSearchVectorFactory`(`api/app/core/rag/vdb/elasticsearch/elasticsearch_vector.py:29-732`)。
|
||||
- 关键调用方:
|
||||
- `api/app/core/workflow/nodes/knowledge/node.py:195-298` 工作流知识节点,按 `RetrieveType` 分支调用 `search_by_vector / search_by_full_text`。
|
||||
- `api/app/core/rag/nlp/search.py: knowledge_retrieval`(36-147 行)API/服务层入口。
|
||||
- `api/app/services/memory_konwledges_server.py`、`api/app/controllers/{chunk,document,knowledge}_controller.py` 等。
|
||||
|
||||
### 3.3 两条路径的边界
|
||||
|
||||
| 维度 | 路径 A(ESConnection / Dealer) | 路径 B(ElasticSearchVector) |
|
||||
| --- | --- | --- |
|
||||
| 索引名 | `graphrag_{workspace_id}` | `Vector_index_{kb_id}_Node`(小写)|
|
||||
| 索引粒度 | 一个 workspace 一个 ES index,多 KB 用 `kb_id` 字段过滤 | 一个 KB 一个 ES index |
|
||||
| Mapping | `res/mapping.json` 全局 dynamic_templates | 代码里 inline 的 `index_mapping` (`elasticsearch_vector.py:616-661`) |
|
||||
| 文本字段 | `content_ltks` / `content_sm_ltks` / `title_tks` / `important_kwd` / `*_tks` | `page_content` (`text` + `ik_max_word`) |
|
||||
| 向量字段 | 动态 `q_{dim}_vec` (`*_512_vec` / `*_768_vec` / `*_1024_vec` / `*_1536_vec`) | 固定 `vector`(dim 取首批 embeddings 长度)|
|
||||
| 关键词检索 | Lucene `query_string`(field-boost、同义词、短语)| `match` + `analyzer=ik_max_word`(BM25)|
|
||||
| 向量检索 | `s.knn(...)`(HNSW,ES 8 原生 ANN)| `script_score` + `cosineSimilarity`(暴力,但精度高)|
|
||||
| 混合融合 | `FusionExpr("weighted_sum", weights="0.05,0.95")` 应用层加权 + ES 内部混合 | 双路并发查 → metadata.doc_id 去重 → 可选 reranker |
|
||||
| 主要使用者 | GraphRAG、`Dealer.retrieval()`、tag/citation 等高级能力 | 工作流知识节点、KB CRUD、召回测试 |
|
||||
|
||||
> **why 不合并**:路径 A 携带丰富 IR(同义词扩展、`tag_feas`、`pagerank_fea`、`question_tks` 等),是面向"知识图谱 + 复杂 RAG"的;路径 B 简单直接,是工作流/服务层的"够用就好"封装。代码上是渐进演化中的双轨,但**目前两条路径都在生产使用**。
|
||||
|
||||
---
|
||||
|
||||
## 四、索引设计
|
||||
|
||||
### 4.1 全局 mapping(路径 A,`api/app/core/rag/res/mapping.json`)
|
||||
|
||||
#### 4.1.1 settings
|
||||
|
||||
```json
|
||||
// api/app/core/rag/res/mapping.json:2-15
|
||||
"settings": {
|
||||
"index": {
|
||||
"number_of_shards": 2,
|
||||
"number_of_replicas": 0,
|
||||
"refresh_interval": "1000ms"
|
||||
},
|
||||
"similarity": {
|
||||
"scripted_sim": {
|
||||
"type": "scripted",
|
||||
"script": {
|
||||
"source": "double idf = Math.log(1+(field.docCount-term.docFreq+0.5)/(term.docFreq + 0.5))/Math.log(1+((field.docCount-0.5)/1.5)); return query.boost * idf * Math.min(doc.freq, 1);"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 项 | 值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `number_of_shards` | 2 | 适合中小型部署;超过 50GB / 单 shard 时需重新规划 |
|
||||
| `number_of_replicas` | 0 | **生产风险点**:单副本意味着任一分片丢失即数据丢失,建议生产环境改为 ≥1 |
|
||||
| `refresh_interval` | 1000ms | 默认 1s 即可见,写入吞吐场景可调高至 30s 或写入期 `-1` |
|
||||
| `scripted_sim` | 自定义 BM25 变体 | 用 `Math.min(doc.freq, 1)` 把词频压成 0/1,等价于 binary BM25——抑制高 TF 的关键字"灌水",对 token 字段更鲁棒 |
|
||||
|
||||
#### 4.1.2 dynamic_templates(按字段名后缀决定字段类型)
|
||||
|
||||
```json
|
||||
// api/app/core/rag/res/mapping.json:25-209(节选)
|
||||
{ "int": { "match": "*_int", "mapping": { "type": "integer", "store": "true" }}},
|
||||
{ "ulong": { "match": "*_ulong", "mapping": { "type": "unsigned_long" }}},
|
||||
{ "long": { "match": "*_long", "mapping": { "type": "long" }}},
|
||||
{ "numeric": { "match": "*_flt", "mapping": { "type": "float" }}},
|
||||
|
||||
{ "tks": { "match": "*_tks", "mapping": { "type": "text", "similarity": "scripted_sim", "analyzer": "whitespace" }}},
|
||||
{ "ltks": { "match": "*_ltks", "mapping": { "type": "text", "analyzer": "whitespace" }}},
|
||||
|
||||
{ "kwd": { "match_pattern": "regex",
|
||||
"match": "^(.*_(kwd|id|ids|uid|uids)|uid)$",
|
||||
"mapping": { "type": "keyword", "similarity": "boolean" }}},
|
||||
{ "dt": { "match_pattern": "regex",
|
||||
"match": "^.*(_dt|_time|_at)$",
|
||||
"mapping": { "type": "date", "format": "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||yyyy-MM-dd_HH:mm:ss" }}},
|
||||
|
||||
{ "rank_feature": { "match": "*_fea", "mapping": { "type": "rank_feature" }}},
|
||||
{ "rank_features": { "match": "*_feas", "mapping": { "type": "rank_features" }}},
|
||||
|
||||
{ "dense_vector": { "match": "*_512_vec", "mapping": { "type": "dense_vector", "index": true, "similarity": "cosine", "dims": 512 }}},
|
||||
{ "dense_vector": { "match": "*_768_vec", "mapping": { "type": "dense_vector", "index": true, "similarity": "cosine", "dims": 768 }}},
|
||||
{ "dense_vector": { "match": "*_1024_vec", "mapping": { "type": "dense_vector", "index": true, "similarity": "cosine", "dims": 1024 }}},
|
||||
{ "dense_vector": { "match": "*_1536_vec", "mapping": { "type": "dense_vector", "index": true, "similarity": "cosine", "dims": 1536 }}},
|
||||
|
||||
{ "nested": { "match": "*_nst", "mapping": { "type": "nested" }}},
|
||||
{ "binary": { "match": "*_bin", "mapping": { "type": "binary" }}}
|
||||
```
|
||||
|
||||
**why dynamic 而不是 strict mapping**:
|
||||
|
||||
- 不同 embedding 模型维度不同(512/768/1024/1536),通过字段名后缀让"模型即维度",在 `nlp/search.py:372` 看到查询侧动态拼名 `f"q_{len(embedding_data)}_vec"`,写入侧也是同样命名,零配置切换 embedding。
|
||||
- token 字段分 `*_tks` 与 `*_ltks`:前者使用 `scripted_sim`(去 TF),用于 important_kwd 这类"命中即可"字段;后者 BM25 默认,用于正文型 `content_ltks`。
|
||||
- `*_fea` (rank_feature) 与 `*_feas` (rank_features) 用于 PageRank 与 tag 加权,详见检索章节的 `_rank_feature_scores`。
|
||||
|
||||
> **why analyzer 是 `whitespace` 而不是 IK**:路径 A 在写入前先用 `rag_tokenizer` 在应用层做完中文分词,写入 ES 时已经是空格分隔的 tokens。这样"分词逻辑"留在应用层,便于热更新词典与同义词,不用 reindex。
|
||||
|
||||
### 4.2 路径 B 的 inline mapping(KB 索引)
|
||||
|
||||
```python
|
||||
# api/app/core/rag/vdb/elasticsearch/elasticsearch_vector.py:609-663
|
||||
def create_collection(self, embeddings, metadatas=None, index_params=None):
|
||||
if not self._client.indices.exists(index=self._collection_name):
|
||||
index_mapping = {
|
||||
"mappings": {
|
||||
"properties": {
|
||||
Field.CONTENT_KEY.value: { # "page_content"
|
||||
"type": "text",
|
||||
"analyzer": "ik_max_word"
|
||||
},
|
||||
Field.METADATA_KEY.value: { # "metadata"
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"doc_id": {"type": "keyword"},
|
||||
"file_id": {"type": "keyword"},
|
||||
"file_name": {"type": "keyword"},
|
||||
"file_created_at": {"type": "date", "format": "epoch_millis"},
|
||||
"document_id": {"type": "keyword"},
|
||||
"knowledge_id": {"type": "keyword"},
|
||||
"sort_id": {"type": "long"},
|
||||
"status": {"type": "integer"}
|
||||
}
|
||||
},
|
||||
Field.VECTOR.value: { # "vector"
|
||||
"type": "dense_vector",
|
||||
"dims": len(embeddings[0]),
|
||||
"index": True,
|
||||
"similarity": "cosine"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self._client.indices.create(index=self._collection_name, body=index_mapping)
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- 索引按 KB 隔离:`collection_name = f"Vector_index_{knowledge.id}_Node"`(同文件 738 行),ES 端要求小写,所以 `super().__init__(index_name.lower())`(32 行)。
|
||||
- `dims = len(embeddings[0])` —— 维度由"第一批数据"决定,**一旦确定不可改**。换 embedding 模型必须重建索引(详见 §6 风险点)。
|
||||
- `similarity = "cosine"` —— 写入向量不要求归一化,由 ES 内部计算余弦相似度。
|
||||
- 没有显式 `number_of_shards`/`replicas` 设置,**走 ES 集群默认**(8.x 默认 1 shard 1 replica),可用性比路径 A 反而更好;但碎片化风险也更高(每个 KB 一个 index,KB 多了 cluster state 会膨胀)。
|
||||
|
||||
### 4.3 索引命名与隔离
|
||||
|
||||
| 路径 | 索引模板 | 来源 |
|
||||
| --- | --- | --- |
|
||||
| A | `graphrag_{workspace_id}` | `nlp/search.py:346` `def index_name(uid): return f"graphrag_{uid}"` |
|
||||
| B | `Vector_index_{kb_id}_Node`(小写)| `elasticsearch_vector.py:738` |
|
||||
|
||||
路径 A 在删除知识库时**故意不删 ES 索引**,而是仅删 `kb_id` 维度的文档:
|
||||
|
||||
```python
|
||||
# api/app/core/rag/utils/es_conn.py:115-124
|
||||
def deleteIdx(self, indexName: str, knowledgebaseId: str):
|
||||
if len(knowledgebaseId) > 0:
|
||||
# The index need to be alive after any kb deletion since all kb under this workspace are in one index.
|
||||
return
|
||||
try:
|
||||
self.es.indices.delete(index=indexName, allow_no_indices=True)
|
||||
except NotFoundError:
|
||||
pass
|
||||
```
|
||||
|
||||
> **why**:一个 workspace 多 KB 共享同一个 index,单 KB 删除不能动 index;只能在 `delete()` 通过 `condition["kb_id"]=knowledgebaseId` 走 delete-by-query(同文件 424-471)。
|
||||
|
||||
---
|
||||
|
||||
## 五、写入链路
|
||||
|
||||
### 5.1 路径 B:高层封装(KB / 工作流场景)
|
||||
|
||||
```python
|
||||
# api/app/core/rag/vdb/elasticsearch/elasticsearch_vector.py:55-87
|
||||
def add_chunks(self, chunks: list[DocumentChunk], **kwargs):
|
||||
texts = [chunk.page_content for chunk in chunks]
|
||||
if self.is_multimodal_embedding:
|
||||
embeddings = self.embeddings.embed_batch(texts)
|
||||
else:
|
||||
embeddings = self.embeddings.embed_documents(list(texts))
|
||||
self.create(chunks, embeddings, **kwargs)
|
||||
|
||||
def create(self, chunks, embeddings, **kwargs):
|
||||
metadatas = [chunk.metadata or {} for chunk in chunks]
|
||||
if not self._client.indices.exists(index=self._collection_name):
|
||||
self.create_collection(embeddings, metadatas) # 懒建索引
|
||||
self.add_texts(chunks, embeddings, **kwargs)
|
||||
|
||||
def add_texts(self, chunks, embeddings, **kwargs):
|
||||
uuids = self._get_uuids(chunks)
|
||||
actions = []
|
||||
for i, chunk in enumerate(chunks):
|
||||
actions.append({
|
||||
"_index": self._collection_name,
|
||||
"_source": {
|
||||
Field.CONTENT_KEY.value: chunk.page_content,
|
||||
Field.METADATA_KEY.value: chunk.metadata or {},
|
||||
Field.VECTOR.value: embeddings[i] or None,
|
||||
}
|
||||
})
|
||||
result = helpers.bulk(self._client, actions)
|
||||
return uuids
|
||||
```
|
||||
|
||||
特性:
|
||||
|
||||
- **懒建索引**:第一次写入时根据 `len(embeddings[0])` 建 mapping。
|
||||
- **批量写**:`elasticsearch.helpers.bulk` 默认 chunk_size=500、max_chunk_bytes=100MB;这里不传 `_id`,ES 自动生成。
|
||||
- **唯一性**:路径 B 把 chunk 唯一标识放在 `metadata.doc_id`(`vector_base.py:62-63 _get_uuids`),更新/删除走"先 search by metadata.doc_id 拿真正 _id 再 bulk delete"两步走(`elasticsearch_vector.py:148-174`)。
|
||||
- **失败处理**:`helpers.bulk` 默认抛 `BulkIndexError`,调用方在 `delete_by_ids / delete_by_metadata_field` 中分桶捕获 404 与其它错误(同文件 137-147、164-174)。**`add_texts` 没有捕获**——一旦底层网络失败会向上抛,调用方需要保证幂等性或重试。
|
||||
|
||||
### 5.2 路径 A:抽象层批量写
|
||||
|
||||
```python
|
||||
# api/app/core/rag/utils/es_conn.py:294-330
|
||||
def insert(self, documents, indexName, knowledgebaseId=None) -> list[str]:
|
||||
operations = []
|
||||
for d in documents:
|
||||
assert "_id" not in d
|
||||
assert "id" in d
|
||||
d_copy = copy.deepcopy(d)
|
||||
d_copy["kb_id"] = knowledgebaseId
|
||||
meta_id = d_copy.pop("id", "")
|
||||
operations.append({"index": {"_index": indexName, "_id": meta_id}})
|
||||
operations.append(d_copy)
|
||||
|
||||
res = []
|
||||
for _ in range(ATTEMPT_TIME): # 默认 2 次
|
||||
try:
|
||||
r = self.es.bulk(index=indexName, operations=operations,
|
||||
refresh=False, timeout="60s")
|
||||
if re.search(r"False", str(r["errors"]), re.IGNORECASE):
|
||||
return res
|
||||
for item in r["items"]:
|
||||
for action in ["create", "delete", "index", "update"]:
|
||||
if action in item and "error" in item[action]:
|
||||
res.append(str(item[action]["_id"]) + ":" + str(item[action]["error"]))
|
||||
return res
|
||||
except ConnectionTimeout:
|
||||
time.sleep(3); self._connect(); continue
|
||||
except Exception as e:
|
||||
res.append(str(e)); break
|
||||
return res
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- **显式 `_id = id`**:调用方自己保证 chunk_id 唯一(典型实现:`uuid4()` 或基于 `doc_id+chunk_idx` 的稳定 hash),重复写入即"覆盖式更新",天然支持幂等重试。
|
||||
- **强制注入 `kb_id`**:所有 chunk 都打上 `kb_id` 标签,作为多租户隔离与 delete-by-query 的依据。
|
||||
- **refresh=False**:写入不等可见,吞吐优先;查询侧通过 1s 默认 refresh 间隔获得近实时性。
|
||||
- **显式 timeout="60s"** + ATTEMPT_TIME=2 重连 —— 网络抖动会自动重试一次。
|
||||
- **失败回滚?** 只返回失败列表,**不做事务回滚**。这是 ES 的典型用法:bulk 是 best-effort,调用方需要根据返回值决定是否补偿(如 chunker 重新生成失败 chunk)。
|
||||
|
||||
### 5.3 增量更新(路径 A)
|
||||
|
||||
```python
|
||||
# api/app/core/rag/utils/es_conn.py:332-422
|
||||
def update(self, condition, newValue, indexName, knowledgebaseId) -> bool:
|
||||
# 单文档 update
|
||||
if "id" in condition and isinstance(condition["id"], str):
|
||||
chunkId = condition["id"]
|
||||
# 删除字段(带 _feas 后缀的 rank_features 必须先 remove 再 set,否则旧 token 残留)
|
||||
for k in doc.keys():
|
||||
if k.split("_")[-1] == "feas":
|
||||
self.es.update(index=indexName, id=chunkId, script=f"ctx._source.remove(\"{k}\");")
|
||||
self.es.update(index=indexName, id=chunkId, doc=doc)
|
||||
return True
|
||||
|
||||
# 批量 update_by_query:构造 painless 脚本
|
||||
bqry = Q("bool")
|
||||
# ... 把 condition 转成 filter
|
||||
scripts = []; params = {}
|
||||
for k, v in newValue.items():
|
||||
if k == "remove": # remove 单个 list 元素
|
||||
scripts.append(f"int i=ctx._source.{kk}.indexOf(params.p_{kk});ctx._source.{kk}.remove(i);")
|
||||
elif k == "add": # 向 list 追加
|
||||
scripts.append(f"ctx._source.{kk}.add(params.pp_{kk});")
|
||||
elif isinstance(v, str):
|
||||
v = re.sub(r"(['\n\r]|\\.)", " ", v) # 防止脚本注入
|
||||
scripts.append(f"ctx._source.{k}=params.pp_{k};")
|
||||
...
|
||||
ubq = UpdateByQuery(index=indexName).using(self.es).query(bqry)\
|
||||
.script(source="".join(scripts), params=params)\
|
||||
.params(refresh=True, slices=5, conflicts="proceed")
|
||||
ubq.execute()
|
||||
```
|
||||
|
||||
亮点:
|
||||
|
||||
- **slices=5** —— 并行 update-by-query,写吞吐放大 5 倍。
|
||||
- **conflicts="proceed"** —— 遇到版本冲突跳过而不中止任务;适合"标签批量更新"这种最终一致场景。
|
||||
- **rank_features 必须先 remove**:因为 `*_feas` 是"key→score"字典,新值无法覆盖旧 key(341-346 行的 patch)。
|
||||
- **input sanitation**:对 string 值做 `re.sub(r"(['\n\r]|\\.)", " ", v)` 防止 painless 脚本注入。
|
||||
|
||||
### 5.4 路径 B 的 update_by_query
|
||||
|
||||
```python
|
||||
# api/app/core/rag/vdb/elasticsearch/elasticsearch_vector.py:299-342
|
||||
def update_by_segment(self, chunk: DocumentChunk, **kwargs) -> str:
|
||||
if self.is_multimodal_embedding:
|
||||
chunk.vector = self.embeddings.embed_text(chunk.page_content)
|
||||
else:
|
||||
chunk.vector = self.embeddings.embed_query(chunk.page_content)
|
||||
body = {
|
||||
"script": {
|
||||
"source": """
|
||||
ctx._source.page_content = params.new_content;
|
||||
ctx._source.vector = params.new_vector;
|
||||
""",
|
||||
"params": {"new_content": chunk.page_content, "new_vector": chunk.vector}
|
||||
},
|
||||
"query": {"term": {Field.DOC_ID.value: chunk.metadata["doc_id"]}}
|
||||
}
|
||||
return self._client.update_by_query(index=indices, body=body)['updated']
|
||||
```
|
||||
|
||||
注意:`metadata.doc_id`(关键字段) 一查多匹配 → 全部刷新内容与向量。这是路径 B 的"chunk 更新"语义,**没有版本控制**,并发更新会以最后写入为准;需要严格控制时应在调用方加锁或退化为先 `delete_by_ids` 再 `add_chunks`。
|
||||
|
||||
---
|
||||
|
||||
## 六、检索链路
|
||||
|
||||
### 6.1 三种检索类型(应用层枚举)
|
||||
|
||||
```python
|
||||
# api/app/schemas/chunk_schema.py:8-13
|
||||
class RetrieveType(StrEnum):
|
||||
PARTICIPLE = "participle" # 关键词 / 分词检索(BM25)
|
||||
SEMANTIC = "semantic" # 语义 / 向量检索(cosine)
|
||||
HYBRID = "hybrid" # 混合检索:双路 + 去重 (+ rerank)
|
||||
Graph = "graph" # 在 hybrid 之上叠加 GraphRAG 检索
|
||||
```
|
||||
|
||||
在 `api/app/core/workflow/nodes/knowledge/node.py:213-298` 与 `api/app/core/rag/nlp/search.py:220-281` 两处可以看到完全一致的三分支 + 默认走 hybrid 的派发逻辑。
|
||||
|
||||
### 6.2 关键词检索(路径 B:BM25 + IK)
|
||||
|
||||
```python
|
||||
# api/app/core/rag/vdb/elasticsearch/elasticsearch_vector.py:468-558(节选)
|
||||
def search_by_full_text(self, query: str, **kwargs) -> list[DocumentChunk]:
|
||||
top_k = kwargs.get("top_k", 1024)
|
||||
score_threshold = float(kwargs.get("score_threshold") or 0.2)
|
||||
file_names_filter = kwargs.get("file_names_filter")
|
||||
|
||||
query_str = {
|
||||
"bool": {
|
||||
"must": {
|
||||
"match": {
|
||||
Field.CONTENT_KEY.value: {
|
||||
"query": query,
|
||||
"analyzer": "ik_max_word" # 与建索引时一致
|
||||
}
|
||||
}
|
||||
},
|
||||
"filter": {"term": {"metadata.status": 1}} # 只看启用状态
|
||||
}
|
||||
}
|
||||
# 可选叠加 file_name 多选过滤
|
||||
if file_names_filter:
|
||||
query_str["bool"]["filter"] = [
|
||||
{"term": {"metadata.status": 1}},
|
||||
{"terms": {"metadata.file_name": file_names_filter}}
|
||||
]
|
||||
|
||||
result = self._client.search(index=indices, from_=0, size=top_k, query=query_str)
|
||||
max_score = result["hits"]["max_score"] or 1.0
|
||||
docs_and_scores = []
|
||||
for res in result["hits"]["hits"]:
|
||||
normalized_score = res["_score"] / max_score # 归一化到 [0,1]
|
||||
...
|
||||
return [doc for doc, score in docs_and_scores if score > score_threshold]
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- BM25 默认相似度,`ik_max_word` 中文分词;写入与查询使用同一 analyzer,避免分词错位。
|
||||
- **score 归一化**:BM25 score 是开放区间,除以 `max_score` 缩放到 [0,1],便于与 `score_threshold` 比较,也便于和向量分数同尺度对齐。
|
||||
- 默认 `score_threshold=0.2`、`top_k=1024`。
|
||||
|
||||
### 6.3 关键词检索(路径 A:query_string + 同义词扩展)
|
||||
|
||||
```python
|
||||
# api/app/core/rag/nlp/query.py:69-201(节选)
|
||||
class FulltextQueryer:
|
||||
query_fields = [
|
||||
"title_tks^10", "title_sm_tks^5",
|
||||
"important_kwd^30", "important_tks^20",
|
||||
"question_tks^20",
|
||||
"content_ltks^2", "content_sm_ltks",
|
||||
]
|
||||
|
||||
def question(self, txt, tbl="qa", min_match: float = 0.6):
|
||||
txt = self.add_space_between_eng_zh(txt) # 中英分词预处理
|
||||
txt = self.rmWWW(txt) # 去问句词(怎么/吗/啥/what/how/...)
|
||||
...
|
||||
# 中文分支:term_weight 权重 + synonym 同义词扩展
|
||||
for tt in self.tw.split(txt)[:256]:
|
||||
twts = self.tw.weights([tt])
|
||||
syns = self.syn.lookup(tt)
|
||||
tk_syns = [f"({tk} OR (%s)^0.2)" % " ".join(tk_syns), ...] # 同义词权重 0.2
|
||||
tms.append((tk, w))
|
||||
query = " OR ".join([f"({t})" for t in qs if t])
|
||||
return MatchTextExpr(self.query_fields, query, 100,
|
||||
{"minimum_should_match": min_match}), keywords
|
||||
```
|
||||
|
||||
```python
|
||||
# api/app/core/rag/utils/es_conn.py:196-217
|
||||
for m in matchExprs:
|
||||
if isinstance(m, MatchTextExpr):
|
||||
minimum_should_match = m.extra_options.get("minimum_should_match", 0.0)
|
||||
if isinstance(minimum_should_match, float):
|
||||
minimum_should_match = str(int(minimum_should_match * 100)) + "%"
|
||||
bqry.must.append(Q("query_string", fields=m.fields,
|
||||
type="best_fields", query=m.matching_text,
|
||||
minimum_should_match=minimum_should_match,
|
||||
boost=1))
|
||||
bqry.boost = 1.0 - vector_similarity_weight
|
||||
```
|
||||
|
||||
亮点:
|
||||
|
||||
- **多字段 field-boost**:`important_kwd^30` 表示标签字段权重远高于正文,符合"重要标签命中即高排名"的直觉。
|
||||
- **同义词加权 0.2**:同义词召回但低权重,避免"同义词稀释"主体相关性。
|
||||
- **minimum_should_match**:默认 0.3 / 0.6,控制 BM25 召回的"严苛度"。当 hybrid 总命中为 0 时会 fallback 到 0.1 重试(详见 6.7)。
|
||||
- **`type="best_fields"`**:多字段场景取每字段最高分作为最终分,符合"标题命中比正文命中更重要"的语义。
|
||||
|
||||
### 6.4 向量检索(路径 B:script_score + cosine)
|
||||
|
||||
```python
|
||||
# api/app/core/rag/vdb/elasticsearch/elasticsearch_vector.py:374-466(节选)
|
||||
def search_by_vector(self, query: str, **kwargs) -> list[DocumentChunk]:
|
||||
if self.is_multimodal_embedding:
|
||||
query_vector = self.embeddings.embed_text(query)
|
||||
else:
|
||||
query_vector = self.embeddings.embed_query(query)
|
||||
top_k = kwargs.get("top_k", 1024)
|
||||
score_threshold = float(kwargs.get("score_threshold") or 0.3)
|
||||
file_names_filter = kwargs.get("file_names_filter")
|
||||
|
||||
query_str = {
|
||||
"bool": {
|
||||
"must": {
|
||||
"script_score": {
|
||||
"query": {"match_all": {}},
|
||||
"script": {
|
||||
# cosineSimilarity 范围 [-1,1],+1 后落到 [0,2]
|
||||
"source": f"cosineSimilarity(params.query_vector, '{Field.VECTOR.value}') + 1.0",
|
||||
"params": {"query_vector": query_vector}
|
||||
}
|
||||
}
|
||||
},
|
||||
"filter": {"term": {"metadata.status": 1}}
|
||||
}
|
||||
}
|
||||
|
||||
result = self._client.search(index=indices, from_=0, size=top_k, query=query_str)
|
||||
docs_and_scores = []
|
||||
for res in result["hits"]["hits"]:
|
||||
score = res["_score"] / 2 # [0,2] -> [0,1]
|
||||
docs_and_scores.append((..., score))
|
||||
return [doc for doc, score in docs_and_scores if score > score_threshold]
|
||||
```
|
||||
|
||||
特性与权衡:
|
||||
|
||||
- **script_score 是暴力扫描**:会对 `match_all` 命中的所有文档(叠加 status=1 filter 后)逐一算 cosine,复杂度 O(N·dim)。优点是结果**精确**、无 ANN 召回率损失;缺点是延迟随 KB chunk 数线性增长,不适合 chunk 量级大的 KB。
|
||||
- **score 归一化**:`(cos+1)/2 ∈ [0,1]`,与 BM25 归一化值同尺度。
|
||||
- **过滤集成**:`metadata.status=1` 在 filter 上,先过滤再算分;`file_names_filter` 同理。
|
||||
|
||||
### 6.5 向量检索(路径 A:knn + filter)
|
||||
|
||||
```python
|
||||
# api/app/core/rag/utils/es_conn.py:206-217
|
||||
elif isinstance(m, MatchDenseExpr):
|
||||
similarity = m.extra_options.get("similarity", 0.0)
|
||||
s = s.knn(
|
||||
m.vector_column_name,
|
||||
m.topn,
|
||||
m.topn * 2, # num_candidates = 2 * k,控制召回率
|
||||
query_vector=list(m.embedding_data),
|
||||
filter=bqry.to_dict(), # 与 BM25 同一份 bool filter
|
||||
# similarity=similarity # 已注释:未启用阈值剪枝
|
||||
)
|
||||
```
|
||||
|
||||
```python
|
||||
# api/app/core/rag/nlp/search.py:365-373
|
||||
def get_vector(self, txt, emb_mdl, topk=10, similarity=0.1):
|
||||
qv, _ = emb_mdl.encode_queries(txt)
|
||||
embedding_data = [get_float(v) for v in qv]
|
||||
vector_column_name = f"q_{len(embedding_data)}_vec" # 动态选维度
|
||||
return MatchDenseExpr(vector_column_name, embedding_data,
|
||||
'float', 'cosine', topk, {"similarity": similarity})
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- **HNSW ANN**:路径 A 用的是 ES 8 原生 `knn` query,底层 HNSW 索引,毫秒级,但有近似召回率损失。
|
||||
- **k vs num_candidates**:`topn * 2` 即 ANN 阶段先取 2k 候选再精排到 k,是召回率与延迟的折中。生产建议至少 `4 * topn`,更高召回。
|
||||
- **filter 共享**:`filter=bqry.to_dict()`——把 BM25 那份 bool filter 同时挂在 knn 上,确保过滤条件在 ANN 内部应用(pre-filter);这点对多租户 `kb_id` 隔离尤为关键,否则 ANN 先取 top-k 再过滤,可能完全不返回该 KB 的文档。
|
||||
- **similarity 阈值已注释**:当前不启用 ES 内置阈值剪枝;需要按相似度阈值过滤的话,由应用层 (`Dealer.retrieval`) 在 rerank 阶段做。
|
||||
|
||||
### 6.6 混合搜索 —— 这是本节最关键的"融合公式"
|
||||
|
||||
#### 6.6.1 路径 A:`FusionExpr("weighted_sum")` + ES 内部混合(**核心融合点**)
|
||||
|
||||
```python
|
||||
# api/app/core/rag/nlp/search.py:435-445
|
||||
matchDense = self.get_vector(qst, emb_mdl, topk, req.get("similarity", 0.1))
|
||||
q_vec = matchDense.embedding_data
|
||||
src.append(f"q_{len(q_vec)}_vec")
|
||||
|
||||
fusionExpr = FusionExpr("weighted_sum", topk, {"weights": "0.05,0.95"})
|
||||
matchExprs = [matchText, matchDense, fusionExpr]
|
||||
|
||||
res = self.dataStore.search(src, highlightFields, filters, matchExprs, orderBy,
|
||||
offset, limit, idx_names, kb_ids, rank_feature=rank_feature)
|
||||
```
|
||||
|
||||
```python
|
||||
# api/app/core/rag/utils/es_conn.py:186-218
|
||||
s = Search()
|
||||
vector_similarity_weight = 0.5
|
||||
for m in matchExprs:
|
||||
if isinstance(m, FusionExpr) and m.method == "weighted_sum" and "weights" in m.fusion_params:
|
||||
# 必须按 [text, dense, fusion] 顺序传入
|
||||
assert len(matchExprs) == 3 and isinstance(matchExprs[0], MatchTextExpr) \
|
||||
and isinstance(matchExprs[1], MatchDenseExpr) and isinstance(matchExprs[2], FusionExpr)
|
||||
weights = m.fusion_params["weights"]
|
||||
vector_similarity_weight = get_float(weights.split(",")[1]) # "0.05,0.95" -> 0.95
|
||||
|
||||
for m in matchExprs:
|
||||
if isinstance(m, MatchTextExpr):
|
||||
...
|
||||
bqry.must.append(Q("query_string", ..., boost=1))
|
||||
bqry.boost = 1.0 - vector_similarity_weight # text 整体 boost = 0.05
|
||||
elif isinstance(m, MatchDenseExpr):
|
||||
s = s.knn(m.vector_column_name, m.topn, m.topn * 2,
|
||||
query_vector=list(m.embedding_data), filter=bqry.to_dict())
|
||||
if bqry:
|
||||
s = s.query(bqry)
|
||||
```
|
||||
|
||||
**融合公式**(这是 [S2-T7] 评审要求"必须明确"的部分):
|
||||
|
||||
```
|
||||
final_score(doc) = (1 - w_vec) * BM25_query_string_score(doc)
|
||||
+ w_vec * knn_cosine_score(doc)
|
||||
+ Σ rank_feature_score(doc) ← PageRank + tag 加权(可选)
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `w_vec = 0.95`(来自 `FusionExpr` 的 `"weights": "0.05,0.95"` 第二个权重)。
|
||||
- BM25 整体 `bqry.boost = 0.05`,即 `query_string` 的 BM25 分数被乘 0.05;knn 的分数没有显式 boost,相当于权重 1.0,但语义上由调用方约定 0.95(**即代码层面是"BM25 直接乘 0.05,knn 不缩放",并未严格归一化到等比例**——这是一个已知近似,见 6.7 fallback)。
|
||||
- 排序逻辑:ES 8 的 hybrid 行为是"bool query 命中集 ∪ knn top-k 候选集",并集后用各自分数相加(未命中那侧分数为 0)。`elasticsearch-dsl Search` 的 `.query(...).knn(...)` 组合自动启用此模式。
|
||||
- `rank_feature` 通过 `bqry.should.append(Q("rank_feature", field=fld, linear={}, boost=sc))`(es_conn.py:219-223)以**加性**方式融入最终分。
|
||||
|
||||
> 这种"应用层约定 + ES 端 boost 缩放"的混合不是教科书式的归一化加权,但工程上简单:BM25 与 cosine 在统计上不同尺度,0.05/0.95 的**极端偏向语义**是为了"以语义检索为主、关键词作为补强"。
|
||||
|
||||
#### 6.6.2 路径 B:双路 + 去重 + 可选 Rerank
|
||||
|
||||
```python
|
||||
# api/app/core/workflow/nodes/knowledge/node.py:236-271
|
||||
case retrieve_type if retrieve_type in (RetrieveType.HYBRID, RetrieveType.Graph):
|
||||
rs1_task = asyncio.to_thread(vector_service.search_by_vector, **{
|
||||
"query": query, "top_k": kb_config.top_k,
|
||||
"indices": indices, "score_threshold": kb_config.vector_similarity_weight
|
||||
})
|
||||
rs2_task = asyncio.to_thread(vector_service.search_by_full_text, **{
|
||||
"query": query, "top_k": kb_config.top_k,
|
||||
"indices": indices, "score_threshold": kb_config.similarity_threshold
|
||||
})
|
||||
rs1, rs2 = await asyncio.gather(rs1_task, rs2_task) # 双路并发
|
||||
|
||||
unique_rs = self._deduplicate_docs(rs1, rs2) # 按 doc_id 去重
|
||||
if not unique_rs: return []
|
||||
if self.typed_config.reranker_id:
|
||||
rs.extend(await asyncio.to_thread(
|
||||
self.rerank, **{"query": query, "docs": unique_rs, "top_k": kb_config.top_k}))
|
||||
else:
|
||||
rs.extend(sorted(unique_rs,
|
||||
key=lambda d: d.metadata.get("score", 0),
|
||||
reverse=True)[:kb_config.top_k])
|
||||
```
|
||||
|
||||
```python
|
||||
# api/app/core/rag/nlp/search.py:236-261(同等逻辑的同步版)
|
||||
case _:
|
||||
rs1 = vector_service.search_by_vector(...)
|
||||
rs2 = vector_service.search_by_full_text(...)
|
||||
seen_ids = set(); unique_rs = []
|
||||
for doc in rs1 + rs2:
|
||||
if doc.metadata["doc_id"] not in seen_ids:
|
||||
seen_ids.add(doc.metadata["doc_id"])
|
||||
unique_rs.append(doc)
|
||||
rs = unique_rs
|
||||
if unique_rs:
|
||||
rs = vector_service.rerank(query=..., docs=unique_rs, top_k=...)
|
||||
```
|
||||
|
||||
**融合公式**(路径 B):
|
||||
|
||||
```
|
||||
candidates = vector_topk(q, w_v) ∪ bm25_topk(q, w_t) # 双路并发召回
|
||||
deduped = unique_by(metadata.doc_id, candidates) # 后到的丢弃
|
||||
if reranker:
|
||||
final = reranker(query, deduped)[:top_k] # 跨编码器重排
|
||||
else:
|
||||
final = sort_by_score_desc(deduped)[:top_k] # 各自归一化分数直接比
|
||||
```
|
||||
|
||||
> **why 不在路径 B 做加权融合**:路径 B 双路分数已分别归一化到 [0,1],但"BM25 归一化分"与"cosine 归一化分"之间**不可比**(一个是相对最大分,一个是绝对几何相似度)。直接把它们排序虽然不严谨,但通常依赖下游的 cross-encoder reranker 做最终排序,因此前置阶段以"召回多样性"为优先(vector 主召回 + BM25 补关键词),不再做权重融合。
|
||||
|
||||
### 6.7 兜底:低召回 fallback
|
||||
|
||||
```python
|
||||
# api/app/core/rag/nlp/search.py:447-459
|
||||
# If result is empty, try again with lower min_match
|
||||
if total == 0:
|
||||
if filters.get("document_id"):
|
||||
# 限定文档场景下,直接退化为"无关键词"召回
|
||||
res = self.dataStore.search(src, [], filters, [], orderBy, offset, limit, idx_names, kb_ids)
|
||||
total = self.dataStore.getTotal(res)
|
||||
else:
|
||||
matchText, _ = self.qryr.question(qst, min_match=0.1) # 0.3 -> 0.1
|
||||
matchDense.extra_options["similarity"] = 0.17 # 0.1 -> 0.17(提高语义阈值)
|
||||
res = self.dataStore.search(src, highlightFields, filters,
|
||||
[matchText, matchDense, fusionExpr],
|
||||
orderBy, offset, limit, idx_names, kb_ids,
|
||||
rank_feature=rank_feature)
|
||||
```
|
||||
|
||||
> **设计意图**:第一轮严格匹配(min_match=0.3)保证精度;命中为 0 时放宽 BM25 但提高向量阈值,等价于"换主导侧",避免空结果。
|
||||
|
||||
### 6.8 Rerank:模型重排 + 应用层混合相似度
|
||||
|
||||
`Dealer.rerank_by_model` 与 `Dealer.rerank` 是两套 reranker:
|
||||
|
||||
```python
|
||||
# api/app/core/rag/nlp/search.py:606-666
|
||||
def rerank(self, sres, query, tkweight=0.3, vtweight=0.7, ...):
|
||||
sim, tksim, vtsim = self.qryr.hybrid_similarity(
|
||||
sres.query_vector, ins_embd, keywords, ins_tw, tkweight, vtweight)
|
||||
return sim + rank_fea, tksim, vtsim
|
||||
|
||||
def rerank_by_model(self, rerank_mdl, sres, query, tkweight=0.3, vtweight=0.7, ...):
|
||||
tksim = self.qryr.token_similarity(keywords, ins_tw)
|
||||
vtsim, _ = rerank_mdl.similarity(query, [...])
|
||||
return tkweight * (np.array(tksim) + rank_fea) + vtweight * vtsim, tksim, vtsim
|
||||
```
|
||||
|
||||
```python
|
||||
# api/app/core/rag/nlp/query.py:203-211
|
||||
def hybrid_similarity(self, avec, bvecs, atks, btkss, tkweight=0.3, vtweight=0.7):
|
||||
sims = CosineSimilarity([avec], bvecs)
|
||||
tksim = self.token_similarity(atks, btkss)
|
||||
if np.sum(sims[0]) == 0:
|
||||
return np.array(tksim), tksim, sims[0]
|
||||
return np.array(sims[0]) * vtweight + np.array(tksim) * tkweight, tksim, sims[0]
|
||||
```
|
||||
|
||||
应用层重排公式:
|
||||
|
||||
```
|
||||
final_score = vtweight * cosine(q_vec, c_vec) + tkweight * token_sim(q, c) + rank_feature_score
|
||||
≈ 0.7 * vector_sim + 0.3 * keyword_sim + (PageRank + tag)
|
||||
```
|
||||
|
||||
注意 `Dealer.retrieval()`(674-768 行)调用时传入的是 `1 - vector_similarity_weight, vector_similarity_weight`,所以这两个权重由调用方(用户配置)决定,默认 0.3 / 0.7(`vector_similarity_weight=0.3` 见 678 行)。
|
||||
|
||||
### 6.9 top_k / 召回率 / 延迟权衡
|
||||
|
||||
| 阶段 | 默认值 | 含义 | 调参建议 |
|
||||
| --- | --- | --- | --- |
|
||||
| `top_k` (KB 节点) | 工作流配置 | 单 KB 单路召回数 | hybrid 模式建议 ≥ 50;语义高质 KB 可 30 |
|
||||
| `topn` / `topk` (Dealer) | 1024 (ann fallback),10 (默认) | knn 阶段 k | 与下游 RERANK_LIMIT 联动 |
|
||||
| `num_candidates` | `topn * 2` (es_conn.py:213) | HNSW 候选数,影响召回率 | 高召回场景改为 `4 * topn` |
|
||||
| `RERANK_LIMIT` | `ceil(64/page_size)*page_size` (search.py:683) | rerank 输入数 | 与显示页大小绑定,避免 rerank 过多 |
|
||||
| `score_threshold` (BM25) | 0.2 | 归一化后阈值 | 关键词强场景可调到 0.3 |
|
||||
| `score_threshold` (vector) | 0.3 | (cos+1)/2 后阈值 | 严苛去噪可到 0.5 |
|
||||
| `min_match` | 0.3,fallback 0.1 | BM25 词命中比 | 短查询调高,长查询调低 |
|
||||
| `request_timeout` | 30s | ES 客户端超时 | 高并发下 60s |
|
||||
| `search timeout` | "600s" (es_conn.py:257) | ES 服务端超时 | 超长 KB 才放宽 |
|
||||
|
||||
---
|
||||
|
||||
## 七、配置项与运维要点
|
||||
|
||||
### 7.1 环境变量(连接 + 客户端调优)
|
||||
|
||||
```python
|
||||
# api/app/core/rag/utils/es_conn.py:60-80
|
||||
# api/app/core/rag/vdb/elasticsearch/elasticsearch_vector.py:685-710
|
||||
ELASTICSEARCH_HOST # 默认 127.0.0.1,可填 http://es-1 / https://es-1
|
||||
ELASTICSEARCH_PORT # 默认 9200
|
||||
ELASTICSEARCH_USERNAME # 默认 elastic
|
||||
ELASTICSEARCH_PASSWORD # 默认 elastic
|
||||
ELASTICSEARCH_REQUEST_TIMEOUT # 默认 30 (秒)
|
||||
ELASTICSEARCH_RETRY_ON_TIMEOUT # 默认 True (es_conn 中是字符串比较,注意 bug 见下)
|
||||
ELASTICSEARCH_MAX_RETRIES # 默认 3
|
||||
ELASTICSEARCH_VERIFY_CERTS # 默认 false
|
||||
ELASTICSEARCH_CA_CERTS # 自签证书路径
|
||||
ELASTICSEARCH_CONNECTIONS_PER_NODE # 路径 B 独有,默认 10
|
||||
```
|
||||
|
||||
> **小坑**:`es_conn.py:72` 写的是 `os.getenv("ELASTICSEARCH_RETRY_ON_TIMEOUT", True) == "true"`——默认值是 bool `True`,但与字符串 `"true"` 比较恒为 `False`。所以**默认情况下其实没开启 retry_on_timeout**,需要显式设置 `ELASTICSEARCH_RETRY_ON_TIMEOUT=true`(小写)才生效。
|
||||
|
||||
### 7.2 ES 集群规模建议
|
||||
|
||||
按 `mapping.json` 默认 2 shards、0 replicas,**不可直接用于生产**。建议:
|
||||
|
||||
| 数据量 | 节点数 | shards | replicas | heap | 备注 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| < 100w chunks | 1-3 | 2 | 1 | 8GB | 默认配置 + 1 副本 |
|
||||
| 100w-1000w | 3-5 | 4-8 | 1 | 16GB | 增加 shard 减少单 shard 体积 |
|
||||
| > 1000w | 5+ | 8-16 | 1-2 | 31GB(不超过 32) | shard 大小控制在 30-50GB |
|
||||
|
||||
**核心准则**:
|
||||
|
||||
- 单 shard 不超过 50GB;
|
||||
- replicas ≥ 1,至少容忍 1 节点宕机;
|
||||
- JVM heap 不超过 32GB(zero-based compressed oops);
|
||||
- 留 50% RAM 给 OS file cache(lucene 依赖 mmap)。
|
||||
|
||||
### 7.3 索引膨胀治理
|
||||
|
||||
观察点:
|
||||
|
||||
- **路径 A 的 `graphrag_{workspace_id}` 索引**:随 workspace chunk 数增长,`number_of_shards=2` 容易超过 50GB/shard。需要按"workspace 容量分层",对热门/大 workspace 单独 reindex 到更多 shards。
|
||||
- **路径 B 的 `Vector_index_{kb_id}_Node` 索引**:每 KB 一个 index,KB 数 1000+ 时 cluster state 显著膨胀,可能拖慢所有索引创建/查询。建议引入"KB 共享索引 + kb_id 路由"模式(详见 §8 优化建议)。
|
||||
|
||||
```python
|
||||
# api/app/core/rag/utils/es_conn.py:587-633
|
||||
def get_cluster_stats(self):
|
||||
"""
|
||||
暴露 store_size / docs / nodes_version / jvm_heap_used 等用于 dashboard
|
||||
"""
|
||||
raw_stats = self.es.cluster.stats()
|
||||
return {...}
|
||||
```
|
||||
|
||||
> **建议**:在调度器里定时拉取 `get_cluster_stats()`,把 `store_size / docs / heap_used_percent` 接入告警。
|
||||
|
||||
### 7.4 慢查询排查
|
||||
|
||||
```python
|
||||
# api/app/core/rag/utils/es_conn.py:250-263
|
||||
logger.debug(f"ESConnection.search {str(indexNames)} query: " + json.dumps(q))
|
||||
res = self.es.search(index=indexNames, body=q, timeout="600s",
|
||||
# search_type="dfs_query_then_fetch",
|
||||
track_total_hits=True, _source=True)
|
||||
```
|
||||
|
||||
排查路径:
|
||||
|
||||
1. **打开 debug 日志**:`logger=rag.es_conn` 调到 DEBUG,可以看到完整 DSL。
|
||||
2. **关闭 `track_total_hits=True`**:超过 10000 hits 时它会真正扫表,对大 KB 是常见慢点;如果不需要精确总数,改为 `track_total_hits=10000`。
|
||||
3. **打开 `dfs_query_then_fetch`**:在多 shard 时让 IDF 全局计算,对相关性更准;代价是一次 RTT。
|
||||
4. **限制 `num_candidates`**:HNSW 阶段候选数大幅影响延迟;已是 `topn * 2`,进一步压缩到 `topn` 可观察延迟下降。
|
||||
5. **slow log**:在 ES 集群层面打开 `index.search.slowlog.threshold.query.warn: 1s`,定位单查询慢点。
|
||||
|
||||
### 7.5 健康监控接口
|
||||
|
||||
```python
|
||||
# api/app/core/rag/utils/es_conn.py:95-98
|
||||
def health(self) -> dict:
|
||||
health_dict = dict(self.es.cluster.health())
|
||||
health_dict["type"] = "elasticsearch"
|
||||
return health_dict
|
||||
```
|
||||
|
||||
```python
|
||||
# api/app/core/rag/utils/doc_store_conn.py:140-145
|
||||
@abstractmethod
|
||||
def health(self) -> dict:
|
||||
"""Return the health status of the database."""
|
||||
```
|
||||
|
||||
接入业务监控的最简方法:起一个轻量 endpoint 调用 `docStoreConn.health()`,把 `status (green/yellow/red)`、`number_of_nodes`、`active_shards_percent_as_number` 上报。
|
||||
|
||||
---
|
||||
|
||||
## 八、边界条件与已知限制
|
||||
|
||||
| 限制 | 影响 | 解决方向 |
|
||||
| --- | --- | --- |
|
||||
| 路径 B `dims = len(embeddings[0])` 锁定维度 | 换 embedding 模型必须重建索引 | 按维度后缀命名向量字段(参考路径 A 的 `q_{dim}_vec`)|
|
||||
| 路径 A 默认 0 副本 | 节点宕机即数据丢失 | 修改 `res/mapping.json` `number_of_replicas: 1` |
|
||||
| `ELASTICSEARCH_RETRY_ON_TIMEOUT` 默认未生效 | 网络抖动直接抛错 | bug:bool 与 "true" 字符串比较;需显式 `=true` |
|
||||
| `script_score` 暴力扫描 | 大 KB 延迟高 | 路径 B 升级到 `knn` query(ES 8 原生)|
|
||||
| 路径 B inline mapping 不带 metadata.kb_id | 多 KB 共享索引时无法过滤 | 与路径 A 对齐,引入 `kb_id` keyword |
|
||||
| `update_by_segment` 无并发控制 | 并发更新最后写入胜出 | 走 `delete_by_ids` + `add_chunks` 或显式版本号 |
|
||||
| `add_texts` 不捕获 `BulkIndexError` | 局部失败整批失败 | 增加 try/except + 失败重投队列 |
|
||||
| 一个 workspace 多 KB 共享路径 A 索引 | 单 KB 删除走 delete-by-query,不立即释放磁盘 | 定期 `_forcemerge?only_expunge_deletes=true` |
|
||||
| 路径 B 每 KB 一索引 | 大量 KB 时 cluster state 膨胀 | 改为共享索引 + `kb_id` routing |
|
||||
| `track_total_hits=True` | 大库 search 全表扫描慢 | 默认改为 10000,按需取 max |
|
||||
|
||||
---
|
||||
|
||||
## 九、监控指标与排错指引
|
||||
|
||||
### 9.1 关键指标
|
||||
|
||||
| 指标 | 来源 | 告警阈值(参考)|
|
||||
| --- | --- | --- |
|
||||
| ES cluster status | `health()` | red 立即告警 |
|
||||
| `active_shards_percent_as_number` | `health()` | < 100% 持续 5min 告警 |
|
||||
| `jvm_heap_used_percent` | `get_cluster_stats()` | > 75% 警告,> 85% 紧急 |
|
||||
| `os_mem_used_percent` | `get_cluster_stats()` | > 90% 警告 |
|
||||
| 写入失败比例 | `ESConnection.insert` 返回的 `res` 列表长度 / 总 chunk 数 | > 1% 告警 |
|
||||
| 单次 search P95 延迟 | 调用方时序日志 | hybrid > 1s 告警 |
|
||||
| `track_total_hits` 命中超过 10k 比例 | search.py 总数 | 频繁触发即扩 shard |
|
||||
|
||||
### 9.2 典型故障与处理
|
||||
|
||||
| 现象 | 可能原因 | 处置 |
|
||||
| --- | --- | --- |
|
||||
| 写入超时 | bulk 太大 / refresh 阻塞 | 减小 batch(≤ 1000)/ 写入窗口 `refresh_interval=30s` |
|
||||
| 检索召回为 0 | min_match 过严 / kb_id 过滤不一致 | 看 search.py:447 fallback 是否触发;核对 kb_id |
|
||||
| HNSW 召回率低 | num_candidates 过小 | 增大到 `4 * topn` 或 `topn * 4` |
|
||||
| 维度不匹配报错 | 换 embedding 模型未 reindex | 按 §8 维度限制处理;或在路径 B 删 KB 重建 |
|
||||
| cluster state 过大 | KB 索引数过多 | §10 改造为共享索引 + kb_id routing |
|
||||
| 中文检索召回差 | 写入 analyzer 与查询 analyzer 不一致 | 路径 B 必须保持 `ik_max_word`(写入与查询)|
|
||||
|
||||
---
|
||||
|
||||
## 十、优化建议与未来扩展点
|
||||
|
||||
### 10.1 架构改造(短期,1-2 个迭代)
|
||||
|
||||
1. **统一双路径**:保留路径 A 抽象 (`DocStoreConnection` + `Dealer`),把路径 B 的 `ElasticSearchVector` 重构为 `DocStoreConnection` 的薄封装,删除重复的连接管理 (`ElasticSearchVectorFactory`),全局只用 `@singleton ESConnection`。
|
||||
2. **修复默认配置**:
|
||||
- `mapping.json` `number_of_replicas: 0 → 1`;
|
||||
- 修正 `ELASTICSEARCH_RETRY_ON_TIMEOUT` bool/str 比较;
|
||||
- 路径 B 的 `script_score` 切换为 `knn` query;
|
||||
- 路径 B mapping 加上 `kb_id` keyword 字段,为后续合并索引铺路。
|
||||
3. **共享索引 + 路由**:把 `Vector_index_{kb_id}_Node` 改为 `kb_chunks_{workspace_id}` 共享索引,`kb_id` 字段做 routing key,索引数从 N(KB) 降到 N(workspace)。
|
||||
|
||||
### 10.2 检索增强(中期)
|
||||
|
||||
1. **真正的 RRF**(reciprocal rank fusion):当前 `weighted_sum` 对分数尺度敏感,引入 `rank_fusion` (ES 8.8+) 或在应用层实现 `rrf_score(d) = Σ 1/(k + rank_i(d))`,对尺度不敏感。
|
||||
2. **稀疏向量(ELSER / SPLADE)**:路径 A 已在 `MatchSparseExpr` 接口预留位置,但 ES 实现未启用 `rank_features` 稀疏检索,引入后可在中文长尾查询上显著提升召回。
|
||||
3. **多模态检索**:路径 B 已感知 `is_multimodal_embedding`(`elasticsearch_vector.py:41`),但只针对火山引擎;引入跨模态 BGE-M3 类模型后,可在同一 dense_vector 字段上做"图文混排"。
|
||||
4. **HNSW 参数显式化**:`mapping.json` 没有指定 `index_options`(m / ef_construction)。在构建大索引时显式 `m=16, ef_construction=200` 可显著提升召回率。
|
||||
|
||||
### 10.3 工程鲁棒性(中期)
|
||||
|
||||
1. **写入幂等保护**:路径 B `add_texts` 不传 `_id`,依赖 `metadata.doc_id` 后查;改为直接用 `doc_id` 作为 `_id`,写入即可幂等,省去后查。
|
||||
2. **变更检测 reindex**:当 mapping 变化时,加一个 `migration_version` 字段触发 alias-swap reindex(`old_index → new_index`),避免线上停机重建。
|
||||
3. **批量限流**:`helpers.bulk` 默认无背压,引入 `chunk_size=500, max_chunk_bytes=10MB` 显式限制,避免大 chunk 撑爆 ES heap。
|
||||
4. **路径 A 的 `ATTEMPT_TIME=2`** 太少:网络抖动 2 次重试后丢错,建议升到 3-5 次,配合指数退避。
|
||||
|
||||
### 10.4 长期扩展点
|
||||
|
||||
1. **冷热分离**:超过半年/低访问的 chunk 迁到冷节点(warm tier)+ rollover index,配合"记忆遗忘引擎" (Memory Forgetting Engine, README §4) 协同。
|
||||
2. **跨集群联邦**:当多 workspace 数据量过大,引入 cross-cluster search(CCS)按 workspace 切集群。
|
||||
3. **GraphRAG 与 VDB 联合检索**:当前 `kg_retriever.retrieval` 在路径 B 是后置 insert(node.py:286-298),可改为"先图谱召回相关实体 → 把实体名作为 `important_kwd^30` 注入 BM25"实现一次 ES 调用同时享受图谱与向量。
|
||||
|
||||
---
|
||||
|
||||
## 十一、关键源码片段索引(评审检查点)
|
||||
|
||||
| 主题 | 文件:行号 | 一句话说明 |
|
||||
| --- | --- | --- |
|
||||
| 抽象接口 | `api/app/core/rag/utils/doc_store_conn.py:128-256` | `DocStoreConnection` 14 个抽象方法 |
|
||||
| MatchExpr 族 | `api/app/core/rag/utils/doc_store_conn.py:43-114` | 文本/稠密/稀疏/张量/融合表达式 |
|
||||
| ES 连接管理 | `api/app/core/rag/utils/es_conn.py:26-86` | `@singleton` + 8.x 版本校验 |
|
||||
| 全局 mapping | `api/app/core/rag/res/mapping.json:1-211` | dynamic_templates + 自定义 BM25 |
|
||||
| ES 8 hybrid 核心 | `api/app/core/rag/utils/es_conn.py:186-218` | `query_string` + `s.knn(...)` 共享 filter |
|
||||
| 加权融合 | `api/app/core/rag/utils/es_conn.py:188-194` 与 `api/app/core/rag/nlp/search.py:439` | `FusionExpr("weighted_sum", weights="0.05,0.95")` |
|
||||
| 应用层 hybrid_similarity | `api/app/core/rag/nlp/query.py:203-211` | `0.7*cos + 0.3*token_sim` |
|
||||
| 双路 + 去重 + rerank | `api/app/core/workflow/nodes/knowledge/node.py:236-271` | 工作流默认混合策略 |
|
||||
| BaseVector 抽象 | `api/app/core/rag/vdb/vector_base.py:9-67` | 路径 B 的接口骨架 |
|
||||
| KB 索引 mapping | `api/app/core/rag/vdb/elasticsearch/elasticsearch_vector.py:609-663` | inline 创建 + dims 锁定 |
|
||||
| 关键词检索(BM25+IK)| `api/app/core/rag/vdb/elasticsearch/elasticsearch_vector.py:468-558` | match + ik_max_word + 归一化 |
|
||||
| 向量检索(cosine 暴力)| `api/app/core/rag/vdb/elasticsearch/elasticsearch_vector.py:374-466` | script_score + filter |
|
||||
| 关键词构造(多字段 + 同义词)| `api/app/core/rag/nlp/query.py:14-22, 69-201` | query_fields field-boost + synonym |
|
||||
| Dealer.retrieval (主入口) | `api/app/core/rag/nlp/search.py:674-768` | 检索 + rerank + 分页 |
|
||||
| 低召回 fallback | `api/app/core/rag/nlp/search.py:447-459` | min_match 0.3→0.1,similarity 0.1→0.17 |
|
||||
| update_by_query | `api/app/core/rag/utils/es_conn.py:332-422` | painless + slices=5 + conflicts=proceed |
|
||||
| bulk 写 + 错误处理 | `api/app/core/rag/utils/es_conn.py:294-330` | refresh=False + 两次重试 + 错误聚合 |
|
||||
| 工厂单例 (路径 B) | `api/app/core/rag/vdb/elasticsearch/elasticsearch_vector.py:666-732` | 双重检查锁 + 版本校验一次性 |
|
||||
| 全局初始化 | `api/app/core/rag/common/settings.py:13-24` | docStoreConn / retriever / kg_retriever |
|
||||
| 检索类型枚举 | `api/app/schemas/chunk_schema.py:8-13` | participle / semantic / hybrid / graph |
|
||||
|
||||
---
|
||||
|
||||
## 十二、TL;DR(一段话总结)
|
||||
|
||||
MemoryBear 用 Elasticsearch 8 同时承担**全文(Lucene + IK + 自定义 BM25)和向量(dense_vector + HNSW)**双引擎,所以选 ES 而不是专用向量库。代码里有**两套并行路径**:路径 A `ESConnection`(单例 `DocStoreConnection`,多字段动态模板,配 `Dealer` 做 `weighted_sum=0.05,0.95` 的应用层加权 + ES 原生 hybrid 与 rank_features,主要给 GraphRAG/复杂 RAG 用);路径 B `ElasticSearchVector`(`BaseVector` 简化封装,`script_score+cosine` 与 `match+ik_max_word`,主要给工作流知识节点和 KB 服务用,hybrid 走"双路并发 → metadata.doc_id 去重 → 可选 reranker")。索引按 workspace 或按 KB 隔离,`mapping.json` 默认 2 shards / 0 副本 / 1s refresh,向量字段按维度后缀(512/768/1024/1536)动态创建,文本字段以 `_tks/_ltks/_kwd` 后缀套用 dynamic_templates。生产化的主要风险点:路径 B 锁死 dims、默认 0 副本、`ELASTICSEARCH_RETRY_ON_TIMEOUT` 比较 bug、`script_score` 暴力扫描、KB 索引数膨胀;优化方向是合并双路径、改用 `knn` + RRF、共享索引 + `kb_id` routing、配合 GraphRAG 做联合检索。
|
||||
991
docs/rag/pipeline/04-graphrag.md
Normal file
991
docs/rag/pipeline/04-graphrag.md
Normal file
@@ -0,0 +1,991 @@
|
||||
# GraphRAG(light + general)实现详解
|
||||
|
||||
| 元数据 | 值 |
|
||||
|---|---|
|
||||
| 环节编号 | 05-graphrag |
|
||||
| 源码目录 | `api/app/core/rag/graphrag/` |
|
||||
| 关联任务 | [WS-11](mention://issue/6c0b5472-a0fa-4997-925c-a67f235f82da) / [S2-T4](mention://issue/16bdb196-e10e-489b-b01c-9067b1f1bb23) |
|
||||
| 依赖输入 | [S2-T2] Embedding、[S2-T3] VDB、[S1-T2] 架构图 |
|
||||
| 输出下游 | [S3-T2] 知识图谱增强 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 一句话定位
|
||||
|
||||
GraphRAG 是 MemoryBear 知识库系统的**知识图谱增强检索模块**,通过 LLM 从文档中抽取实体-关系三元组构建知识图谱,在检索阶段利用图谱结构(实体关联、社区报告、多跳路径)补充传统向量检索的语义盲区,实现"结构化知识 + 语义向量"的混合召回。
|
||||
|
||||
---
|
||||
|
||||
## 2. 设计目标与适用场景
|
||||
|
||||
### 2.1 设计目标
|
||||
|
||||
1. **结构化知识补充**:向量检索擅长语义匹配,但对"多跳推理""实体关系推导""全局摘要"等场景覆盖不足。GraphRAG 通过显式构建实体关系图谱填补这一 gap。
|
||||
2. **两种精度-成本档位**:
|
||||
- **Light 模式**(默认):基于 LightRAG 思路,轻量快速,适合对延迟敏感、文档规模中等的场景。
|
||||
- **General 模式**(完整版):基于 Microsoft GraphRAG,支持实体消歧、社区发现、社区报告生成,适合需要深度分析、复杂推理的场景。
|
||||
3. **与现有基础设施复用**:不引入 Neo4j 等独立图数据库,复用 Elasticsearch 作为图谱存储,降低运维复杂度。
|
||||
|
||||
### 2.2 适用场景
|
||||
|
||||
| 场景 | 推荐模式 | 原因 |
|
||||
|---|---|---|
|
||||
| 快速知识问答,文档 < 1K | Light | 建图快、成本低 |
|
||||
| 企业级知识库,文档 > 10K | General | 实体消歧 + 社区报告提供全局洞察 |
|
||||
| 需要跨文档实体关联分析 | General | 实体消歧合并跨文档同名实体 |
|
||||
| 需要"某实体的全局影响力"评估 | General | 社区报告 + PageRank 提供全局视角 |
|
||||
| 实时对话/低延迟检索 | Light | General 的社区报告生成耗时高 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 关键概念与术语表
|
||||
|
||||
| 术语 | 定义 |
|
||||
|---|---|
|
||||
| **Entity(实体)** | 从文本中抽取的命名对象,如人名、组织、地点。在代码中存储为图的节点。 |
|
||||
| **Relationship(关系)** | 实体之间的语义关联,如"A 是 B 的 CEO"。存储为图的边。 |
|
||||
| **Subgraph(子图)** | 单个文档抽取出的局部知识图谱,最终合并为全局图谱。 |
|
||||
| **Entity Resolution(实体消歧)** | 识别图谱中不同名称但指向同一实体的节点,将其合并(如 "Apple Inc." vs "Apple")。 |
|
||||
| **Community(社区)** | 图谱中高密度连接的节点簇,通过 Leiden 算法发现。 |
|
||||
| **Community Report(社区报告)** | 对单个社区的 LLM 生成的结构化摘要报告,含标题、摘要、影响力评级、关键发现。 |
|
||||
| **PageRank** | 用于衡量实体在图谱中的重要程度,检索时作为排序因子之一。 |
|
||||
| **N-hop Path** | 从查询实体出发,沿图谱边行走 N 步可达的实体路径,用于扩展召回。 |
|
||||
| **Tuple Delimiter** | 实体/关系抽取输出中的字段分隔符,代码中为 `<\|>`。 |
|
||||
| **Record Delimiter** | 抽取输出中多条记录的分隔符,代码中为 `##`。 |
|
||||
| **knowledge_graph_kwd** | ES 文档中的类型标记字段,取值:`entity` / `relation` / `graph` / `subgraph` / `community_report` / `ty2ents`。 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 实现概览
|
||||
|
||||
### 4.1 模块结构
|
||||
|
||||
```
|
||||
api/app/core/rag/graphrag/
|
||||
├── search.py # KGSearch:图谱检索入口
|
||||
├── entity_resolution.py # 实体消歧(LLM + 编辑距离)
|
||||
├── entity_resolution_prompt.py # 实体消歧 Prompt
|
||||
├── query_analyze_prompt.py # 查询分析 Prompt(MiniRAG 风格)
|
||||
├── utils.py # 图操作工具集(merge、cache、ES 读写)
|
||||
├── __init__.py
|
||||
├── light/
|
||||
│ ├── graph_extractor.py # Light 版实体/关系抽取器
|
||||
│ └── graph_prompt.py # Light 版抽取 Prompt + RAG 回答 Prompt
|
||||
└── general/
|
||||
├── extractor.py # 通用抽取基类(LLM 调用、节点/边合并)
|
||||
├── graph_extractor.py # General 版实体/关系抽取器
|
||||
├── graph_prompt.py # General 版抽取 Prompt
|
||||
├── index.py # GraphRAG 建图总控(子图生成→合并→消歧→社区报告)
|
||||
├── entity_embedding.py # Node2Vec 实体嵌入(备用)
|
||||
├── leiden.py # Leiden 社区发现算法封装
|
||||
├── community_reports_extractor.py # 社区报告抽取器
|
||||
├── community_report_prompt.py # 社区报告生成 Prompt
|
||||
├── mind_map_extractor.py # 思维导图抽取器
|
||||
└── mind_map_prompt.py # 思维导图 Prompt
|
||||
```
|
||||
|
||||
### 4.2 建图时序图
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户/任务
|
||||
participant T as tasks.py<br/>(Celery Task)
|
||||
participant I as general/index.py<br/>run_graphrag/run_graphrag_for_kb
|
||||
participant E as light/general<br/>GraphExtractor
|
||||
participant ES as Elasticsearch<br/>(Doc Store)
|
||||
participant ER as entity_resolution.py<br/>EntityResolution
|
||||
participant CR as community_reports_extractor.py<br/>CommunityReportsExtractor
|
||||
|
||||
U->>T: 上传文档 / 触发建图
|
||||
T->>I: run_graphrag_for_kb(document_ids, parser_config)
|
||||
I->>I: load_doc_chunks()<br/>按 1024 token 合并 chunk
|
||||
loop 每个文档并行(max 4)
|
||||
I->>E: generate_subgraph(extractor, chunks)
|
||||
E->>E: LLM 抽取 entities + relations<br/>(多轮 gleaning)
|
||||
E->>E: 解析输出 → nx.Graph
|
||||
E->>ES: 写入 subgraph (knowledge_graph_kwd="subgraph")
|
||||
end
|
||||
I->>I: merge_subgraph()<br/>逐个文档合并子图到全局图
|
||||
I->>ES: 写入全局 graph (knowledge_graph_kwd="graph")
|
||||
I->>ES: 写入 entity/relation chunks<br/>(带向量嵌入)
|
||||
|
||||
alt with_resolution=true (General 可选)
|
||||
I->>ER: resolve_entities(graph, subgraph_nodes)
|
||||
ER->>ER: 编辑距离预筛选候选对
|
||||
ER->>ER: LLM 批量判断"是否同一实体"
|
||||
ER->>ER: 合并连通分量中的节点
|
||||
ER->>ER: 重新计算 PageRank
|
||||
ER->>ES: 更新 graph/entity/relation
|
||||
end
|
||||
|
||||
alt with_community=true (General 可选)
|
||||
I->>CR: extract_community(graph)
|
||||
CR->>CR: Leiden 社区发现
|
||||
CR->>CR: LLM 生成每个社区的报告<br/>(title/summary/rating/findings)
|
||||
CR->>ES: 写入 community_report chunks
|
||||
end
|
||||
I-->>T: 返回 {ok_documents, failed_documents, seconds}
|
||||
```
|
||||
|
||||
### 4.3 查图时序图
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户 Query
|
||||
participant S as search.py<br/>KGSearch.retrieval()
|
||||
participant QP as query_analyze_prompt.py<br/>minirag_query2kwd
|
||||
participant ES as Elasticsearch
|
||||
participant LLM as LLM
|
||||
|
||||
U->>S: retrieval(question, workspace_ids, kb_ids, ...)
|
||||
S->>LLM: query_rewrite()<br/>PROMPTS["minirag_query2kwd"]
|
||||
LLM-->>S: {answer_type_keywords, entities_from_query}
|
||||
|
||||
par 三路召回并行
|
||||
S->>ES: get_relevant_ents_by_keywords()<br/>向量相似度搜索 entity
|
||||
ES-->>S: 候选实体列表 + sim + pagerank + n_hop
|
||||
S->>ES: get_relevant_ents_by_types()<br/>按类型过滤 entity
|
||||
ES-->>S: 类型匹配实体列表
|
||||
S->>ES: get_relevant_relations_by_txt()<br/>向量相似度搜索 relation
|
||||
ES-->>S: 候选关系列表
|
||||
end
|
||||
|
||||
S->>S: 计算 n-hop 路径权重衰减<br/>sim / (2 + hop_depth)
|
||||
S->>S: 实体排序:sim × pagerank<br/>关系排序:sim × pagerank × boost
|
||||
S->>S: Token 预算截断(max_token 递减)
|
||||
|
||||
alt 社区报告召回
|
||||
S->>ES: _community_retrieval_()<br/>按 entities_kwd 匹配 community_report
|
||||
ES-->>S: 社区报告文本
|
||||
end
|
||||
|
||||
S-->>U: {page_content: Entities + Relations + Community Reports,<br/>metadata, vector: None}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 关键源码详解
|
||||
|
||||
### 5.1 图谱构建链路
|
||||
|
||||
#### 5.1.1 建图总控入口
|
||||
|
||||
**文件**: `api/app/core/rag/graphrag/general/index.py:36-119`
|
||||
|
||||
```python
|
||||
async def run_graphrag(
|
||||
row: dict, language, with_resolution: bool, with_community: bool,
|
||||
chat_model, embedding_model, callback,
|
||||
):
|
||||
# 选择抽取器:LightKGExt(默认)或 GeneralKGExt
|
||||
extractor = LightKGExt if method != "general" else GeneralKGExt
|
||||
subgraph = await generate_subgraph(extractor, workspace_id, kb_id, document_id, chunks, ...)
|
||||
new_graph = await merge_subgraph(workspace_id, kb_id, document_id, subgraph, embedding_model, callback)
|
||||
if with_resolution:
|
||||
await resolve_entities(new_graph, subgraph_nodes, ...)
|
||||
if with_community:
|
||||
await extract_community(new_graph, ...)
|
||||
```
|
||||
|
||||
**设计要点**:
|
||||
- `parser_config["graphrag"]["method"]` 控制 Light/General 切换(`"general"` 为 General,其他为 Light)。
|
||||
- `with_resolution` 和 `with_community` 为独立开关,仅在 General 模式下有意义(Light 不支持)。
|
||||
- 使用 `RedisDistributedLock` 保证同一 KB 的并发建图安全。
|
||||
|
||||
#### 5.1.2 子图生成
|
||||
|
||||
**文件**: `api/app/core/rag/graphrag/general/index.py:333-406`
|
||||
|
||||
```python
|
||||
async def generate_subgraph(extractor, workspace_id, kb_id, document_id, chunks, ...):
|
||||
# 幂等检查:如果 document_id 已在图中,跳过
|
||||
contains = await does_graph_contains(workspace_id, kb_id, document_id)
|
||||
if contains:
|
||||
return None
|
||||
ext = extractor(llm_bdl, language=language, entity_types=entity_types)
|
||||
ents, rels = await ext(document_id, chunks, callback, task_id=task_id)
|
||||
subgraph = nx.Graph()
|
||||
for ent in ents:
|
||||
subgraph.add_node(ent["entity_name"], **ent)
|
||||
for rel in rels:
|
||||
if subgraph.has_node(rel["src_id"]) and subgraph.has_node(rel["tgt_id"]):
|
||||
subgraph.add_edge(rel["src_id"], rel["tgt_id"], **rel)
|
||||
tidy_graph(subgraph, callback, check_attribute=False)
|
||||
# 写入 ES 作为 subgraph 类型文档
|
||||
await trio.to_thread.run_sync(settings.docStoreConn.insert, [chunk], ...)
|
||||
return subgraph
|
||||
```
|
||||
|
||||
**关键设计**:
|
||||
- `does_graph_contains()` 通过查询 `knowledge_graph_kwd="graph"` 的 `source_id` 字段实现幂等性。
|
||||
- `tidy_graph()` 清理无 description/source_id 的脏节点/边。
|
||||
- 每个文档的 subgraph 独立存储,便于增量更新和重建。
|
||||
|
||||
#### 5.1.3 实体/关系抽取(Light vs General)
|
||||
|
||||
**Light 版抽取器**
|
||||
|
||||
**文件**: `api/app/core/rag/graphrag/light/graph_extractor.py:31-132`
|
||||
|
||||
```python
|
||||
class GraphExtractor(Extractor):
|
||||
def __init__(self, llm_invoker, language="English", entity_types=None,
|
||||
example_number=2, max_gleanings=None):
|
||||
# 使用 LightRAG 风格的 Prompt
|
||||
self._entity_extract_prompt = PROMPTS["entity_extraction"]
|
||||
self._continue_prompt = PROMPTS["entity_continue_extraction"]
|
||||
self._if_loop_prompt = PROMPTS["entity_if_loop_extraction"]
|
||||
# 预留 60% token 给输入文本
|
||||
self._left_token_count = max(getattr(llm_invoker, 'max_length', 8096) * 0.6, ...)
|
||||
|
||||
async def _process_single_content(self, chunk_key_dp, chunk_seq, num_chunks, out_results, task_id=""):
|
||||
hint_prompt = self._entity_extract_prompt.format(**self._context_base, input_text=content)
|
||||
# 首轮抽取
|
||||
final_result = await trio.to_thread.run_sync(self._chat, "", [{"role": "user", "content": hint_prompt}], {}, task_id)
|
||||
# 多轮 gleaning:追问"还有遗漏吗?"
|
||||
for now_glean_index in range(self._max_gleanings):
|
||||
glean_result = await trio.to_thread.run_sync(self._chat, "", history, gen_conf, task_id)
|
||||
final_result += glean_result
|
||||
# 用 if_loop_prompt 判断是否继续
|
||||
if_loop_result = await trio.to_thread.run_sync(self._chat, "", history, gen_conf, task_id)
|
||||
if if_loop_result.strip().lower() != "yes":
|
||||
break
|
||||
```
|
||||
|
||||
**General 版抽取器**
|
||||
|
||||
**文件**: `api/app/core/rag/graphrag/general/graph_extractor.py:34-151`
|
||||
|
||||
```python
|
||||
class GraphExtractor(Extractor):
|
||||
def __init__(self, llm_invoker, language="English", entity_types=None, ...):
|
||||
self._extraction_prompt = GRAPH_EXTRACTION_PROMPT
|
||||
self._max_gleanings = max_gleanings or ENTITY_EXTRACTION_MAX_GLEANINGS
|
||||
# 使用 tiktoken 构造 logit_bias 强制输出 YES/NO
|
||||
encoding = tiktoken.get_encoding("cl100k_base")
|
||||
yes = encoding.encode("YES")
|
||||
no = encoding.encode("NO")
|
||||
self._loop_args = {"logit_bias": {yes[0]: 100, no[0]: 100}, "max_tokens": 1}
|
||||
|
||||
async def _process_single_content(self, chunk_key_dp, chunk_seq, num_chunks, out_results, task_id=""):
|
||||
# 类似 Light,但使用 CONTINUE_PROMPT + LOOP_PROMPT
|
||||
for i in range(self._max_gleanings):
|
||||
history.append({"role": "user", "content": CONTINUE_PROMPT})
|
||||
response = await trio.to_thread.run_sync(lambda: self._chat("", history, {}))
|
||||
if i >= self._max_gleanings - 1:
|
||||
break
|
||||
history.append({"role": "assistant", "content": response})
|
||||
history.append({"role": "user", "content": LOOP_PROMPT})
|
||||
continuation = await trio.to_thread.run_sync(lambda: self._chat("", history))
|
||||
if continuation != "Y":
|
||||
break
|
||||
```
|
||||
|
||||
**Light vs General 抽取差异**:
|
||||
|
||||
| 维度 | Light | General |
|
||||
|---|---|---|
|
||||
| Prompt 风格 | LightRAG(更详细的示例 + content_keywords) | MS GraphRAG(简洁 + 无 keywords) |
|
||||
| Gleaning 终止 | 自然语言判断 `"yes"/"no"` | 强制单字 `"Y"`(logit_bias) |
|
||||
| 示例数量 | 默认 3 个,可调 `example_number` | 固定 3 个 |
|
||||
| 输出格式 | 含 `content_keywords` 元组 | 仅 entity + relationship |
|
||||
|
||||
#### 5.1.4 节点/边合并与摘要
|
||||
|
||||
**文件**: `api/app/core/rag/graphrag/general/extractor.py:205-300`
|
||||
|
||||
```python
|
||||
async def _merge_nodes(self, entity_name, entities, all_relationships_data, task_id=""):
|
||||
# 投票决定实体类型(出现次数最多者)
|
||||
entity_type = sorted(Counter([dp["entity_type"] for dp in entities]).items(), key=lambda x: x[1], reverse=True)[0][0]
|
||||
# 去重合并所有描述
|
||||
description = GRAPH_FIELD_SEP.join(sorted(set([dp["description"] for dp in entities])))
|
||||
# LLM 摘要(描述超过 12 条时触发)
|
||||
description = await self._handle_entity_relation_summary(entity_name, description, task_id=task_id)
|
||||
node_data = dict(entity_type=entity_type, description=description, source_id=already_source_ids)
|
||||
all_relationships_data.append(node_data)
|
||||
|
||||
async def _handle_entity_relation_summary(self, entity_or_relation_name, description, task_id=""):
|
||||
description_list = use_description.split(GRAPH_FIELD_SEP)
|
||||
if len(description_list) <= 12:
|
||||
return use_description # 描述较少时不摘要
|
||||
# 触发 LLM 摘要
|
||||
async with chat_limiter:
|
||||
summary = await trio.to_thread.run_sync(self._chat, "", [{"role": "user", "content": use_prompt}], {}, task_id)
|
||||
return summary
|
||||
```
|
||||
|
||||
**设计要点**:
|
||||
- 同一实体名在不同 chunk 中的描述用 `<SEP>` 拼接,超过 12 条触发 LLM 摘要,防止描述无限膨胀。
|
||||
- 关系合并同理:权重累加、关键词去重并集、描述拼接摘要。
|
||||
|
||||
#### 5.1.5 子图合并到全局图
|
||||
|
||||
**文件**: `api/app/core/rag/graphrag/utils.py:199-229`
|
||||
|
||||
```python
|
||||
def graph_merge(g1: nx.Graph, g2: nx.Graph, change: GraphChange):
|
||||
"""Merge graph g2 into g1 in place."""
|
||||
for node_name, attr in g2.nodes(data=True):
|
||||
change.added_updated_nodes.add(node_name)
|
||||
if not g1.has_node(node_name):
|
||||
g1.add_node(node_name, **attr)
|
||||
continue
|
||||
# 已存在:描述追加、source_id 合并
|
||||
node = g1.nodes[node_name]
|
||||
node["description"] += GRAPH_FIELD_SEP + attr["description"]
|
||||
node["source_id"] += attr["source_id"]
|
||||
|
||||
for source, target, attr in g2.edges(data=True):
|
||||
change.added_updated_edges.add(get_from_to(source, target))
|
||||
edge = g1.get_edge_data(source, target)
|
||||
if edge is None:
|
||||
g1.add_edge(source, target, **attr)
|
||||
continue
|
||||
# 已存在:权重累加、描述追加
|
||||
edge["weight"] += attr.get("weight", 0)
|
||||
edge["description"] += GRAPH_FIELD_SEP + attr["description"]
|
||||
edge["keywords"] += attr["keywords"]
|
||||
edge["source_id"] += attr["source_id"]
|
||||
|
||||
# 更新度中心性(rank)
|
||||
for node_degree in g1.degree:
|
||||
g1.nodes[str(node_degree[0])]["rank"] = int(node_degree[1])
|
||||
```
|
||||
|
||||
#### 5.1.6 实体消歧
|
||||
|
||||
**文件**: `api/app/core/rag/graphrag/entity_resolution.py:31-141`
|
||||
|
||||
```python
|
||||
class EntityResolution(Extractor):
|
||||
async def __call__(self, graph, subgraph_nodes, prompt_variables=None, callback=None, task_id=""):
|
||||
# 1. 按 entity_type 分组
|
||||
node_clusters = {entity_type: [] for entity_type in entity_types}
|
||||
for node in nodes:
|
||||
node_clusters[graph.nodes[node].get('entity_type', '-')].append(node)
|
||||
|
||||
# 2. 生成候选对(组合数限制 + 编辑距离预筛选)
|
||||
for k, v in node_clusters.items():
|
||||
candidate_resolution[k] = [(a, b) for a, b in itertools.combinations(v, 2)
|
||||
if (a in subgraph_nodes or b in subgraph_nodes) and self.is_similarity(a, b)]
|
||||
|
||||
# 3. LLM 批量判断(batch=100,并发=5,trio 协程)
|
||||
async def limited_resolve_candidate(candidate_batch, result_set, result_lock):
|
||||
async with semaphore:
|
||||
await self._resolve_candidate(candidate_batch, result_set, result_lock, task_id)
|
||||
|
||||
# 4. 合并连通分量
|
||||
connect_graph = nx.Graph()
|
||||
connect_graph.add_edges_from(resolution_result)
|
||||
for sub_connect_graph in nx.connected_components(connect_graph):
|
||||
merging_nodes = list(sub_connect_graph)
|
||||
await self._merge_graph_nodes(graph, merging_nodes, change, task_id)
|
||||
|
||||
# 5. 重新计算 PageRank
|
||||
pr = nx.pagerank(graph)
|
||||
```
|
||||
|
||||
**编辑距离预筛选算法**(`is_similarity`,第 225-239 行):
|
||||
|
||||
```python
|
||||
def is_similarity(self, a, b):
|
||||
# 规则1:2-gram 差异中不能包含数字(避免 "Product 1" vs "Product 2" 被误判)
|
||||
if self._has_digit_in_2gram_diff(a, b):
|
||||
return False
|
||||
# 规则2:英文用 editdistance,阈值 = min(len(a), len(b)) // 2
|
||||
if is_english(a) and is_english(b):
|
||||
return editdistance.eval(a, b) <= min(len(a), len(b)) // 2
|
||||
# 规则3:中文/混合文本用字符集 Jaccard 相似度,阈值 0.8
|
||||
a, b = set(a), set(b)
|
||||
max_l = max(len(a), len(b))
|
||||
if max_l < 4:
|
||||
return len(a & b) > 1
|
||||
return len(a & b) * 1. / max_l >= 0.8
|
||||
```
|
||||
|
||||
**消歧流程设计意图**:
|
||||
1. **预筛选**:编辑距离过滤掉明显不同的实体对,减少 LLM 调用量(组合数从 O(n²) 降到可控范围)。
|
||||
2. **批量 LLM 判断**:每批 100 对,并发 5 个请求,timeout 280s(测试环境)或无限(生产环境)。
|
||||
3. **连通分量合并**:LLM 判定"A=B"和"B=C"后,即使 LLM 没直接判断"A=C",通过连通分量也会将 A、B、C 合并。
|
||||
4. **任务取消支持**:每步检查 `has_canceled(task_id)`,支持用户中断长时任务。
|
||||
|
||||
#### 5.1.7 社区发现与报告生成
|
||||
|
||||
**文件**: `api/app/core/rag/graphrag/general/leiden.py:95-141`
|
||||
|
||||
```python
|
||||
def run(graph, args):
|
||||
max_cluster_size = args.get("max_cluster_size", 12)
|
||||
use_lcc = args.get("use_lcc", True)
|
||||
# 使用 graspologic 的 hierarchical_leiden
|
||||
community_mapping = hierarchical_leiden(graph, max_cluster_size=max_cluster_size, random_seed=seed)
|
||||
# 按层级组织社区,计算社区权重(节点 rank × weight 归一化)
|
||||
for level in levels:
|
||||
for node_id, raw_community_id in node_id_to_community_map[level].items():
|
||||
community_id = str(raw_community_id)
|
||||
result[community_id]["nodes"].append(node_id)
|
||||
result[community_id]["weight"] += graph.nodes[node_id].get("rank", 0) * graph.nodes[node_id].get("weight", 1)
|
||||
```
|
||||
|
||||
**文件**: `api/app/core/rag/graphrag/general/community_reports_extractor.py:55-158`
|
||||
|
||||
```python
|
||||
class CommunityReportsExtractor(Extractor):
|
||||
async def __call__(self, graph, callback=None, task_id=""):
|
||||
communities = leiden.run(graph, {})
|
||||
async with trio.open_nursery() as nursery:
|
||||
for level, comm in communities.items():
|
||||
for community in comm.items():
|
||||
nursery.start_soon(extract_community_report, community)
|
||||
|
||||
async def extract_community_report(community):
|
||||
cm_id, cm = community
|
||||
ents = cm["nodes"]
|
||||
if len(ents) < 2:
|
||||
return # 忽略单节点社区
|
||||
ent_df = pd.DataFrame([{"entity": e, "description": graph.nodes[e]["description"]} for e in ents])
|
||||
rela_df = pd.DataFrame([...]) # 社区内关系,上限 10000
|
||||
prompt = perform_variable_replacements(COMMUNITY_REPORT_PROMPT,
|
||||
variables={"entity_df": ent_df.to_csv(), "relation_df": rela_df.to_csv()})
|
||||
response = await trio.to_thread.run_sync(self._chat, text, ...)
|
||||
# 解析 JSON,校验字段类型
|
||||
if not dict_has_keys_with_types(response, [("title", str), ("summary", str), ("findings", list), ("rating", float), ("rating_explanation", str)]):
|
||||
return
|
||||
```
|
||||
|
||||
### 5.2 图谱检索链路
|
||||
|
||||
#### 5.2.1 检索入口
|
||||
|
||||
**文件**: `api/app/core/rag/graphrag/search.py:19-280`
|
||||
|
||||
```python
|
||||
class KGSearch(Dealer):
|
||||
def retrieval(self, question, workspace_ids, kb_ids, emb_mdl, llm,
|
||||
max_token=8196, ent_topn=6, rel_topn=6, comm_topn=1,
|
||||
ent_sim_threshold=0.3, rel_sim_threshold=0.3, **kwargs):
|
||||
# Step 1: Query 改写
|
||||
ty_kwds, ents = self.query_rewrite(llm, qst, idxnms, kb_ids)
|
||||
# Step 2: 三路召回
|
||||
ents_from_query = self.get_relevant_ents_by_keywords(ents, filters, idxnms, kb_ids, emb_mdl, ent_sim_threshold)
|
||||
ents_from_types = self.get_relevant_ents_by_types(ty_kwds, filters, idxnms, kb_ids, 10000)
|
||||
rels_from_txt = self.get_relevant_relations_by_txt(qst, filters, idxnms, kb_ids, emb_mdl, rel_sim_threshold)
|
||||
# Step 3: n-hop 路径扩展
|
||||
nhop_pathes = defaultdict(dict)
|
||||
for _, ent in ents_from_query.items():
|
||||
for nbr in ent.get("n_hop_ents", []):
|
||||
for i in range(len(path) - 1):
|
||||
nhop_pathes[(path[i], path[i+1])]["sim"] += ent["sim"] / (2 + i)
|
||||
# Step 4: 融合打分
|
||||
for ent in ents_from_types:
|
||||
if ent in ents_from_query:
|
||||
ents_from_query[ent]["sim"] *= 2 # 类型匹配 boost
|
||||
for (f, t) in rels_from_txt:
|
||||
s = nhop_pathes.get(pair, {}).get("sim", 0)
|
||||
if f in ents_from_types: s += 1
|
||||
if t in ents_from_types: s += 1
|
||||
rels_from_txt[(f, t)]["sim"] *= s + 1 # n-hop + 类型 boost
|
||||
# Step 5: 排序截断
|
||||
ents_from_query = sorted(..., key=lambda x: x[1]["sim"] * x[1]["pagerank"], reverse=True)[:ent_topn]
|
||||
rels_from_txt = sorted(..., key=lambda x: x[1]["sim"] * x[1]["pagerank"], reverse=True)[:rel_topn]
|
||||
# Step 6: 社区报告召回
|
||||
community = self._community_retrieval_([n for n, _ in ents_from_query], filters, kb_ids, idxnms, comm_topn, max_token)
|
||||
return {"page_content": ents + relas + community, "vector": None, ...}
|
||||
```
|
||||
|
||||
#### 5.2.2 Query 改写
|
||||
|
||||
**文件**: `api/app/core/rag/graphrag/search.py:33-55`
|
||||
|
||||
```python
|
||||
def query_rewrite(self, llm, question, idxnms, kb_ids):
|
||||
# 从 ES 获取当前 KB 的实体类型池
|
||||
ty2ents = trio.run(lambda: get_entity_type2samples(idxnms, kb_ids))
|
||||
hint_prompt = PROMPTS["minirag_query2kwd"].format(
|
||||
query=question,
|
||||
TYPE_POOL=json.dumps(ty2ents, ensure_ascii=False, indent=2))
|
||||
result = self._chat(llm, hint_prompt, [{"role": "user", "content": "Output:"}], {})
|
||||
keywords_data = json_repair.loads(result)
|
||||
type_keywords = keywords_data.get("answer_type_keywords", [])
|
||||
entities_from_query = keywords_data.get("entities_from_query", [])[:5]
|
||||
return type_keywords, entities_from_query
|
||||
```
|
||||
|
||||
**设计意图**:
|
||||
- Query 改写将自然语言问题转换为两种结构化信号:
|
||||
1. `answer_type_keywords`:回答类型(如 "ORGANIZATION", "PERSON"),用于类型过滤召回。
|
||||
2. `entities_from_query`:查询中的具体实体,用于向量相似度召回。
|
||||
- 类型池 `ty2ents` 从 ES 中已建图谱的实体类型采样而来,保证类型建议与当前知识库实际类型一致。
|
||||
|
||||
#### 5.2.3 实体向量召回
|
||||
|
||||
**文件**: `api/app/core/rag/graphrag/search.py:96-106`
|
||||
|
||||
```python
|
||||
def get_relevant_ents_by_keywords(self, keywords, filters, idxnms, kb_ids, emb_mdl, sim_thr=0.3, N=56):
|
||||
filters["knowledge_graph_kwd"] = "entity"
|
||||
matchDense = self.get_vector(", ".join(keywords), emb_mdl, 1024, sim_thr)
|
||||
es_res = self.dataStore.search(
|
||||
["page_content", "entity_kwd", "rank_flt"], [], filters, [matchDense],
|
||||
OrderByExpr(), 0, N, idxnms, kb_ids)
|
||||
return self._ent_info_from_(es_res, sim_thr)
|
||||
```
|
||||
|
||||
**设计要点**:
|
||||
- 实体和关系都以独立 chunk 形式存储在 ES 中,附带 dense_vector 字段。
|
||||
- 向量维度由 embedding model 决定,存储字段名为 `q_{dim}_vec`。
|
||||
- `sim_thr=0.3` 为默认相似度阈值,过滤低质量匹配。
|
||||
|
||||
#### 5.2.4 n-hop 路径扩展与融合公式
|
||||
|
||||
**文件**: `api/app/core/rag/graphrag/search.py:160-210`
|
||||
|
||||
```python
|
||||
# n-hop 路径:从命中实体出发,沿预计算的邻居路径扩展
|
||||
for _, ent in ents_from_query.items():
|
||||
nhops = ent.get("n_hop_ents", [])
|
||||
for nbr in nhops:
|
||||
path = nbr["path"]
|
||||
wts = nbr["weights"]
|
||||
for i in range(len(path) - 1):
|
||||
f, t = path[i], path[i + 1]
|
||||
if (f, t) in nhop_pathes:
|
||||
nhop_pathes[(f, t)]["sim"] += ent["sim"] / (2 + i)
|
||||
else:
|
||||
nhop_pathes[(f, t)]["sim"] = ent["sim"] / (2 + i)
|
||||
nhop_pathes[(f, t)]["pagerank"] = wts[i]
|
||||
|
||||
# 融合公式:P(E|Q) ≈ P(E) * P(Q|E) → pagerank * sim
|
||||
# 实体排序:score = sim × pagerank
|
||||
ents_from_query = sorted(ents_from_query.items(),
|
||||
key=lambda x: x[1]["sim"] * x[1]["pagerank"], reverse=True)[:ent_topn]
|
||||
```
|
||||
|
||||
**设计意图**:
|
||||
- n-hop 路径在实体入库时预计算(通过 NetworkX 邻居遍历),存储在 `n_hop_with_weight` 字段。
|
||||
- 距离越远的 hop,贡献权重按 `1/(2+i)` 衰减(1-hop: 1/3, 2-hop: 1/4...)。
|
||||
- 最终排序融合了两个信号:向量相似度(P(Q|E),查询与实体的语义匹配)和 PageRank(P(E),实体在全局图谱中的重要性)。
|
||||
|
||||
#### 5.2.5 与向量检索的协同
|
||||
|
||||
GraphRAG 检索**不替代**向量检索,而是作为**并行的召回源**之一。在 `settings.py` 中:
|
||||
|
||||
```python
|
||||
kg_retriever = kg_search.KGSearch(docStoreConn) # 图谱检索器
|
||||
retriever = search.Dealer(docStoreConn) # 向量检索器
|
||||
```
|
||||
|
||||
上层调用方(如对话工作流)会同时调用两者,将图谱召回结果(Entities + Relations + Community Reports)与向量召回的 Document Chunks 一起送入 LLM 上下文。
|
||||
|
||||
---
|
||||
|
||||
## 6. Light vs General 差异详解
|
||||
|
||||
### 6.1 功能对比
|
||||
|
||||
| 维度 | Light | General | 说明 |
|
||||
|---|---|---|---|
|
||||
| **实体抽取 Prompt** | LightRAG 风格,含 content_keywords | MS GraphRAG 风格,更简洁 | `light/graph_prompt.py` vs `general/graph_prompt.py` |
|
||||
| **Gleaning 终止** | 自然语言 yes/no | 强制单字 Y(logit_bias) | Light 更灵活,General 更确定 |
|
||||
| **实体消歧** | ❌ 不支持 | ✅ 支持 | `entity_resolution.py` 仅在 General 流程中调用 |
|
||||
| **社区发现** | ❌ 不支持 | ✅ Leiden 算法 | `general/leiden.py` |
|
||||
| **社区报告** | ❌ 不支持 | ✅ LLM 生成报告 | `general/community_reports_extractor.py` |
|
||||
| **实体嵌入** | 仅实体名向量 | 支持 Node2Vec(备用) | `general/entity_embedding.py` 当前未在主线使用 |
|
||||
| **思维导图** | ❌ 不支持 | ✅ 支持 | `general/mind_map_extractor.py` |
|
||||
| **并发控制** | 相同 | 相同 | `trio.Semaphore` + `chat_limiter` |
|
||||
| **建图耗时** | 低(无消歧/社区) | 高(消歧 + 社区报告 ≈ 额外 10-30 分钟) | |
|
||||
| **Token 消耗** | 低 | 高(社区报告每社区一次 LLM 调用) | |
|
||||
| **适用数据规模** | < 1K 文档 | > 1K 文档 | |
|
||||
|
||||
### 6.2 切换条件
|
||||
|
||||
**配置入口**:`parser_config["graphrag"]["method"]`
|
||||
|
||||
```python
|
||||
# api/app/core/rag/graphrag/general/index.py:54
|
||||
extractor = LightKGExt if (
|
||||
"method" not in row["parser_config"].get("graphrag", {})
|
||||
or row["parser_config"]["graphrag"]["method"] != "general"
|
||||
) else GeneralKGExt
|
||||
```
|
||||
|
||||
| 条件 | 推荐模式 |
|
||||
|---|---|
|
||||
| `parser_config.graphrag.method` 未设置 或 != `"general"` | **Light**(默认) |
|
||||
| `parser_config.graphrag.method == "general"` | **General** |
|
||||
| `with_resolution=True` 且 method=general | General + 实体消歧 |
|
||||
| `with_community=True` 且 method=general | General + 社区报告 |
|
||||
|
||||
### 6.3 资源消耗对比(估算)
|
||||
|
||||
以 1000 个 chunk(约 50 万字)的知识库为例:
|
||||
|
||||
| 阶段 | Light | General | 差异原因 |
|
||||
|---|---|---|---|
|
||||
| 实体抽取 | ~100 次 LLM 调用 | ~100 次 LLM 调用 | 两者类似 |
|
||||
| 实体消歧 | 0 | ~10-50 次 LLM 调用 | 候选对数量取决于实体重复率 |
|
||||
| 社区报告 | 0 | ~20-100 次 LLM 调用 | 社区数量取决于图密度 |
|
||||
| 总 Token | ~500K-1M | ~2M-5M | General 多轮摘要 + 社区报告 |
|
||||
| 总时间 | ~5-15 分钟 | ~30-60 分钟 | 消歧和社区是主要耗时 |
|
||||
| ES 存储 | ~实体数 + 关系数 | + 社区报告数 + 全局图 | |
|
||||
|
||||
---
|
||||
|
||||
## 7. 关键 Prompt 解读
|
||||
|
||||
### 7.1 Query 分析 Prompt:`minirag_query2kwd`
|
||||
|
||||
**文件**: `api/app/core/rag/graphrag/query_analyze_prompt.py:9-155`
|
||||
|
||||
```
|
||||
---Role---
|
||||
You are a helpful assistant tasked with identifying both answer-type and low-level keywords...
|
||||
|
||||
---Goal---
|
||||
Given the query, list both answer-type and low-level keywords.
|
||||
answer_type_keywords focus on the type of the answer...
|
||||
The answer_type_keywords must be selected from Answer type pool.
|
||||
|
||||
---Instructions---
|
||||
- Output the keywords in JSON format.
|
||||
- "answer_type_keywords" for the types of the answer... No more than 3.
|
||||
- "entities_from_query" for specific entities or details.
|
||||
```
|
||||
|
||||
**设计意图逐行解读**:
|
||||
|
||||
| Prompt 片段 | 设计意图 |
|
||||
|---|---|
|
||||
| `answer_type_keywords must be selected from Answer type pool` | 强制从知识库实际存在的类型中选择,避免 LLM 编造不存在的类型。类型池从已建图谱采样,保证类型有效性。 |
|
||||
| `No more than 3` | 限制类型数量,防止过度发散导致召回噪声。 |
|
||||
| `entities_from_query must be extracted from the query` | 强调实体必须从查询原文提取,禁止 LLM 扩展或推测,保证召回精确性。 |
|
||||
| 4 个覆盖不同领域的示例 | Few-shot 示例涵盖时间、地点、组织、抽象概念,帮助 LLM 理解类型判定逻辑。 |
|
||||
| `TYPE_POOL` 动态注入 | 运行时从 ES 查询当前 KB 的实体类型分布,使类型建议与知识库内容一致。 |
|
||||
|
||||
### 7.2 实体消歧 Prompt:`ENTITY_RESOLUTION_PROMPT`
|
||||
|
||||
**文件**: `api/app/core/rag/graphrag/entity_resolution_prompt.py:1-58`
|
||||
|
||||
```
|
||||
-Goal-
|
||||
Please answer the following Question as required
|
||||
|
||||
-Steps-
|
||||
1. Identify each line of questioning as required
|
||||
2. Return output in English as a single list of each line answer...
|
||||
Use **{record_delimiter}** as the list delimiter.
|
||||
|
||||
-Examples-
|
||||
Example 1: Product 对比(computer vs phone → No,television vs TV → No)
|
||||
Example 2: Toponym 对比(Chicago vs ChiTown → Yes,Shanghai vs Zhengzhou → No)
|
||||
|
||||
-Real Data-
|
||||
Question:{input_text}
|
||||
```
|
||||
|
||||
**设计意图逐行解读**:
|
||||
|
||||
| Prompt 片段 | 设计意图 |
|
||||
|---|---|
|
||||
| `only focus on critical properties and overlook noisy factors` | 引导 LLM 关注核心语义特征,忽略大小写、缩写、冠词等噪声。 |
|
||||
| `Use domain knowledge of {entity_type}s` | 提示 LLM 利用领域知识辅助判断(如 "Peking" = "Beijing" 在地理领域成立)。 |
|
||||
| `answer the above N questions in the format: For Question i, Yes/No...` | 强制固定输出格式,便于正则解析。 |
|
||||
| `##` record_delimiter + `<\|>` entity_index_delimiter + `&&` resolution_result_delimiter | 三层分隔符设计,降低解析冲突概率。 |
|
||||
| 两个示例分别覆盖产品和地名 | 展示不同领域的消歧标准差异,增强泛化能力。 |
|
||||
|
||||
**注意**:示例中 "television vs TV → No" 和 "Chicago vs ChiTown → Yes" 看起来矛盾,实际上是在**引导 LLM 区分"缩写是否代表同一实体"**——TV 是 television 的缩写(同一事物),但 Prompt 标注为 No,可能是示例错误;而 Chicago vs ChiTown(俚语别称)标注为 Yes。这个示例设计值得商榷,实际效果取决于 LLM 的理解。
|
||||
|
||||
### 7.3 Light 版实体抽取 Prompt
|
||||
|
||||
**文件**: `api/app/core/rag/graphrag/light/graph_prompt.py:20-59`
|
||||
|
||||
```
|
||||
---Goal---
|
||||
Given a text document... identify all entities... and all relationships...
|
||||
|
||||
---Steps---
|
||||
1. Identify all entities. Format: ("entity"{tuple_delimiter}<name>{tuple_delimiter}<type>{tuple_delimiter}<description>)
|
||||
2. Identify all relationships. Format: ("relationship"{tuple_delimiter}<src>{tuple_delimiter}<tgt>{tuple_delimiter}<desc>{tuple_delimiter}<keywords>{tuple_delimiter}<strength>)
|
||||
3. Identify high-level key words... Format: ("content_keywords"{tuple_delimiter}<keywords>)
|
||||
4. Return output as a single list...
|
||||
5. When finished, output {completion_delimiter}
|
||||
```
|
||||
|
||||
**设计意图**:
|
||||
- **Tuple 格式**:`("entity"<\|>NAME<\|>TYPE<\|>DESC)` 使用固定分隔符,便于正则提取,比 JSON 更抗格式错误。
|
||||
- **content_keywords**:额外提取文档级关键词,可用于后续检索增强或标签分类。
|
||||
- **relationship_keywords**:关系关键词用于关系 chunk 的文本检索补充。
|
||||
- **strength**:关系强度(1-10)用于后续排序加权。
|
||||
- **多轮 gleaning**:首轮抽取后,用 `"MANY entities were missed"` 追问,最多 2 轮(`ENTITY_EXTRACTION_MAX_GLEANINGS=2`)。
|
||||
|
||||
### 7.4 General 版实体抽取 Prompt
|
||||
|
||||
**文件**: `api/app/core/rag/graphrag/general/graph_prompt.py:8-106`
|
||||
|
||||
与 Light 版的主要差异:
|
||||
- **无 content_keywords**:仅抽取 entity + relationship,更聚焦。
|
||||
- **无 relationship_keywords**:关系描述更简洁。
|
||||
- **无 strength 数值**:关系权重由出现频率决定(非 LLM 评分)。
|
||||
- **LOOP_PROMPT 使用 logit_bias**:强制输出单字 `Y` 或 `N`,比 Light 的自然语言判断更确定。
|
||||
|
||||
### 7.5 社区报告 Prompt
|
||||
|
||||
**文件**: `api/app/core/rag/graphrag/general/community_report_prompt.py:8-157`
|
||||
|
||||
```
|
||||
# Goal
|
||||
Write a comprehensive report of a community...
|
||||
|
||||
# Report Structure
|
||||
- TITLE: community's name...
|
||||
- SUMMARY: An executive summary...
|
||||
- IMPACT SEVERITY RATING: a float score between 0-10...
|
||||
- RATING EXPLANATION: single sentence...
|
||||
- DETAILED FINDINGS: 5-10 key insights...
|
||||
|
||||
# Grounding Rules
|
||||
Points supported by data should list their data references as follows:
|
||||
"...supported by multiple data references [Data: <dataset name> (record ids)]"
|
||||
```
|
||||
|
||||
**设计意图**:
|
||||
- **结构化 JSON 输出**:强制 `title/summary/rating/rating_explanation/findings` 五字段,便于程序解析。
|
||||
- **影响力评级(0-10)**:量化社区重要性,检索时按 `weight_flt` 排序优先返回高影响力社区。
|
||||
- **Grounding Rules**:要求引用数据记录 ID,增强可解释性(虽然当前实现未实际利用这些引用)。
|
||||
- **示例输入**:提供 `VERDANT OASIS PLAZA` 和 `HARMONY ASSEMBLY` 的完整示例,展示输出格式和数据引用方式。
|
||||
|
||||
---
|
||||
|
||||
## 8. 图谱存储设计
|
||||
|
||||
### 8.1 不使用 Neo4j
|
||||
|
||||
MemoryBear 的 GraphRAG **不依赖 Neo4j** 等专用图数据库,而是复用 Elasticsearch 作为统一存储。理由:
|
||||
1. **运维简化**:无需维护额外的图数据库集群。
|
||||
2. **混合检索**:实体/关系的向量嵌入与文档 chunk 存储在同一张索引,便于统一检索。
|
||||
3. **增量更新**:ES 的文档模型天然支持增量写入和版本管理。
|
||||
|
||||
### 8.2 ES 文档类型(knowledge_graph_kwd)
|
||||
|
||||
| 类型 | 存储内容 | 关键字段 |
|
||||
|---|---|---|
|
||||
| `graph` | 全局图(NetworkX node_link_data JSON) | `page_content`(JSON)、`source_id` |
|
||||
| `subgraph` | 单文档子图 | `page_content`(JSON)、`source_id` |
|
||||
| `entity` | 单个实体(可向量检索) | `entity_kwd`、`entity_type_kwd`、`rank_flt`、`q_*_vec` |
|
||||
| `relation` | 单个关系(可向量检索) | `from_entity_kwd`、`to_entity_kwd`、`weight_int`、`q_*_vec` |
|
||||
| `community_report` | 社区报告 | `docnm_kwd`(标题)、`weight_flt`、`entities_kwd` |
|
||||
| `ty2ents` | 类型→实体样例映射 | `page_content`(JSON dict) |
|
||||
|
||||
### 8.3 向量嵌入策略
|
||||
|
||||
**文件**: `api/app/core/rag/graphrag/utils.py:301-327`(实体)和 `352-378`(关系)
|
||||
|
||||
```python
|
||||
async def graph_node_to_chunk(kb_id, embd_mdl, ent_name, meta, chunks):
|
||||
chunk = {
|
||||
"entity_kwd": ent_name,
|
||||
"knowledge_graph_kwd": "entity",
|
||||
"entity_type_kwd": meta["entity_type"],
|
||||
"page_content": json.dumps(meta, ensure_ascii=False),
|
||||
...
|
||||
}
|
||||
# 实体向量 = entity_name 的 embedding
|
||||
ebd, _ = embd_mdl.encode([ent_name])
|
||||
chunk["q_%d_vec" % len(ebd)] = ebd
|
||||
|
||||
async def graph_edge_to_chunk(kb_id, embd_mdl, from_ent_name, to_ent_name, meta, chunks):
|
||||
# 关系向量 = "from->to: description" 的 embedding
|
||||
txt = f"{from_ent_name}->{to_ent_name}"
|
||||
ebd, _ = embd_mdl.encode([txt + f": {meta['description']}"])
|
||||
chunk["q_%d_vec" % len(ebd)] = ebd
|
||||
```
|
||||
|
||||
**设计要点**:
|
||||
- 实体向量基于**实体名**(`ent_name`),而非描述文本——因为检索时用户查询通常包含实体名。
|
||||
- 关系向量基于 `"from->to: description"`,兼顾结构信息和语义信息。
|
||||
- 向量缓存:通过 Redis + xxhash 缓存 embedding 结果,避免重复计算。
|
||||
|
||||
---
|
||||
|
||||
## 9. 配置项与可调参数
|
||||
|
||||
### 9.1 环境变量
|
||||
|
||||
| 环境变量 | 默认值 | 说明 | 源码位置 |
|
||||
|---|---|---|---|
|
||||
| `MAX_CONCURRENT_CHATS` | 10 | LLM 并发调用上限(trio CapacityLimiter) | `utils.py:41` |
|
||||
| `MAX_CONCURRENT_PROCESS_AND_EXTRACT_CHUNK` | 10 | Chunk 处理并发上限 | `general/extractor.py:33` |
|
||||
| `ENABLE_TIMEOUT_ASSERTION` | 未设置 | 测试模式:启用短超时(3-280s) | 多处 `trio.fail_after` |
|
||||
|
||||
### 9.2 parser_config 配置
|
||||
|
||||
**文件**: `api/app/models/knowledge_model.py:77-82` / `document_model.py:27-32`
|
||||
|
||||
```python
|
||||
"graphrag": {
|
||||
"use_graphrag": False, # 总开关
|
||||
"method": "light", # "light" 或 "general"
|
||||
"resolution": False, # 是否启用实体消歧(仅 General)
|
||||
"community": False, # 是否启用社区报告(仅 General)
|
||||
"entity_types": [] # 自定义实体类型列表,空则使用默认值
|
||||
}
|
||||
```
|
||||
|
||||
### 9.3 检索参数
|
||||
|
||||
**文件**: `api/app/core/rag/graphrag/search.py:130-141`
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `max_token` | 8196 | 返回结果的总 token 预算 |
|
||||
| `ent_topn` | 6 | 返回实体数量上限 |
|
||||
| `rel_topn` | 6 | 返回关系数量上限 |
|
||||
| `comm_topn` | 1 | 返回社区报告数量上限 |
|
||||
| `ent_sim_threshold` | 0.3 | 实体向量相似度阈值 |
|
||||
| `rel_sim_threshold` | 0.3 | 关系向量相似度阈值 |
|
||||
|
||||
### 9.4 消歧参数
|
||||
|
||||
**文件**: `api/app/core/rag/graphrag/entity_resolution.py`
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `resolution_batch_size` | 100 | 每批消歧的实体对数量 |
|
||||
| `max_concurrent_tasks` | 5 | 消歧 LLM 调用并发数 |
|
||||
| 超时 | 280s(测试)/ 无限(生产) | `trio.move_on_after` |
|
||||
|
||||
### 9.5 社区发现参数
|
||||
|
||||
**文件**: `api/app/core/rag/graphrag/general/leiden.py:97`
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `max_cluster_size` | 12 | 单个社区最大节点数 |
|
||||
| `use_lcc` | True | 是否只取最大连通分量 |
|
||||
| `seed` | 0xDEADBEEF | Leiden 算法随机种子 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 边界条件与已知限制
|
||||
|
||||
### 10.1 已知限制
|
||||
|
||||
| 限制 | 影响 | 缓解措施 |
|
||||
|---|---|---|
|
||||
| 实体消歧仅处理 subgraph_nodes 内的节点 | 历史已消歧的节点不再参与新一轮消歧 | 手动重建图谱触发全量消歧 |
|
||||
| 社区报告忽略 < 2 个节点的社区 | 孤立实体无社区报告覆盖 | 通过实体直接召回补充 |
|
||||
| 关系抽取忽略无对应实体的关系 | 实体抽取失败导致关系丢失 | `tidy_graph` 后检查日志 |
|
||||
| LLM 输出格式错误导致解析失败 | 部分 chunk 的实体/关系丢失 | `json_repair` 库容错 + 错误计数限制(max_errors=3) |
|
||||
| 实体名大写归一化 | "Apple" 和 "apple" 被视为同一实体 | 设计如此,避免大小写重复 |
|
||||
| 中文编辑距离用字符集 Jaccard | 对短实体(< 4 字)阈值不同 | `is_similarity` 中特殊处理 |
|
||||
| 图谱全量重建需遍历所有 subgraph | 大数据集重建耗时高 | 增量合并避免全量重建 |
|
||||
|
||||
### 10.2 幂等性与并发安全
|
||||
|
||||
- `generate_subgraph()` 检查 `does_graph_contains()`,避免同一文档重复建图。
|
||||
- `merge_subgraph()` 使用 `RedisDistributedLock` 保证同一 KB 的并发合并安全。
|
||||
- `run_graphrag_for_kb()` 支持 `max_parallel_documents=4`,控制文档级并发。
|
||||
|
||||
### 10.3 任务取消
|
||||
|
||||
所有长时操作(抽取、消歧、社区报告)都穿插 `has_canceled(task_id)` 检查,支持用户通过 Redis 键取消任务:
|
||||
|
||||
```python
|
||||
def has_canceled(task_id):
|
||||
return redis_client.get(f"{task_id}-cancel") is not None
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 监控指标与排错指引
|
||||
|
||||
### 11.1 关键日志
|
||||
|
||||
| 日志模式 | 含义 | 排查方向 |
|
||||
|---|---|---|
|
||||
| `ignored X relations due to missing entities` | 关系指向的实体未抽取到 | 检查 LLM 输出格式,或降低 tidy_graph 的清理标准 |
|
||||
| `Resolved X candidate pairs, Y of them are selected to merge` | 实体消歧结果统计 | Y/X 过低说明预筛选太严格或 LLM 过于保守 |
|
||||
| `Graph extracted X communities in Ys` | 社区发现完成 | 社区数异常(0 或过多)检查图谱连通性 |
|
||||
| `Task {id} cancelled during...` | 任务被取消 | 正常用户行为,无需排查 |
|
||||
| `Didn't extract any entities and relationships` | LLM 返回空 | 检查 LLM 可用性、Prompt 长度是否超限 |
|
||||
| `Insert chunk error` | ES 写入失败 | 检查 ES 集群状态、索引 mapping |
|
||||
|
||||
### 11.2 性能指标
|
||||
|
||||
| 指标 | 采集方式 | 健康阈值 |
|
||||
|---|---|---|
|
||||
| 单文档建图耗时 | callback 日志 | Light < 5min,General < 30min |
|
||||
| 实体抽取 Token 消耗 | `sum_token_count` | 关注单 chunk 消耗是否异常高 |
|
||||
| ES 查询延迟 | `dataStore.search` 耗时 | P99 < 500ms |
|
||||
| LLM 调用成功率 | 错误日志计数 | > 95% |
|
||||
| 消歧候选对数量 | `num_candidates` | 与节点数平方成正比,关注异常增长 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 优化建议与未来扩展点
|
||||
|
||||
### 12.1 短期优化(1-2 周可落地)
|
||||
|
||||
1. **实体消歧预筛选优化**:当前 `is_similarity` 对中文使用字符集 Jaccard,对同音字/形近字(如"阿里巴巴" vs "阿狸巴巴")效果差。建议引入拼音相似度或字形相似度作为第三层预筛选。
|
||||
2. **消歧 Prompt 示例修正**:`entity_resolution_prompt.py` 中 "television vs TV → No" 的示例与常识矛盾,建议修正为 Yes,避免误导 LLM。
|
||||
3. **社区报告并发控制**:当前 `community_reports_extractor.py` 对每个社区启动一个 trio task,社区数过多时会压垮 LLM。建议增加社区级并发限制。
|
||||
4. **关系向量优化**:当前关系向量使用 `"from->to: description"`,但 description 可能很长。建议仅使用 `"from->to"` 或关系关键词作为嵌入文本,提升检索效率。
|
||||
|
||||
### 12.2 中期扩展(1-2 月)
|
||||
|
||||
1. **多跳推理增强**:当前 n-hop 路径是预计算的静态数据。可考虑在检索阶段动态执行多跳遍历,支持更灵活的推理路径。
|
||||
2. **时序图谱**:在关系/实体上增加时间维度,支持"某实体在某时间段的关系变化"类查询。
|
||||
3. **图可视化 API**:基于 `nx.node_link_data` 输出,提供前端可消费的图数据接口,支持交互式图谱浏览。
|
||||
4. **增量实体类型发现**:当前实体类型是静态配置。可通过 LLM 自动发现文档中的新实体类型,动态扩展类型池。
|
||||
|
||||
### 12.3 长期方向(路线图)
|
||||
|
||||
1. **GraphRAG + 多模态**:将图片中的实体(如 OCR 提取的组织 logo)纳入图谱,支持跨模态实体关联。
|
||||
2. **动态图谱更新**:当前是批处理模式(文档上传后触发建图)。可探索流式更新,支持实时知识库编辑后的图谱增量更新。
|
||||
3. **替代 ES 的图数据库评估**:当图谱规模达到百万节点级别时,ES 的图查询性能可能成为瓶颈。可评估 Neo4j / Dgraph 等专用图数据库的接入可行性。
|
||||
|
||||
---
|
||||
|
||||
## 附录:源码索引速查表
|
||||
|
||||
| 功能 | 文件 | 关键类/函数 | 行号 |
|
||||
|---|---|---|---|
|
||||
| 建图总控 | `general/index.py` | `run_graphrag()` | 36-119 |
|
||||
| KB 级批量建图 | `general/index.py` | `run_graphrag_for_kb()` | 122-330 |
|
||||
| 子图生成 | `general/index.py` | `generate_subgraph()` | 333-406 |
|
||||
| 子图合并 | `general/index.py` | `merge_subgraph()` | 409-436 |
|
||||
| Light 实体抽取 | `light/graph_extractor.py` | `GraphExtractor._process_single_content()` | 74-131 |
|
||||
| General 实体抽取 | `general/graph_extractor.py` | `GraphExtractor._process_single_content()` | 100-150 |
|
||||
| 抽取基类 | `general/extractor.py` | `Extractor.__call__()` | 97-203 |
|
||||
| 节点合并 | `general/extractor.py` | `Extractor._merge_nodes()` | 205-225 |
|
||||
| 边合并 | `general/extractor.py` | `Extractor._merge_edges()` | 227-236 |
|
||||
| 图节点合并 | `general/extractor.py` | `Extractor._merge_graph_nodes()` | 238-275 |
|
||||
| 描述摘要 | `general/extractor.py` | `Extractor._handle_entity_relation_summary()` | 277-300 |
|
||||
| 实体消歧 | `entity_resolution.py` | `EntityResolution.__call__()` | 53-141 |
|
||||
| 消歧候选判断 | `entity_resolution.py` | `EntityResolution._resolve_candidate()` | 143-186 |
|
||||
| 结果解析 | `entity_resolution.py` | `EntityResolution._process_results()` | 188-213 |
|
||||
| 相似度预筛选 | `entity_resolution.py` | `EntityResolution.is_similarity()` | 225-239 |
|
||||
| 社区发现 | `general/leiden.py` | `run()` | 95-141 |
|
||||
| 社区报告抽取 | `general/community_reports_extractor.py` | `CommunityReportsExtractor.__call__()` | 55-158 |
|
||||
| 图谱检索 | `search.py` | `KGSearch.retrieval()` | 130-280 |
|
||||
| Query 改写 | `search.py` | `KGSearch.query_rewrite()` | 33-55 |
|
||||
| 实体向量召回 | `search.py` | `KGSearch.get_relevant_ents_by_keywords()` | 96-106 |
|
||||
| 关系向量召回 | `search.py` | `KGSearch.get_relevant_relations_by_txt()` | 107-117 |
|
||||
| 类型过滤召回 | `search.py` | `KGSearch.get_relevant_ents_by_types()` | 118-128 |
|
||||
| 社区报告召回 | `search.py` | `KGSearch._community_retrieval_()` | 282-302 |
|
||||
| 图合并工具 | `utils.py` | `graph_merge()` | 199-229 |
|
||||
| 图写入 ES | `utils.py` | `set_graph()` | 426-516 |
|
||||
| 图读取 ES | `utils.py` | `get_graph()` | 407-423 |
|
||||
| 实体转 chunk | `utils.py` | `graph_node_to_chunk()` | 301-327 |
|
||||
| 关系转 chunk | `utils.py` | `graph_edge_to_chunk()` | 352-378 |
|
||||
| LLM 缓存 | `utils.py` | `get_llm_cache()` / `set_llm_cache()` | 97-113 |
|
||||
| 任务取消检查 | `utils.py` | `has_canceled()` | 628-634 |
|
||||
| Query 分析 Prompt | `query_analyze_prompt.py` | `PROMPTS["minirag_query2kwd"]` | 9-155 |
|
||||
| 消歧 Prompt | `entity_resolution_prompt.py` | `ENTITY_RESOLUTION_PROMPT` | 1-58 |
|
||||
| Light 抽取 Prompt | `light/graph_prompt.py` | `PROMPTS["entity_extraction"]` | 20-59 |
|
||||
| General 抽取 Prompt | `general/graph_prompt.py` | `GRAPH_EXTRACTION_PROMPT` | 8-106 |
|
||||
| 社区报告 Prompt | `general/community_report_prompt.py` | `COMMUNITY_REPORT_PROMPT` | 8-157 |
|
||||
| 建图触发入口 | `tasks.py` | `build_graphrag_for_document()` | 557-636 |
|
||||
| KB 建图触发 | `tasks.py` | `build_graphrag_for_kb()` | 472-556 |
|
||||
| 模型默认配置 | `models/knowledge_model.py` | `parser_config["graphrag"]` | 77-82 |
|
||||
445
docs/rag/pipeline/05-reranking-prompt-llm.md
Normal file
445
docs/rag/pipeline/05-reranking-prompt-llm.md
Normal file
@@ -0,0 +1,445 @@
|
||||
---
|
||||
|
||||
# [S2-T5] 检索后处理与生成(Reranking / Prompt 工程 / LLM 调用 / 后处理)实现详解
|
||||
|
||||
**author:** Python 开发工程师
|
||||
**source-commit:** `feae2f2e` (Merge PR #1033 release/v0.3.2)
|
||||
**reviewer:** 待 [S2-T7] 评审
|
||||
**last-reviewed-at:** 2026-05-08
|
||||
|
||||
---
|
||||
|
||||
## 一句话定位
|
||||
|
||||
本文档覆盖 MemoryBear RAG 链路的后半段:从检索结果进入系统,到最终 LLM 生成答案并输出给用户的全过程,包括重排序、Prompt 组装、多模型 LLM 调用、流式输出、工具调用及生成后处理。
|
||||
|
||||
## 设计目标与适用场景
|
||||
|
||||
- **设计目标**:在多知识库、多检索策略(关键词 / 向量 / 混合 / GraphRAG)返回的原始结果上,通过重排序提升相关性,通过 Prompt 工程高效利用上下文,通过多提供商 LLM 封装实现高可用调用,最终输出带引用溯源、支持流式/非流式的答案。
|
||||
- **适用场景**:
|
||||
- Agent 聊天(`app_chat_service.py` / `draft_run_service.py`)
|
||||
- Workflow 知识检索节点(`workflow/nodes/knowledge/node.py`)
|
||||
- 独立 chunk 检索 API(`chunk_controller.py`)
|
||||
|
||||
## 关键概念与术语表
|
||||
|
||||
| 术语 | 含义 |
|
||||
|------|------|
|
||||
| Rerank | 在初步召回后对 chunk 进行精细重排序 |
|
||||
| RedBearRerank | 基于 LangChain `BaseDocumentCompressor` 的 rerank 封装 |
|
||||
| Dealer | 底层检索调度器,负责混合搜索、内置 rerank、引用插入 |
|
||||
| KnowledgeRetrievalNode | Workflow 引擎中的知识检索节点 |
|
||||
| LangChainAgent | 基于 `create_agent` 的 ReAct Agent,负责工具调用循环 |
|
||||
| citation | 生成后处理阶段向答案文本中插入 `[ID:N]` 引用标记 |
|
||||
| rank_feature | 基于 tag 特征和 PageRank 的辅助排序分 |
|
||||
|
||||
## 实现概览(Mermaid 流程图)
|
||||
|
||||
```
|
||||
检索结果输入
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Rerank 层 │
|
||||
│ A:内置混合 │
|
||||
│ B:外部模型 │
|
||||
│ C:RedBearRerank │
|
||||
│ D:ES层封装 │
|
||||
└────────┬────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ Prompt 工程与上下文组装 │
|
||||
│ 系统 Prompt + 技能 Prompt │
|
||||
│ 知识上下文拼接 │
|
||||
│ Token 预算管理 │
|
||||
└────────┬────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ LLM 调用层 (LangChainAgent)│
|
||||
│ ReAct 工具调用循环 │
|
||||
│ 流式/非流式 │
|
||||
│ 多模态 + 深度思考 │
|
||||
└────────┬────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ 生成后处理 │
|
||||
│ 引用过滤 + 下载链接 │
|
||||
│ 引用插入 (embedding 匹配) │
|
||||
│ JSON 结构化校验 │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Reranking 章节
|
||||
|
||||
### 1.1 是否使用显式 Rerank
|
||||
|
||||
**是**。MemoryBear 在多处实现了 rerank,采用"多方案并存、按场景选择"策略。
|
||||
|
||||
### 1.2 Rerank 方案全景
|
||||
|
||||
#### 方案 A:内置混合 Rerank(Dealer.rerank)
|
||||
|
||||
**源码**:`api/app/core/rag/nlp/search.py:606-643`
|
||||
|
||||
核心融合公式:
|
||||
```
|
||||
score = tkweight * token_similarity + vtweight * vector_similarity + rank_feature
|
||||
```
|
||||
|
||||
- `tkweight` 默认 0.3,`vtweight` 默认 0.7
|
||||
- `token_similarity`:基于 rag_tokenizer 分词后的 Jaccard 风格相似度
|
||||
- `vector_similarity`:query_vector 与 chunk 向量的余弦相似度
|
||||
- `rank_feature`:tag 特征 TF-IDF 余弦 + PageRank,缩放 10 倍(`search.py:579-604`)
|
||||
- token 权重分配:`content_ltks + title_tks*2 + important_kwd*5 + question_tks*6`
|
||||
|
||||
#### 方案 B:外部 Rerank 模型(Dealer.rerank_by_model)
|
||||
|
||||
**源码**:`api/app/core/rag/nlp/search.py:645-666`
|
||||
|
||||
将向量相似度替换为外部 rerank 模型的 `similarity()` 输出,保留 token 相似度和 rank_feature。
|
||||
|
||||
#### 方案 C:RedBearRerank(LCEL 兼容封装)
|
||||
|
||||
**源码**:`api/app/core/models/rerank.py:11-84`
|
||||
|
||||
- 继承 `langchain_core.documents.BaseDocumentCompressor`
|
||||
- 支持 `XINFERENCE` / `GPUSTACK` → `JinaRerank`
|
||||
- 支持 `DASHSCOPE` → `DashScopeRerank`
|
||||
- 端点自动规范化:补齐 `/v1/rerank`
|
||||
|
||||
使用场景:
|
||||
- Workflow `KnowledgeRetrievalNode.rerank()`(`node.py:108-155`)
|
||||
- `ElasticSearchVector.rerank()`(`elasticsearch_vector.py:560-607`)
|
||||
- `nlp/search.py:rerank()`(`search.py:284-343`)
|
||||
|
||||
#### 方案 D:ElasticSearchVector 层 Rerank
|
||||
|
||||
ES Vector 初始化时注入 `reranker_config`,`rerank()` 中调用 `self.reranker.compress_documents()`。
|
||||
|
||||
### 1.3 阈值与延迟
|
||||
|
||||
- **内置 rerank**:本地 numpy 计算,毫秒级延迟
|
||||
- **外部 rerank**:网络调用,本地 Xinference <10ms,远程 DashScope 100-500ms
|
||||
- **相似度阈值**:`similarity_threshold` 默认 0.2,低于此值的 chunk 被过滤(`search.py:674-768`)
|
||||
|
||||
### 1.4 为什么没有统一使用 Cross-Encoder
|
||||
|
||||
- Cross-Encoder 需额外部署,对小型部署不友好
|
||||
- 内置 `Dealer.rerank` 在多数场景已足够
|
||||
- RedBearRerank 作为可选增强,仅在显式配置 `reranker_id` 时启用
|
||||
|
||||
---
|
||||
|
||||
## 2. Prompt 工程与上下文组装
|
||||
|
||||
### 2.1 Prompt 模板组织
|
||||
|
||||
**目录**:`api/app/core/rag/prompts/`
|
||||
|
||||
| 模板文件 | 用途 |
|
||||
|----------|------|
|
||||
| `ask_summary.md` | 知识库问答主 Prompt |
|
||||
| `citation_prompt.md` | 引用标注规范(`[ID:i]` 格式) |
|
||||
| `citation_plus.md` | 引用回填 Agent Prompt |
|
||||
| `question_prompt.md` | 文本生成问题 |
|
||||
| `keyword_prompt.md` | 关键词提取 |
|
||||
| `structured_output_prompt.md` | JSON Schema 约束 |
|
||||
| `cross_languages_*.md` | 跨语言查询扩展 |
|
||||
| `analyze_task_*.md` | 任务分析与工具选择 |
|
||||
|
||||
**加载机制**:`api/app/core/rag/prompts/template.py:9-20`,启动时加载并缓存。
|
||||
|
||||
### 2.2 上下文组装流程
|
||||
|
||||
**Agent 层**:`api/app/core/agent/langchain_agent.py:230-271`
|
||||
|
||||
```python
|
||||
def _prepare_messages(self, message, history, context, files):
|
||||
messages = []
|
||||
for msg in history:
|
||||
if msg["role"] == "user": messages.append(HumanMessage(...))
|
||||
elif msg["role"] == "assistant": messages.append(AIMessage(...))
|
||||
user_content = message
|
||||
if context:
|
||||
user_content = f"参考信息:\n{context}\n\n用户问题:\n{user_content}"
|
||||
messages.append(HumanMessage(content=user_content))
|
||||
return messages
|
||||
```
|
||||
|
||||
### 2.3 知识检索工具中的 Chunk 拼接
|
||||
|
||||
**源码**:`api/app/services/draft_run_service.py:227-255`
|
||||
|
||||
```python
|
||||
retrieve_chunks_result = knowledge_retrieval(query, kb_config)
|
||||
retrieval_knowledge = [i.page_content for i in retrieve_chunks_result]
|
||||
context = '\n\n'.join(retrieval_knowledge)
|
||||
return f"检索到以下相关信息:\n\n{context}"
|
||||
```
|
||||
|
||||
- chunk 间用 `\n\n` 分隔
|
||||
- 引用信息(document_id、file_name、score)由外部 `citations_collector` 收集,与上下文字符串分离
|
||||
- 属于"隐式引用"策略:LLM 看不到 `[ID:N]`,引用回填在生成后完成
|
||||
|
||||
### 2.4 Token 预算管理
|
||||
|
||||
**源码**:`api/app/core/rag/prompts/generator.py:46-80`
|
||||
|
||||
策略:
|
||||
1. 计算总 token;未超限直接返回
|
||||
2. 超限后保留 `system` + 最后一条消息,丢弃中间历史
|
||||
3. 仍超限则按比例截断 system 或 user 内容
|
||||
|
||||
### 2.5 System / User 分层结构
|
||||
|
||||
```
|
||||
system: {用户自定义 system_prompt} + {技能 Prompt} + {文档图片识别指令}
|
||||
user: {历史消息...}
|
||||
user: 参考信息:\n\n{chunks}\n\n用户问题:\n{query}
|
||||
```
|
||||
|
||||
System Prompt 组装见 `app_chat_service.py:77-96`:先变量替换,再追加 skill_prompts。
|
||||
|
||||
---
|
||||
|
||||
## 3. LLM 调用
|
||||
|
||||
### 3.1 支持的模型与切换机制
|
||||
|
||||
**核心封装**:`api/app/core/rag/llm/chat_model.py:52-63`
|
||||
|
||||
`Base` 类基于 OpenAI 兼容 API,子类覆盖:
|
||||
|
||||
| 类名 | 提供商 |
|
||||
|------|--------|
|
||||
| `GptTurbo` | OpenAI |
|
||||
| `XinferenceChat` | Xinference |
|
||||
| `HuggingFaceChat` | HuggingFace |
|
||||
| `ModelScopeChat` | ModelScope |
|
||||
| `AzureChat` | Azure OpenAI |
|
||||
| `BaiChuanChat` | 百川 |
|
||||
| `LocalAIChat` | LocalAI |
|
||||
| `VolcEngineChat` | 火山引擎 |
|
||||
| `OpenAI_APIChat` | VLLM / OpenAI-API-Compatible |
|
||||
| `GPUStackChat` | GPUStack |
|
||||
|
||||
**切换机制**:`ModelApiKeyService.get_available_api_key()` 根据 `model_id` 从数据库读取 provider/api_key/base_url/model_name,运行时动态实例化。
|
||||
|
||||
### 3.2 流式 vs 非流式
|
||||
|
||||
**非流式**(`Base._chat()`,`chat_model.py:122-150`):
|
||||
- `stream=False`,返回 `(text, total_tokens)`
|
||||
- QWQ 推理模型强制内部走流式聚合,过滤 `<think>` 标签
|
||||
|
||||
**流式**(`Base._chat_streamly()`,`chat_model.py:152-185`):
|
||||
- `stream=True`,yield `(delta, token_count)`
|
||||
- 支持 `reasoning_content` 提取
|
||||
- `finish_reason == "length"` 时自动追加截断提示(中英文自适应)
|
||||
|
||||
**Agent 流式**(`LangChainAgent.chat_stream()`):
|
||||
- `agent.astream_events(version="v2")`
|
||||
- 处理 `on_chat_model_stream` / `on_llm_stream`
|
||||
- 支持多模态响应解析(OpenAI + 通义千问格式)
|
||||
|
||||
### 3.3 超时、重试、降级
|
||||
|
||||
**源码**:`chat_model.py:64-89, 192-215`
|
||||
|
||||
- 超时:`LLM_TIMEOUT_SECONDS`(默认 600s)
|
||||
- 重试:`LLM_MAX_RETRIES`(默认 5)+ 随机抖动延迟
|
||||
- 仅对 `RATE_LIMIT` / `SERVER_ERROR` 重试
|
||||
- **降级**:无自动模型降级,失败返回 `"**ERROR**: ..."`
|
||||
|
||||
### 3.4 函数调用 / 工具使用
|
||||
|
||||
**源码**:`chat_model.py:251-303, 335-436`
|
||||
|
||||
- 最多 `max_rounds`(默认 5)轮工具调用循环
|
||||
- 工具参数解析使用 `json_repair.loads()` 增强容错
|
||||
- 流式工具调用:`chat_streamly_with_tools()`
|
||||
|
||||
**Agent 工具循环**:`LangChainAgent`
|
||||
- `create_agent(model, tools, system_prompt)`
|
||||
- `max_iterations = 5 + len(tools) * 2`
|
||||
- 单个工具最大连续调用:`max_tool_consecutive_calls = 3`
|
||||
- `_wrap_tools_with_tracking()` 防循环
|
||||
|
||||
### 3.5 CV 模型与序列到文本模型
|
||||
|
||||
**CV 模型**(`cv_model.py`):`QWenCV`、`AzureGptV4` — 用于图片/版面分析。
|
||||
|
||||
**序列到文本**(`sequence2txt_model.py`):`QWenSeq2txt`(带时间戳 ASR)、`GPTSeq2txt`(Whisper)— 用于音视频预处理。
|
||||
|
||||
---
|
||||
|
||||
## 4. 生成后处理
|
||||
|
||||
### 4.1 引用回填(Citation Insertion)
|
||||
|
||||
**源码**:`api/app/core/rag/nlp/search.py:489-577`
|
||||
|
||||
流程:
|
||||
1. 将答案按句子切分(避开代码块 ```` ``` ````)
|
||||
2. 对每句话 embedding,与 chunk embeddings 计算 hybrid similarity
|
||||
3. 阈值从 0.63 开始动态衰减(×0.8),最低 0.3
|
||||
4. 每句最多引用 4 个 chunk,句末插入 `[ID:N]`
|
||||
|
||||
### 4.2 引用过滤与下载链接
|
||||
|
||||
**源码**:`api/app/services/draft_run_service.py:474-490`
|
||||
|
||||
- `features_config.citation.enabled` 开关控制
|
||||
- `allow_download=True` 时附加 `download_url`
|
||||
|
||||
### 4.3 安全过滤
|
||||
|
||||
当前版本无显式敏感词过滤模块。安全依赖:
|
||||
- LLM 提供商自带内容过滤
|
||||
- `ERROR_CONTENT_FILTER` 错误码捕获
|
||||
|
||||
### 4.4 输出结构化(JSON Schema)
|
||||
|
||||
**源码**:`api/app/core/agent/langchain_agent.py:85-92`
|
||||
|
||||
通过 system prompt 注入 `"\n请以JSON格式输出。"` 实现(非 `response_format` API),因为 LangChain Agent 有工具时无法使用原生 API。
|
||||
|
||||
---
|
||||
|
||||
## 5. 端到端示例
|
||||
|
||||
### 场景:Agent 聊天触发知识库检索
|
||||
|
||||
**Step 1** — 用户提问:`"MemoryBear 的 Rerank 策略是什么?"`
|
||||
|
||||
**Step 2** — System Prompt 组装:
|
||||
```
|
||||
你是一个专业的 AI 知识库助手,名为 Miss R。
|
||||
任务:根据知识库中的信息回答用户问题。
|
||||
要求:不要编造信息;使用 Markdown;用用户提问的语言回答。
|
||||
```
|
||||
(来自 `ask_summary.md`)
|
||||
|
||||
**Step 3** — LLM 判断调用 `knowledge_retrieval_tool`
|
||||
|
||||
工具内部:
|
||||
```python
|
||||
retrieve_chunks_result = knowledge_retrieval(query, kb_config)
|
||||
context = '\n\n'.join([i.page_content for i in retrieve_chunks_result])
|
||||
return f"检索到以下相关信息:\n\n{context}"
|
||||
```
|
||||
|
||||
**Step 4** — 若配置 `reranker_id`,执行 RedBearRerank:
|
||||
```python
|
||||
reranker = RedBearRerank(RedBearModelConfig(...))
|
||||
reranked_docs = list(reranker.compress_documents(documents, query))
|
||||
```
|
||||
|
||||
**Step 5** — Agent 组装消息并调用 LLM:
|
||||
```
|
||||
system: 你是一个专业的 AI 知识库助手...
|
||||
user: 参考信息:\n\nChunk 0...\n\nChunk 1...\n\n用户问题:\nMemoryBear 的 Rerank 策略是什么?
|
||||
```
|
||||
|
||||
**Step 6** — 输出后处理:
|
||||
```python
|
||||
filtered_citations = _filter_citations(features_config, citations_collector)
|
||||
```
|
||||
|
||||
最终返回:content + citations(含 document_id、file_name、score、可选 download_url)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 关键源码索引
|
||||
|
||||
| 功能 | 文件 | 类/函数 | 行号 |
|
||||
|------|------|---------|------|
|
||||
| Rerank 封装 | `api/app/core/models/rerank.py` | `RedBearRerank` | 11-84 |
|
||||
| 内置混合 Rerank | `api/app/core/rag/nlp/search.py` | `Dealer.rerank` | 606-643 |
|
||||
| 外部模型 Rerank | `api/app/core/rag/nlp/search.py` | `Dealer.rerank_by_model` | 645-666 |
|
||||
| rank_feature | `api/app/core/rag/nlp/search.py` | `_rank_feature_scores` | 579-604 |
|
||||
| 独立 rerank | `api/app/core/rag/nlp/search.py` | `rerank()` | 284-343 |
|
||||
| 知识检索入口 | `api/app/core/rag/nlp/search.py` | `knowledge_retrieval()` | 36-147 |
|
||||
| ES Vector rerank | `api/app/core/rag/vdb/elasticsearch/elasticsearch_vector.py` | `ElasticSearchVector.rerank` | 560-607 |
|
||||
| Workflow 节点 rerank | `api/app/core/workflow/nodes/knowledge/node.py` | `KnowledgeRetrievalNode.rerank` | 108-155 |
|
||||
| Workflow 执行 | `api/app/core/workflow/nodes/knowledge/node.py` | `KnowledgeRetrievalNode.execute` | 303-378 |
|
||||
| LLM 基类 | `api/app/core/rag/llm/chat_model.py` | `Base` | 52-319 |
|
||||
| 流式 LLM | `api/app/core/rag/llm/chat_model.py` | `_chat_streamly` | 152-185 |
|
||||
| 工具调用 | `api/app/core/rag/llm/chat_model.py` | `chat_with_tools` | 251-303 |
|
||||
| 流式工具调用 | `api/app/core/rag/llm/chat_model.py` | `chat_streamly_with_tools` | 335-436 |
|
||||
| 错误分类 | `api/app/core/rag/llm/chat_model.py` | `_classify_error` | 69-89 |
|
||||
| CV 模型 | `api/app/core/rag/llm/cv_model.py` | `QWenCV`, `AzureGptV4` | 1-497 |
|
||||
| 音频转录 | `api/app/core/rag/llm/sequence2txt_model.py` | `QWenSeq2txt`, `GPTSeq2txt` | 1-215 |
|
||||
| Prompt 加载 | `api/app/core/rag/prompts/template.py` | `load_prompt` | 9-20 |
|
||||
| Prompt 生成器 | `api/app/core/rag/prompts/generator.py` | `message_fit_in` 等 | 1-744 |
|
||||
| Agent 封装 | `api/app/core/agent/langchain_agent.py` | `LangChainAgent` | 26-641 |
|
||||
| Agent 消息准备 | `api/app/core/agent/langchain_agent.py` | `_prepare_messages` | 230-271 |
|
||||
| 知识检索工具 | `api/app/services/draft_run_service.py` | `create_knowledge_retrieval_tool` | 195-263 |
|
||||
| 引用过滤 | `api/app/services/draft_run_service.py` | `_filter_citations` | 474-490 |
|
||||
| 聊天服务 | `api/app/services/app_chat_service.py` | `agnet_chat` | 43-239 |
|
||||
| 流式聊天 | `api/app/services/app_chat_service.py` | `agnet_chat_stream` | 340-550 |
|
||||
| 引用插入 | `api/app/core/rag/nlp/search.py` | `Dealer.insert_citations` | 489-577 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 配置项与可调参数
|
||||
|
||||
**环境变量**:
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `LLM_TIMEOUT_SECONDS` | 600 | LLM 超时 |
|
||||
| `LLM_MAX_RETRIES` | 5 | 最大重试 |
|
||||
| `LLM_BASE_DELAY` | 2.0 | 重试基础延迟 |
|
||||
|
||||
**知识检索配置**:
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
|--------|--------|------|
|
||||
| `retrieve_type` | `participle` | participle/semantic/hybrid/graph |
|
||||
| `similarity_threshold` | 0.2 | 关键词相似度阈值 |
|
||||
| `vector_similarity_weight` | 0.3 | 向量权重 |
|
||||
| `top_k` | 4 | 单次检索 chunk 数 |
|
||||
| `reranker_id` | `None` | Rerank 模型 ID |
|
||||
| `reranker_top_k` | 4 | Rerank 后最终返回数 |
|
||||
|
||||
**Agent 参数**:
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
|--------|--------|------|
|
||||
| `max_iterations` | `5 + len(tools) * 2` | Agent 最大迭代 |
|
||||
| `max_tool_consecutive_calls` | 3 | 单工具最大连续调用 |
|
||||
| `max_rounds` | 5 | LLM 工具调用最大轮数 |
|
||||
| `temperature` | 0.7 | 生成温度 |
|
||||
| `max_tokens` | 2000 | 最大生成 token |
|
||||
| `json_output` | `False` | 强制 JSON 输出 |
|
||||
| `deep_thinking` | `False` | 深度思考 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 边界条件与已知限制
|
||||
|
||||
1. **外部 Rerank 延迟高**:RedBearRerank 调用 Jina/DashScope API,无本地缓存。
|
||||
2. **Token 裁剪较粗糙**:`message_fit_in` 丢弃中间历史,可能丢失上下文;按比例截断可能切断语义。
|
||||
3. **引用回填非 LLM 原生**:基于 embedding 相似度匹配,表述不同可能漏引。
|
||||
4. **JSON 输出兼容性差**:通过 system prompt 注入实现,可靠性低于原生 `response_format`。
|
||||
5. **无模型降级**:LLM 失败返回错误文本,不自动切换备用模型。
|
||||
6. **混合检索融合简单**:仅去重取并集,无 RRF 或加权分数融合。
|
||||
7. **GraphRAG 结果前置**:始终 `insert(0, ...)`,优先级最高但无分数参与 rerank。
|
||||
|
||||
---
|
||||
|
||||
## 9. 优化建议与未来扩展点
|
||||
|
||||
1. **Rerank 缓存**:对高频 query 做 LRU 缓存,降低外部 API 成本。
|
||||
2. **引用增强**:将 `citation_prompt.md` 注入 system prompt,让 LLM 生成阶段就输出 `[ID:N]`。
|
||||
3. **Token 预算精细化**:引入 `tiktoken` 精确计数,实现滑动窗口历史管理。
|
||||
4. **模型降级**:在 `Base.chat()` 中增加 fallback 模型链。
|
||||
5. **混合检索 RRF**:在 ES 查询层面实现 Reciprocal Rank Fusion。
|
||||
6. **流式引用**:在 `on_tool_end` 事件中实时 emit citation 元数据。
|
||||
7. **输出校验中间件**:对 `json_output=True` 增加 JSON Schema 强制校验层。
|
||||
|
||||
---
|
||||
|
||||
以上为 [S2-T5] 初版全文,请评审。
|
||||
Reference in New Issue
Block a user