乐于分享
好东西不私藏

让 AI 文档助手开始“查资料再回答”

让 AI 文档助手开始“查资料再回答”
第一阶段,我们让 AI 能读 PDF、Word、TXT。
第二阶段,我们把文档处理做成流水线,解析、分段、摘要、结构化输出。
第三阶段,终于可以碰 RAG 了。不再让 AI 凭感觉回答,而是先去资料里找,再基于找到的内容回答。

前两阶段做完以后,这个文档助手已经能干一点实事了。
它能读文件。能切长文档。能做分段摘要。也能把结果整理成固定格式。
但它还缺一个很关键的能力。
问答。
比如你手里有一批资料:
产品说明
项目周报
会议纪要
技术方案
合同条款
调研报告
你不一定每次都想让 AI 总结全文。
很多时候,你只是想问一句:
这个项目目前最大的风险是什么?
或者:
上周提到的阻塞点解决了吗?
再比如:
合同里关于交付延期是怎么约定的?
这时候,如果 AI 只是凭感觉回答,就很危险。
我不太喜欢那种看起来很完整、细看没出处的答案。
尤其是处理项目资料、合同、会议纪要的时候。
错一次就够麻烦。
所以第三阶段要做的事很简单:

先查资料,再回答。

这就是 RAG 最朴素的用法。

01 RAG 解决的不是“让模型更聪明”

RAG,全称是 Retrieval-Augmented Generation。
中文一般叫检索增强生成。
这个词有点硬。
换成普通话,就是:

回答之前,先把相关资料找出来。

普通问答大概是这样:
用户问题 → 大模型直接回答
RAG 问答多了一步:
用户问题 → 检索相关文档片段 → 把片段交给模型 → 基于片段回答
这一步看着不起眼,但很关键。
因为很多业务问题的答案不在模型训练数据里,而在你自己的文档里。
模型不知道你上周会议讲了什么。不知道合同里怎么写。也不知道项目日报里有哪些风险。
它不知道,就不能让它硬答。
先把资料找出来。

02 这次先做一个最小版本

第三阶段先不碰复杂向量数据库。
先做一个能跑起来的小版本:

把文档切成片段,用关键词匹配做检索,再让模型基于检索结果回答。

它会做 5 件事:
读取本地文档
切分成多个片段
给每个片段保留来源信息
根据问题检索相关片段
让模型基于片段回答,并列出引用来源
最后你可以这样问:
请根据 test_docs 里的资料,回答这个项目目前有哪些风险?
输出会接近这样:
回答项目当前主要风险包括交付延期、接口依赖未确认、测试资源不足。依据1. sample_report.pdf / 第 2 页,提到接口联调依赖外部系统确认。2. meeting_notes.md,提到测试环境还未准备完成。
这比“看起来很懂”的回答靠谱多了。
至少你知道它参考了哪里。

03 先定义文档片段结构

RAG 的第一步不是模型。
是把文档整理成一批可检索的片段。
新建文件:
07_rag_qa.py
先定义一个简单的数据结构:
from dataclasses import dataclass@dataclassclass DocumentChunk: content: str source: str index: int
这里有 3 个字段:
为什么要保留 source?
因为问答结果最好能告诉用户:

这句话是根据哪份资料来的。

否则回答再漂亮,也很难放心用。

04 读取文件夹里的所有文档

第一阶段和第二阶段都只处理单个文件。
第三阶段开始,我们处理一个文件夹。
from pathlib import Pathfrom docx import Documentfrom pypdf import PdfReaderdef read_pdf(file_path: str) -> str: reader = PdfReader(file_path) pages = [] for i, page in enumerate(reader.pages): text = page.extract_text() or ”” text = text.strip() if text: pages.append(f”[第 {i + 1} 页]\n{text}”) return ”\n\n”.join(pages)def read_docx(file_path: str) -> str: doc = Document(file_path) parts = [] for p in doc.paragraphs: text = p.text.strip() if text: parts.append(text) return ”\n”.join(parts)def read_txt(file_path: str) -> str: return Path(file_path).read_text(encoding=”utf-8”)def read_document(file_path: str) -> str: path = Path(file_path) suffix = path.suffix.lower() if suffix == ”.pdf”: return read_pdf(str(path)) if suffix == ”.docx”: return read_docx(str(path)) if suffix in [”.txt”, ”.md”, ”.log”]: return read_txt(str(path)) return ””
再写一个函数,读取整个目录:
def load_documents(folder: str) -> dict[strstr]: folder_path = Path(folder) docs = {} for file_path in folder_path.rglob(”*”): if not file_path.is_file(): continue if file_path.suffix.lower() not in [”.pdf”, ”.docx”, ”.txt”, ”.md”, ”.log”]: continue text = read_document(str(file_path)) if text.strip(): docs[str(file_path)] = text return docs
这一步做完以后,我们就能把 test_docs 目录里的资料读进来了。

05 把文档切成可检索片段

继续沿用第二阶段的切块思路。
def chunk_text(text: str, chunk_size: int = 800, overlap: int = 100) -> list[str]: text = text.strip() if not text: return [] chunks = [] start = 0 while start < len(text): end = start + chunk_size chunk = text[start:end].strip() if chunk: chunks.append(chunk) start = end - overlap if start < 0: start = 0 if start >= len(text): break return chunksdef build_chunks(folder: str) -> list[DocumentChunk]: docs = load_documents(folder) all_chunks = [] for source, text in docs.items(): chunks = chunk_text(text) for i, chunk in enumerate(chunks, start=1): all_chunks.append( DocumentChunk( content=chunk, source=source, index=i, ) ) return all_chunks
这里的 chunk_size 我设置得比第二阶段小一点。
因为问答检索时,片段太长容易带入无关内容。片段太短,又容易丢上下文。
第一版可以先用:
chunk_size = 800overlap = 100
后面根据文档类型再调。

06 先用一个笨办法做检索

正式 RAG 一般会用 embedding 和向量数据库。
但这篇先不急。
我们先用一个很笨、但能看懂的办法:

看问题里的词,在文档片段里出现了多少。

这不是最强检索。
但足够帮你看懂 RAG 的流程。
import redef tokenize(text: str) -> list[str]: tokens = re.findall(r”[\u4e00-\u9fa5A-Za-z0-9]+”, text.lower()) return [t for t in tokens if len(t.strip()) > 1]def score_chunk(question: str, chunk: DocumentChunk) -> int: question_tokens = set(tokenize(question)) content = chunk.content.lower() score = 0 for token in question_tokens: if token in content: score += 1 return scoredef retrieve(question: str, chunks: list[DocumentChunk], top_k: int = 4) -> list[DocumentChunk]: scored = [] for chunk in chunks: score = score_chunk(question, chunk) if score > 0: scored.append((score, chunk)) scored.sort(key=lambda x: x[0], reverse=True) return [chunk for _, chunk in scored[:top_k]]
这个检索器很简单。
但它已经有了 RAG 的核心动作:
问题 → 找相关片段 → 返回 top_k 个片段
后面你把这部分换成向量检索,整体流程不用大改。
这也是我建议先写简化版的原因。
先把骨架跑起来。

07 让模型基于片段回答

现在进入生成阶段。
还是用 DeepSeek。
import osfrom langchain_openai import ChatOpenAIdef build_llm() -> ChatOpenAI: api_key = os.getenv(”DEEPSEEK_API_KEY”) if not api_key: raise RuntimeError(”请先设置环境变量 DEEPSEEK_API_KEY”) return ChatOpenAI( model=”deepseek-chat”, base_url=”https://api.deepseek.com/v1”, api_key=api_key, temperature=0, )
再写回答函数:
def answer_question(llm: ChatOpenAI, question: str, chunks: list[DocumentChunk]) -> str: if not chunks: return ”没有检索到相关内容,无法基于文档回答。” context_parts = [] for i, chunk in enumerate(chunks, start=1): context_parts.append( f”[资料 {i}]\n来源:{chunk.source}\n片段编号:{chunk.index}\n内容:\n{chunk.content}” ) context = ”\n\n”.join(context_parts) prompt = f”””你是一个文档问答助手。请只根据下面提供的资料回答问题。要求:- 不要编造资料中没有的信息- 如果资料不足,直接说“资料中未明确提到”- 回答要简洁- 最后列出你参考了哪些资料来源用户问题:{question}可参考资料:{context}请按下面格式输出:回答直接回答用户问题。# 依据列出引用的资料来源和片段编号。””” result = llm.invoke(prompt) return result.content
这里最重要的是这句:

请只根据下面提供的资料回答问题。

它不能保证模型完全不编。
但能明显降低模型乱发挥的概率。
同时,输出里要求列出来源。
后面人工检查会方便很多。

08 把 RAG 问答流程拼起来

现在把前面的步骤串起来。
def rag_answer(folder: str, question: str) -> str: print(”正在读取并切分文档...”) chunks = build_chunks(folder) print(f”共生成 {len(chunks)} 个文档片段。”) print(”正在检索相关片段...”) related_chunks = retrieve(question, chunks, top_k=4) if not related_chunks: return ”没有检索到相关内容。你可以换一种问法,或者检查文档是否包含相关信息。” print(”检索到的片段:”) for chunk in related_chunks: print(f”- {chunk.source} / 片段 {chunk.index}”) print(”正在生成回答...”) llm = build_llm() return answer_question(llm, question, related_chunks)if __name__ == ”__main__”: folder = ”test_docs” question = ”这个项目目前有哪些风险?” answer = rag_answer(folder, question) print(”\n” + ”=” * 80) print(answer)
运行:
python 07_rag_qa.py
你会看到类似过程:
正在读取并切分文档...共生成 12 个文档片段。正在检索相关片段...检索到的片段:- test_docs/sample_report.pdf / 片段 2- test_docs/meeting_notes.md / 片段 1正在生成回答...
这就是一个最小版本的 RAG 文档问答。
不复杂。
但它已经把“凭空回答”变成了“查资料后回答”。

09 这个版本有什么限制

先说清楚,这一版不是最终形态。
它能帮你理解 RAG,但还不够强。

限制一,关键词检索比较粗糙

现在的检索方式只是看关键词匹配。
如果问题和文档表达不一致,它可能找不到。
比如你问:
交付有什么不确定性?
但文档里写的是:
上线时间存在延期风险。
关键词不一样,就可能漏掉。
后面要换成 embedding 检索。

限制二,引用来源还不够细

现在只能引用到文件和片段编号。
如果要更严谨,最好能做到:
PDF 页码
Word 标题
Markdown 小节
原文片段
这会更适合严肃场景。

限制三,文档一多,检索会变慢

现在所有片段都在内存里遍历。
几十份文档还行。上百份、上千份就不合适了。
后面要接向量数据库,比如 FAISS、Chroma 这类工具。

限制四,敏感内容仍然要谨慎

原始文件在本地解析。
但被检索出来的片段会发给模型。
如果是合同、客户资料、财务信息,记得先脱敏,或者换成本地模型。
这个提醒不性感,但很重要。

10 第三阶段到底学到了什么

到这里,你应该能看懂 RAG 的基本流程了。
它不是魔法。
就是四步:
切文档 → 建片段 → 检索相关内容 → 基于内容回答
第一阶段,我们让 AI 会读文件。
第二阶段,我们让 AI 会按流程处理文件。
第三阶段,我们让 AI 开始围绕资料回答问题。
这三步连起来,才开始像一个真正的文档助手。
不是聊天机器人。是能翻资料、能引用、能辅助办公的工具。

完整代码获取

第三阶段代码会继续整理到同一个 Gitee 仓库里。
后台回复:

Agent学习

即可获取完整代码链接。
仓库地址也可以直接访问:
https://gitee.com/ai-gravity-field/guide-to-ai-agent-exploration
下一阶段,我会把这个简易 RAG 升级一下。
重点做两件事:
用 embedding 替换关键词检索
用向量库保存文档片段
这样它就能从“能查一点资料”,慢慢变成真正可用的本地知识库助手。