Files
MemoryBear/docs/rag/pipeline/03-vdb-and-retrieval.md
Multica PM Agent 343a5eebe3
Some checks failed
Sync to Gitee / sync (push) Has been cancelled
docs(rag): add MemoryBear RAG implementation docs v1.0
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>
2026-05-09 10:51:48 +08:00

973 lines
53 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# [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 / ESREADME "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 两条路径的边界
| 维度 | 路径 AESConnection / Dealer | 路径 BElasticSearchVector |
| --- | --- | --- |
| 索引名 | `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(...)`HNSWES 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 mappingKB 索引)
```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 一个 indexKB 多了 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"字典,新值无法覆盖旧 key341-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 关键词检索(路径 BBM25 + 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 关键词检索(路径 Aquery_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 向量检索(路径 Bscript_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 向量检索(路径 Aknn + 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.05knn 的分数没有显式 boost相当于权重 1.0,但语义上由调用方约定 0.95**即代码层面是"BM25 直接乘 0.05knn 不缩放",并未严格归一化到等比例**——这是一个已知近似,见 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.3fallback 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 不超过 32GBzero-based compressed oops
- 留 50% RAM 给 OS file cachelucene 依赖 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 一个 indexKB 数 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` 默认未生效 | 网络抖动直接抛错 | bugbool 与 "true" 字符串比较;需显式 `=true` |
| `script_score` 暴力扫描 | 大 KB 延迟高 | 路径 B 升级到 `knn` queryES 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 searchCCS按 workspace 切集群。
3. **GraphRAG 与 VDB 联合检索**:当前 `kg_retriever.retrieval` 在路径 B 是后置 insertnode.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.1similarity 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 做联合检索。