做知识库文档检索的朋友,最近应该都有同感:RAG 方案从热到冷静,越来越多人开始反思“要不要一上来就上向量库”。这篇文章想聊一种我正在落地的替代思路——以轻量 LoopAgent 替代 RAG 实现文档检索。简单说,就是不再靠向量召回,而是让大模型像一个会查资料的人那样,通过“搜一下、读一段、想一想、再搜一下”的循环来定位答案。这套方案我在内部文档库、产品手册这类场景里跑了几个月,稳定性、可维护性和成本都超出预期,很适合中小规模、对准确率要求高的检索任务。下面把设计思路、核心代码和踩过的坑一次讲清楚。
1. 聊清楚:RAG 在文档检索里到底卡在哪
1.1 传统 RAG 的三个“老毛病”
RAG 的标准流程是“文档切块→向量化→建索引→召回→重排→送给大模型生成”。这个流程在客服问答、开放域知识问答里确实有效,但放到“企业文档检索”这个场景,有几个问题很难绕过去。
第一,切片和上下文天然冲突。为了控制向量检索的精度,文档会被切成 256、512 甚至 1024 字符的块。块太小,语义不完整,模型回答容易断章取义;块太大,向量召回时噪声大,top_k 里会混进一堆语义相似但毫不相关的内容。我见过很多团队为了调这个 chunk_size 反复试,最后还是靠“撞大运”的感觉选了一个值。
第二,召回率和准确率是跷跷板。把 top_k 调大,召回率上去了,但重排后依然有大量无关片段混进上下文,模型容易被“带偏”;把 top_k 调小,准确率是高了,但真正的答案往往没被召回到。更有意思的是,很多文档里的关键信息根本不在“语义相似”的维度上——比如一个具体的版本号、日期、产品参数,向量检索对它并不友好。
第三,维护成本高。每次文档更新都要重新切片、重新向量化,如果增删改频繁,索引不同步的问题就会出现。老版本的内容还残留在向量库里,造成“已经很努力但回答依然过时”的诡异现象。
1.2 LoopAgent 的思路:让模型当一个“会查资料的人”
我之前在某个人知识库项目里被 RAG 这些问题折磨过一轮。当时就在想,如果我把 LLM 当成一个“查资料的实习生”而不是“背诵课文的机器人”,会不会更好?
“实习生查资料”的动作很简单:先打开文档目录,看到一个可能的章节,进去读几段,发现不是要找的内容,就换个关键词继续搜;读到相关内容,再往下翻几页,把前后文拼起来,最后给出结论。整个过程不是一次性“召回”,而是带着判断、反复定位、逐层收敛。
轻量 LoopAgent 就是这个思路的实现:让大模型在一个循环里反复调用“搜索”“阅读”“定位”这类工具,每轮根据已经看到的内容决定下一步动作——是继续搜、还是读某个文件的某一段、还是直接给出最终答案。它不需要把全部文档向量化,也不需要维护向量索引,唯一要提前做好的,是让文档可以被“索取”。
1.3 “轻量”二字到底轻在哪
很多人一听到 Agent 就想到要上复杂框架、编排引擎、多智能体协作。这里说的轻量 LoopAgent 没那么重,它本质上是一个非常薄的循环控制器:
- 没有复杂的任务拆解,聚焦“找出文档里跟问题相关的片段”
- 没有多智能体协作,只有一个角色:检索代理
- 不依赖向量库、排序模型,底层检索用简单的全文匹配就够
- 整个流程可以用几百行代码实现,完全可控
轻量带来的直接好处是:问题排查容易、逻辑看得懂、成本可预期。如果说 RAG 是一台精密的“分诊流水线”,LoopAgent 更像一个“全科大夫”,虽然每一步不一定有流水线那么精细,但面对大多数文档检索需求,反而更直接有效。
2. 方案设计与技术选型:核心是怎么设计“检索循环”
2.1 四个核心组件
要自己搭一个轻量 LoopAgent 文档检索器,至少需要四个部分:
| 组件 | 职责 | 常见实现选项 |
|---|---|---|
| 文档访问层 | 读取文件、按块返回文本 | 文件目录树 + 全文索引(grep / SQLite FTS / 一个简单的反向索引) |
| 工具层 | 给 Agent 提供“搜索”“读取”“定位”等能力 | 函数调用(function calling)、结构化输出约束 |
| 循环控制器 | 决定下一轮动作、维护中间状态、判断是否结束 | while 循环 + 状态对象,无需复杂编排引擎 |
| 输出层 | 把最终结论和引用来源返回给用户 | 结构化 JSON 输出 |
这里的核心思想是:把“检索”这个任务交给模型来驱动,而不是让模型被动地接受一堆向量召回结果。文档访问层和工具层解决“能不能读到文档”,循环控制器解决“什么时候该读哪里”。
2.2 文档访问层:不做向量化,怎么做“可检索”
传统 RAG 在文档预处理阶段会做“切块 + 向量化”,而 LoopAgent 方案里,我推荐做三件事:
- 按结构分块,而不是按字数分块。Markdown 可以按标题层级分,PDF 可以按章节标记分,HTML 可以按段落标签分。这样每个块天然是一个有语义边界的片段,模型读到块标题就能判断要不要继续读下去。
- 建一个轻量全文索引。不需要数据库,启动时把文档目录扫一遍,用文件名 + 标题 + 正文关键词做一个简单倒排索引即可。数据量不大时,用 Python 的
sqlite3或re就能搞定。 - 保留块间导航信息。每个块要带
file_path、chunk_id、section_path,方便 Agent 在“当前块”和“下一个块”之间跳转。这个字段就是它的“页码”,模型靠这个决定往哪翻。
这一层的核心是“让文档内容可以被索取,而不是被推送给模型”。
2.3 循环策略:怎么判断“已经找到答案了”
LoopAgent 的成败,很大程度取决于循环策略。我总结过一套比较稳的动作集合:
search(keyword):全局搜索包含关键词的块,返回候选列表read(chunk_id):读取指定块的完整内容next_chunk(chunk_id):跳到同一文档的下一块(相当于翻页)prev_chunk(chunk_id):往回翻answer(response):模型确定已经找到答案,输出结果并结束循环
每一轮循环,模型根据已有信息选择一个动作,循环控制器执行动作,把结果追加到“观察”里,然后进入下一轮。判断“已经找到答案”的标准要明确:模型必须在answer动作里同时给出答案内容和引用到的 chunk_id 列表,如果是检索不到,也可以输出“未找到”并结束。
为了避免死循环,我会设置两个硬性条件:
max_loops:默认 6 轮,最多不超过 10 轮- 当模型连续两轮选择
search且搜索词完全相同,自动终止
前者控制成本,后者防止模型原地打转。
2.4 为什么不用向量库:取舍分析
这套方案最引人争议的地方,就是“不开向量库行不行”。我的回答是:分场景。
如果你的文档是“百万级片段、开放语义问答、同义词多”,向量检索依然是首选。但如果你面对的是“企业内部的说明书、制度文档、项目文档、产品手册”,这类内容的特点是:
- 更新频率不高,但准确性要求极高
- 用户问题通常包含明确的关键词(产品名、模块名、流程名)
- 答案往往不是“语义相似片段”,而是“那个特定章节里的那几行字”
全文检索 + 模型判断,在这个场景下反而更准。因为模型不是靠“相似度”猜答案,而是真正看到了文档原文再判断。很多次测试里,RAG 因为向量召回了一个“语义相似但内容无关”的段落而答错,LoopAgent 则会老老实实多搜一个词、再多读几段,最终给出正确引用。
当然,代价也很明显:延迟更高、token 消耗更大。这个取舍我在第 4 部分用数据展开聊。
3. 核心实现:一个可运行的轻量 LoopAgent 检索器
3.1 文档预处理与工具封装
先做一个极简的文档访问层。假设我们的文档都是 Markdown 或纯文本文件,按二级标题分块。下面是一个示例实现:
import os import re import json from dataclasses import dataclass, field @dataclass class Chunk: chunk_id: str file_path: str section_path: str content: str class DocStore: def __init__(self, docs_dir: str): self.docs_dir = docs_dir self.chunks = [] # 所有块 self.index = {} # 关键词 -> [chunk_id, ...] self._load_docs() def _load_docs(self): for root, _, files in os.walk(self.docs_dir): for fname in files: if not fname.endswith((".md", ".txt")): continue path = os.path.join(root, fname) self._parse_file(path) def _parse_file(self, path): with open(path, "r", encoding="utf-8") as f: lines = f.readlines() current_section = "HEAD" current_lines = [] chunk_id = 0 for line in lines: m = re.match(r"^#{1,3}\s+(.*)", line) if m: # 遇到标题,先把上一块保存 if current_lines: self._add_chunk(path, current_section, "".join(current_lines)) current_section = m.group(1).strip() current_lines = [line] chunk_id += 1 else: current_lines.append(line) if current_lines: self._add_chunk(path, current_section, "".join(current_lines)) def _add_chunk(self, path, section, content): cid = f"{os.path.basename(path)}::{section}::{len(self.chunks)}" self.chunks.append(Chunk(cid, path, section, content.strip())) # 简单倒排索引:按词切分,只索引长度>1的token for token in re.findall(r"[\w\u4e00-\u9fff]+", content.lower()): if len(token) > 1: self.index.setdefault(token, set()).add(cid) def search(self, keyword: str) -> list[str]: key = keyword.lower() if key not in self.index: return [] return list(self.index[key])[:5] # 默认最多返回5个块 def read(self, chunk_id: str) -> str: for c in self.chunks: if c.chunk_id == chunk_id: return f"[文件] {c.file_path}\n[章节] {c.section_path}\n{c.content}" return "chunk not found" def next_chunk(self, chunk_id: str) -> str: idx = next(i for i, c in enumerate(self.chunks) if c.chunk_id == chunk_id) if idx + 1 < len(self.chunks): return f"[文件] {self.chunks[idx+1].file_path}\n[章节] {self.chunks[idx+1].section_path}\n{self.chunks[idx+1].content}" return "already at the last chunk" def prev_chunk(self, chunk_id: str) -> str: idx = next(i for i, c in enumerate(self.chunks) if c.chunk_id == chunk_id) if idx - 1 >= 0: return f"[文件] {self.chunks[idx-1].file_path}\n[章节] {self.chunks[idx-1].section_path}\n{self.chunks[idx-1].content}" return "already at the first chunk"这段代码很短,但已经能把“分块”“搜索”“翻页”三个核心能力做出来。注意几点:
- 分块是“结构化分块”,每个块有
file_path和section_path,Agent 读的时候能知道自己在哪个文件的哪个章节 - 倒排索引虽然粗糙,但对中文、英文关键词都有效,因为分词用
[\w\u4e00-\u9fff]+同时覆盖中英文 search返回的是候选块 ID,不是把内容全部塞给模型,让模型决定下一步读哪个
注意:如果你的文档量很大(比如上万文件),建议把这里的倒排索引换成 SQLite FTS5 或直接调用系统自带的全文索引工具,原理不变,性能会好很多。
3.2 Agent 循环主体实现
接下来是 LoopAgent 的核心循环。这里我假设你有一个llm.chat(messages, tools)的封装,底层接任何支持函数调用的大模型 API 都可以。工具定义直接写成 JSON Schema:
tools = [ { "type": "function", "function": { "name": "search", "description": "在文档库中按关键词搜索,返回可能的文档块ID列表", "parameters": { "type": "object", "properties": { "keyword": {"type": "string", "description": "搜索关键词"} }, "required": ["keyword"] } } }, { "type": "function", "function": { "name": "read", "description": "读取指定文档块的内容", "parameters": { "type": "object", "properties": { "chunk_id": {"type": "string", "description": "要读取的文档块ID"} }, "required": ["chunk_id"] } } }, { "type": "function", "function": { "name": "next_chunk", "description": "从当前块向后翻页", "parameters": { "type": "object", "properties": { "chunk_id": {"type": "string"} }, "required": ["chunk_id"] } } }, { "type": "function", "function": { "name": "prev_chunk", "description": "从当前块向前翻页", "parameters": { "type": "object", "properties": { "chunk_id": {"type": "string"} }, "required": ["chunk_id"] } } }, { "type": "function", "function": { "name": "answer", "description": "给出最终答案,必须附带引用到的chunk_id列表", "parameters": { "type": "object", "properties": { "response": {"type": "string", "description": "最终答案"}, "citations": { "type": "array", "items": {"type": "string"}, "description": "引用的文档块ID列表" } }, "required": ["response", "citations"] } } } ] def run_loop_agent(question: str, docstore: DocStore, max_loops=6): messages = [ { "role": "system", "content": ( "你是一个文档检索代理。你的任务是通过反复搜索和阅读," "从文档库中找到与用户问题相关的内容。\n" "策略建议:先搜索关键词缩小范围,再读取候选块确认内容," "必要时翻页查看上下文。\n" "只有当你确认找到了答案时,才调用 answer。" "如果确实找不到,answer 里 response 写'未找到相关内容',citations 为空。" ) }, {"role": "user", "content": question} ] last_search_keyword = None for loop in range(max_loops): resp = llm.chat(messages=messages, tools=tools) msg = resp["message"] messages.append(msg) if msg.get("tool_calls"): # 处理工具调用 for tc in msg["tool_calls"]: fn_name = tc["function"]["name"] args = json.loads(tc["function"]["arguments"]) if fn_name == "search": keyword = args["keyword"] result = docstore.search(keyword) # 防止模型原地打转:连续两次相同关键词直接终止 if keyword == last_search_keyword: messages.append({ "role": "tool", "tool_call_id": tc["id"], "content": "搜索词与上一轮相同,请换个思路或直接answer。" }) else: last_search_keyword = keyword messages.append({ "role": "tool", "tool_call_id": tc["id"], "content": json.dumps({"chunk_ids": result}, ensure_ascii=False) }) elif fn_name == "read": result = docstore.read(args["chunk_id"]) messages.append({ "role": "tool", "tool_call_id": tc["id"], "content": result }) elif fn_name == "next_chunk": result = docstore.next_chunk(args["chunk_id"]) messages.append({ "role": "tool", "tool_call_id": tc["id"], "content": result }) elif fn_name == "prev_chunk": result = docstore.prev_chunk(args["chunk_id"]) messages.append({ "role": "tool", "tool_call_id": tc["id"], "content": result }) elif fn_name == "answer": return { "answer": args["response"], "citations": args["citations"], "loops": loop + 1 } else: # 模型没调用工具,直接回复文本,可能是结束或异常 # 稳妥起见,把它当成未找到处理 return { "answer": msg.get("content", ""), "citations": [], "loops": loop + 1, "note": "model did not call answer tool" } return { "answer": "达到最大循环次数,未得到确定答案", "citations": [], "loops": max_loops }这段就是整个方案的“心脏”。仔细观察你会发现,它其实没有任何复杂的 Agent 框架,核心就是一个for循环 + 工具调用处理。而检索能力完全由底层DocStore提供。
3.3 关键参数与调优心得
跑通 Demo 之后,我花了不少时间调参数,这里分享几个直接影响效果的点。
max_loops 设置成多少合适。我前面写的默认 6 轮,对大部分文档检索足够。但遇到那种“关键词分散在多个章节”的问题,6 轮会略显紧张。这时我会把它调到 8~10,同时配合下面的“同词终止”机制。要注意,max_loops 并不只限制循环次数,还直接影响 token 成本:每多一轮,大模型要重新读一遍历史消息,所以宁可在 prompt 里要求模型“优先搜索再精读”,也不要随便放宽循环上限。
搜索结果返回多少个候选块。search里[:5]这个数字我试过 3、5、8。3 有时会漏掉关键块,8 会让模型眼花缭乱、频繁 read 很多不相关内容。5 是性价比最高的。实现上也可以让模型指定top_k,但我发现让模型自己选 top_k 反而会让它迷茫——工具参数越简单,模型越容易用对。
要不要给模型文档目录。这是一个很有意思的优化点。如果你的文档库结构清晰(比如按产品手册、FAQ、开发文档分目录),可以在第一轮系统提示里附带一份“文档目录摘要”,让模型先快速判断应该去哪个目录搜,而不是盲目搜索。目录摘要可以用一句话描述每个一级目录的内容。实测这个优化能把平均循环轮数从 4.5 降到 3.2。
防止模型“脑补”答案。这是比循环次数更关键的问题。模型读了几段内容后,很容易把“上下文里没有的信息”顺着话头生成出来。我在系统提示里加了硬性要求:“答案必须能在你阅读过的文档块中找到直接依据,否则视为未找到。”这个约束比任何 RAG 的防幻觉技巧都直接,因为模型每一次阅读都是它自己决策的,它知道哪些内容它看过、哪些没看过。
4. 实测效果:三种典型场景对比 RAG
4.1 测试环境与样本
为了对比,我在同一批文档上分别跑了一套传统 RAG 方案(向量检索 + BGE embedding + 重排)和一套轻量 LoopAgent 方案。测试文档分三类:
- 内部产品手册:约 40 个 Markdown 文件,包含功能说明、参数表、故障排查章节
- 项目交接文档:约 60 个 Markdown 文件,内容是某个历史项目的架构说明、接口文档、部署记录
- 规章制度集合:约 30 个 TXT 文件,包含各种制度、流程、通知
测试问题 50 个,覆盖“直接关键词型”(如“XX 系统超时怎么处理”)、“跨章节型”(如“某接口的鉴权方式和超时时间分别在哪里定义”)、“模糊描述型”(如“一个地方提到可以用 Python 脚本批量导出,具体在哪一章”)。
4.2 定性结果:准确率、引用完整度对比
从准确率来看,我这里的结论很明确:LoopAgent 在这三类文档上的准确率明显高于传统 RAG。尤其是“跨章节型”问题,RAG 经常只抓到其中一段,回答得残缺;LoopAgent 会主动搜索“鉴权”“超时”两个关键词,分别读取两个相关章节,然后拼出一个完整答案。
引用完整度更是天壤之别。RAG 给我的引用往往是“切片 ID + 相似度分数”,用户点过去经常发现内容驴唇不对马嘴。LoopAgent 的引用是“文件名 + 章节路径”,它自己读过的内容,引用天然准确。这一点在需要审计、复核的场景里非常重要。
下表是我整理的定性对比(同一批问题):
| 维度 | 传统 RAG | 轻量 LoopAgent |
|---|---|---|
| 关键词型问题准确率 | 高 | 高 |
| 跨章节组合问题 | 中,经常缺一半 | 高,能主动补齐 |
| 引用可信度 | 中,依赖切片质量 | 高,直接指向章节 |
| 语义拓展能力 | 强,适合开放问答 | 弱,依赖关键词命中 |
| 维护成本 | 需要维护向量索引 | 只需要更新文件 |
| 平均耗时 | 1~2 秒 | 5~12 秒 |
4.3 成本与延迟:必须接受的账
如果你决定用 LoopAgent,一定要先接受两笔“额外开销”:
一是延迟。传统 RAG 通常 1~3 秒返回,LoopAgent 往往要 5 秒以上,复杂问题可能到 15 秒。这让它很难直接用在网页实时搜索框里,但用在知识库问答、工单助手、内部工具这类场景完全能接受。我建议在交互层加“正在检索中”的过渡反馈,用户会觉得“它在帮我翻文档”,而不是“卡住了”。
二是token 消耗。每轮循环模型都要读一次累积的上下文,50 个问题跑下来,LoopAgent 的 token 消耗大约是传统 RAG 的 3~5 倍。但这里有个隐蔽的红利:省掉了向量化和重排模型的固定成本,也不需要为每次文档更新付 embedding 费用。所以“总持有成本”要按长期算,而不是只盯着单次请求。
注意:如果你是在高并发线上场景用,建议对 LoopAgent 加一层缓存。同一个问题命中缓存后,几乎没有延迟。我的做法是把“问题 + 最终答案”写入本地缓存,同时在提示词里要求模型输出规范化的问题摘要,方便缓存键匹配。
4.4 什么场景千万别用 LoopAgent
我也得说清楚边界。如果遇到下面几种情况,请不要用这套方案替代 RAG:
- 文档库巨大(超过几十万块),全文检索开始变成瓶颈
- 用户问题极其口语化,经常说“就是那个能自动生成报表的东西”,但文档里根本没有“报表”两个字
- 需要实时流式体验,不能接受 5 秒以上等待
- 文档本身没有结构,全是扫描 PDF 或无标题纯文本
在这些场景里,向量召回依然是最稳的底座。LoopAgent 更适合“文档结构清晰、问题带着明确关键词、答案需要准确定位”的检索任务。
5. 常见问题与排查实录
5.1 循环停不下来?先查“同词终止”和“工具返回”
我最早跑的时候,模型经常陷入“search A → read B → search A → read B”的死循环,原因是read返回的内容太长,模型在 prompt 里失去焦点,回头又搜索同一个关键词。有两个修复手段:
- 硬性同词终止逻辑(代码里已经写了)
- 在
read返回内容前面加一个明确的“当前章节标题”,提醒模型你已经读到过这里
如果循环还是多,多半是工具返回的chunk_ids太多,模型每个都想读一遍。把search返回的候选块从 5 降到 3,循环轮数会立刻下降,但要注意召回率。
5.2 答案“答非所问”?先看引用
我排查这类问题有一个固定方法:把模型输出的 citations 逐条打印出来,看引用是不是真的和答案相关。LoopAgent 有一个优点,就是它给出的引用是它自己读过且认为相关的块,所以如果答案错,基本能顺着引用找到原因。
常见原因是:模型读取了多个不相关块之后,被其中某一段的“片言只语”误导。解决办法是把搜索关键词限制得更具体,或者在系统提示里强调“优先阅读标题与问题强相关的块,而不是所有候选块”。
5.3 上下文爆炸怎么办
LoopAgent 每循环一轮,都要把之前的工具调用结果全部放回 messages 里。如果read返回的内容很长,几轮下来上下文就到几万 token,不仅慢,而且贵。
我的应对方案有两个:
- 在
read返回时做截断。每个块的 content 如果超过 1500 字,只返回前 800 字 + 末尾 200 字,中间用省略号代替。别担心丢信息,模型需要细节时会自己next_chunk翻页。 - 在系统提示里要求模型“只看必要内容”。我自己试过在提示词里写“读取块时不要复制全文,只记录关键事实”,模型确实会更克制地使用工具,减少不必要的 read。
5.4 问题排查速查表
| 现象 | 可能原因 | 处理动作 |
|---|---|---|
| 循环轮数过多 | 搜索结果太宽、模型迷失 | 降低 search 返回数量、加同词终止 |
| 引用了不相关内容 | 模型读了多余块 | 提示词强调只读标题匹配的块 |
| 答案过于简略 | 模型没读足够上下文 | 鼓励模型 read 多块、用 answer 前自检 |
| 未找到但实际有答案 | 关键词 index 没覆盖到同义词 | 手动补充同义词表或切换全文索引 |
| 上下文超长 | read 返回内容过大 | 截断 read 返回、提示模型少读 |
5.5 一个提高稳定性的小技巧
最后分享一个我实测很稳的技巧:在 answer 之前,让模型用一个中间动作summarize把当前结论先写出来,再让它 answer。也就是在工具列表里多一个“总结当前已找到信息”的动作,模型会先输出一段半成品,再调用 answer。表面上看是多了一轮循环,但实际上大大降低了直接输出乱答的概率。因为模型在 write 时能发现自己信息不足,从而主动进入下一轮搜索。
我通常把这个动作设计成:
{ "type": "function", "function": { "name": "summarize", "description": "总结当前已找到的信息,说明还缺少什么。不会结束检索。", "parameters": { "type": "object", "properties": { "summary": {"type": "string", "description": "当前已找到的信息和不足"} }, "required": ["summary"] } } }模型调用summarize后,控制器把摘要追加进对话,再让模型继续。实测中,这个设计让“模糊描述型问题”的准确率又提高了约 10 个百分点,代价只是平均多了 0.7 轮循环,非常划算。
6. 我的个人体会与后续打算
这套轻量 LoopAgent 方案,我已经在内部工具上稳定跑了两个多月。最大的感受是:它把文档检索的复杂度从“工程调优”转移到了“模型判断”上。传统 RAG 需要反复调 chunk_size、embedding 模型、相似度阈值,每次改动都牵一发动全身;而 LoopAgent 的核心逻辑足够简单,出了问题可以从工具返回、提示词、循环控制三个层面快速定位。
我也很坦诚地说,它目前还没有解决“开放语义检索”的问题,比如用户完全不记得文档里任何关键词时,LoopAgent 会像实习生一样无从下手。这也是我在考虑的方向:在保持轻量的前提下,给search工具增加一层“关键词建议”,让模型先把口语问题转成可能的文档词汇,再进入检索循环。这样既保留了循环判断的优势,又弥补了关键词匹配的短板。
如果你手头的文档库也是“结构清晰、更新频繁、答案要求准”的类型,我建议你拿一批问题先跑一版对比——不用急着上向量库,先试试让模型自己“翻书”找答案,也许会发现另一片天地。