第三阶段的 RAG 已经能跑起来了。
你问一个问题,它会去 test_docs 里找相关片段,再把片段交给 DeepSeek 回答。
流程没问题。
但一放到真实文档里,就有点别扭。
你问:
交付有什么不确定性?文档里写的是:
上线时间存在延期风险。人一眼就知道,这俩说的是一回事。
关键词检索不一定知道。
因为「交付」「不确定性」和「上线」「延期风险」不是同一组词。
它可能就漏了。
这事挺烦的。
RAG 好不好用,第一步不是生成,而是检索。
资料没找对,后面回答再漂亮也没用。
所以第四阶段,我们把关键词检索换成语义检索。
也就是 embedding + 向量库。
01 关键词检索的问题在哪里
第三阶段的检索很朴素。
逻辑大概是这样:
问题里出现了哪些词↓这些词在文档片段里出现了几次↓命中越多,相关性越高
这个办法有两个好处。
简单。也容易理解。
但它的上限很明显。
问法变了,可能找不到
比如用户问:
项目有没有延期可能?文档里写:
当前排期存在交付风险。意思接近,但关键词不同。
关键词检索容易漏。
词命中了,也不一定相关
比如用户问:
风险在哪里?文档里很多地方都有「风险」两个字。
但有些只是背景描述,有些才是真正要看的风险点。
关键词检索不太会分辨。
文档越多,误差越明显
文档少的时候,关键词检索还能凑合。
资料一多,它就开始乱。
这也是为什么很多 RAG 项目第一版看着能跑,真正放进业务资料后就开始不稳。
不是模型突然变差。
是检索没做好。
02 embedding 是什么
embedding 听起来也挺硬。
简单说,就是把一段文字变成一串数字。
这串数字可以表示这段文字的大概含义。
比如下面两句话:
上线时间存在延期风险。项目交付可能会推迟。
关键词不一样。
但意思接近。
如果把它们转成向量,它们在向量空间里就会比较近。
再看另一句:
今天中午吃什么?这句话和前面两句就没什么关系。
向量距离就会远。
所以 embedding 做的事可以理解为:
把文字变成可以计算相似度的数字。
这样我们就不用只靠关键词了。
03 向量检索怎么工作
语义检索的流程大概是这样:
文档片段↓转成 embedding 向量↓存入向量库↓用户问题也转成向量↓在向量库里找最相似的片段↓把片段交给模型回答
和第三阶段相比,核心变化只有一个。
检索方式从关键词匹配,变成向量相似度。
其他流程不变。
还是:
切文档 → 找片段 → 基于片段回答 → 给出来源这也是分阶段做的好处。
第三阶段先把 RAG 骨架搭起来。第四阶段只替换检索模块。
不用推倒重来。
04 这次要做什么
第四阶段新建一个文件:
08_vector_rag.py这次做一个最小版向量 RAG。
它会完成这些事:
读取 test_docs 目录下的文档 切分成文档片段 用 embedding 模型把片段转成向量 用 FAISS 建一个本地向量索引 把用户问题也转成向量 检索最相似的片段 让 DeepSeek 基于片段回答 保留资料来源和片段编号
先说清楚。
这版不是最终知识库。
它每次运行都会重新读取文档、重新生成向量、重新建索引。
有点笨。
但适合学习。
等第五阶段,我们再把索引持久化,做成本地知识库。
05 安装依赖
在原来依赖基础上,需要加两个包。
pip install faiss-cpu numpy如果你想一次装齐,可以把 requirements.txt 更新成这样:
pypdfpdfplumberpython-docxmarkitdownlangchainlangchain-openaifaiss-cpunumpy
这里用 faiss-cpu。
先在普通电脑上跑通。
不用 GPU。
06 先定义文档片段
这部分和第三阶段差不多。
from dataclasses import dataclass@dataclassclass DocumentChunk:content: strsource: strindex: int
三个字段:
RAG 里一定要保留来源。
不然后面很难做引用。
07 读取文档和切块
还是沿用前面阶段的读取逻辑。
from pathlib import Pathfrom docx import Documentfrom pypdf import PdfReaderSUPPORTED_SUFFIXES = {”.pdf”, ”.docx”, ”.txt”, ”.md”, ”.log”}def 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)for table_index, table in enumerate(doc.tables):parts.append(f”\n[表格 {table_index + 1}]”)for row in table.rows:cells = [cell.text.strip().replace(”\n”, ” ”) for cell in row.cells]if any(cells):parts.append(” | ”.join(cells))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[str, str]:folder_path = Path(folder)docs = {}for file_path in folder_path.rglob(”*”):if not file_path.is_file():continueif file_path.suffix.lower() not in SUPPORTED_SUFFIXES:continuetext = read_document(str(file_path))if text.strip():docs[str(file_path)] = textreturn docs
切块函数:
def chunk_text(text: str, chunk_size: int = 800, overlap: int = 100) -> list[str]:text = text.strip()if not text:return []chunks = []start = 0while start < len(text):end = start + chunk_sizechunk = text[start:end].strip()if chunk:chunks.append(chunk)if end >= len(text):breakstart = end - overlapreturn 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
到这里,和第三阶段几乎一样。
真正变化从 embedding 开始。
08 创建 embedding 模型
这里有个小坑。
聊天模型和 embedding 模型不是一回事。
前三阶段主要用 DeepSeek 做聊天和总结。
但 embedding 模型要看你实际能用哪个服务。
为了让代码更通用,第四阶段先用 LangChain 的 OpenAIEmbeddings 写法。
如果你的服务支持 OpenAI embedding 兼容接口,就可以替换 base_url 和模型名。
import osfrom langchain_openai import OpenAIEmbeddingsdef build_embeddings() -> OpenAIEmbeddings:api_key = os.getenv(”OPENAI_API_KEY”)if not api_key:raise RuntimeError(”请先设置环境变量 OPENAI_API_KEY”)return OpenAIEmbeddings(model=”text-embedding-3-small”,api_key=api_key,)
如果你后面要完全换成国内可访问方案,可以考虑:
bge-m3 text2vec 通义千问 embedding 火山 embedding 其他 OpenAI-compatible embedding 服务
这部分后续可以单独做一篇模型替换说明。
别在这里纠结太久。
先把向量检索流程跑通。
09 用 FAISS 建本地向量索引
FAISS 可以理解成一个本地向量检索工具。
我们把所有文档片段转成向量,然后放进去。
import faissimport numpy as npdef build_vector_index(chunks: list[DocumentChunk], embeddings: OpenAIEmbeddings):texts = [chunk.content for chunk in chunks]vectors = embeddings.embed_documents(texts)matrix = np.array(vectors).astype(”float32”)dimension = matrix.shape[1]index = faiss.IndexFlatL2(dimension)index.add(matrix)return index
这里用了 IndexFlatL2。
它的意思很简单。
用 L2 距离找最相似的向量。
第一版够用了。
后面资料量大了,再考虑更复杂的索引。
10 检索相似片段
用户提问后,也要把问题转成向量。
再拿问题向量去 FAISS 里查相似片段。
def retrieve_by_vector(question: str,chunks: list[DocumentChunk],index,embeddings: OpenAIEmbeddings,top_k: int = 4,) -> list[DocumentChunk]:query_vector = embeddings.embed_query(question)query_matrix = np.array([query_vector]).astype(”float32”)distances, indices = index.search(query_matrix, top_k)results = []for idx in indices[0]:if idx == -1:continueresults.append(chunks[idx])return results
到这里,关键词检索就被替换掉了。
第三阶段是:
问题关键词 → 文档关键词匹配第四阶段变成:
问题向量 → 文档片段向量相似度这一步升级很关键。
11 让模型基于片段回答
回答部分和第三阶段类似。
还是用 DeepSeek。
from 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
这里要继续强调:
模型回答只能基于检索到的片段。
不然 RAG 就失去意义了。
12 把向量 RAG 流程拼起来
最后串成完整流程。
def vector_rag_answer(folder: str, question: str, top_k: int = 4) -> str:print(”正在读取并切分文档...”)chunks = build_chunks(folder)if not chunks:return ”没有读取到可用文档。请检查 test_docs 目录。”print(f”共生成 {len(chunks)} 个文档片段。”)print(”正在创建 embedding...”)embeddings = build_embeddings()print(”正在构建向量索引...”)index = build_vector_index(chunks, embeddings)print(”正在向量检索相关片段...”)related_chunks = retrieve_by_vector(question, chunks, index, embeddings, top_k=top_k)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 = vector_rag_answer(folder, question)print(”\n” + ”=” * 80)print(answer)
运行:
python 08_vector_rag.py这就是第四阶段的最小版本。
不做持久化。不做知识库管理。先把语义检索跑起来。
13 这版有什么限制
先把坑说清楚。
第一个坑,每次运行都会重建索引
这版代码每次都会重新读取文档、重新算 embedding、重新建 FAISS 索引。
文档少的时候没问题。
文档多了会慢,也会花钱。
第五阶段要做的本地知识库,就是为了解决这个问题。
第二个坑,embedding 模型需要单独选择
聊天模型和 embedding 模型不是一回事。
DeepSeek Chat 用来回答问题。embedding 模型用来把文本转成向量。
如果你用的是国内服务,要确认它是否提供 embedding 接口。
第三个坑,检索到了也不代表一定正确
向量检索比关键词检索聪明,但不是百分百准确。
它只是找“语义上相似”的片段。
所以回答里仍然要保留来源,方便人工核查。
第四个坑,敏感文档还是要小心
原始文件在本地解析。
但文本片段会被送去 embedding 服务,检索出的片段也会被送给 DeepSeek 生成回答。
合同、客户资料、财务数据、内部文件,先脱敏。
这件事别偷懒。
14 第四阶段到底升级了什么
第三阶段解决的是:
AI 能不能先查资料再回答第四阶段解决的是:
AI 能不能更像人一样,按意思去找资料关键词检索看字面。
向量检索看语义。
这就是最大的区别。
到这一步,文档助手已经从“能跑”开始往“好用”靠近了。
还没到知识库。
但方向对了。
完整代码获取
第四阶段代码会继续整理到同一个 Gitee 仓库里。
后台回复:
Agent学习
即可获取完整代码链接。
仓库地址也可以直接访问:
https://gitee.com/ai-gravity-field/guide-to-ai-agent-exploration下一阶段开始做本地知识库。
也就是把这次临时生成的向量索引保存下来,让它支持批量导入、增量更新和长期使用。
夜雨聆风