`
前言
本项目围绕 ESG 报告(以 PDF 为例)搭建一套检索增强生成(RAG)流程:首先对 PDF 进行结构化解析,区分正文与表格,将表格转为 Markdown 并避免与正文重复提取;随后对文本做分块、对表格整表保留,使用中文 BGE 模型将片段向量化并写入 ChromaDB 向量库;最后在问答阶段根据用户问题做语义检索,将 Top-K 片段作为参考资料交给大模型生成回答,并在元数据中保留页码与内容类型,便于溯源与展示。该方案兼顾中文语义检索与表格结构完整性,适合作为 ESG 类长文档问答与分析的基线实现。
一、Ingest
在检索之前,我们需要一个向量库,将报告数据通过ocr 或者 pdf 解析将其拆解成文本块,然后对每个文本块进行embedding(我这里用的BGE模型) ,最后存入向量库
PDF 解析 → 文本分块(Chunking)→ 向量化(Embedding)→ 存入向量数据库(ChromaDB)
可以根据需求实现如下操作取文本分块(这里以pdf数据为例)
def table_to_markdown(table: list) -> str:
"""将 pdfplumber 提取的二维列表转成 Markdown 表格"""
if not table or not table[0]:
return ""
rows = []
header = [str(cell or "").strip() for cell in table[0]]
rows.append("| " + " | ".join(header) + " |")
rows.append("| " + " | ".join(["---"] * len(header)) + " |")
for row in table[1:]:
cells = [str(cell or "").strip() for cell in row]
rows.append("| " + " | ".join(cells) + " |")
return "\n".join(rows)
def parse_pdf(pdf_path: str) -> list[dict]:
"""
逐页解析 PDF,区分表格与正文。
返回: [{"page": int, "type": "text"|"table", "content": str}, ...]
"""
blocks = []
with pdfplumber.open(pdf_path) as pdf:
total = len(pdf.pages)
print(f"共 {total} 页,开始解析...")
for i, page in enumerate(pdf.pages):
page_num = i + 1
# 1. 提取表格区域(bbox 用于后续剔除重叠文本)
tables = page.find_tables()
table_bboxes = [t.bbox for t in tables]
for table in tables:
data = table.extract()
md = table_to_markdown(data)
if md:
blocks.append({
"page": page_num,
"type": "table",
"content": md
})
# 2. 提取正文(过滤掉已被表格覆盖的区域)
if table_bboxes:
# 剔除表格 bbox 内的文字,避免重复
page_filtered = page
for bbox in table_bboxes:
page_filtered = page_filtered.filter(
lambda obj, b=bbox: not (
obj.get("x0", 0) >= b[0] and
obj.get("x1", 0) <= b[2] and
obj.get("top", 0) >= b[1] and
obj.get("bottom", 0) <= b[3]
)
)
text = page_filtered.extract_text() or ""
else:
text = page.extract_text() or ""
text = text.strip()
if text:
blocks.append({
"page": page_num,
"type": "text",
"content": text
})
if page_num % 20 == 0:
print(f" 已解析 {page_num}/{total} 页")
print(f"解析完成,共提取 {len(blocks)} 个原始块")
return blocks
def chunk_blocks(blocks: list[dict]) -> list:
"""
对每个原始块进行切分:
- 表格块:整个表格作为一个 Chunk(不切割,避免破坏结构)
- 文本块:用 RecursiveCharacterTextSplitter 切分
"""
from langchain_core.documents import Document
splitter = RecursiveCharacterTextSplitter(
chunk_size=CHUNK_SIZE,
chunk_overlap=CHUNK_OVERLAP,
separators=["\n\n", "\n", "。", ";", ",", " ", ""],
)
docs = []
for block in blocks:
meta = {"page": block["page"], "type": block["type"]}
if block["type"] == "table":
# 表格整体保留为一个 Chunk
docs.append(Document(page_content=block["content"], metadata=meta))
else:
# 文本切分
chunks = splitter.create_documents(
[block["content"]], metadatas=[meta]
)
print(chunks)
print("--------------------------------")
docs.extend(chunks)
print(f"Chunking 完成,共 {len(docs)} 个 Chunk")
return docs
我这里定义了一个基本格式 “page”: page_num, “type”: “text”, “content”: text, chunks将按照此格式进行存储。这里需要注意在实际编码的时候是只编码content。 page 和 type 是存在 metadata 里的溯源标签,向量化时不参与,但检索返回结果时会一起带回来。作用是:告诉你这个 Chunk 来自哪里、是什么类型。
二、Retrieval
向量库建好之后,就可以进行检索和LLM问答了,流程如下
加载向量库 – 语义检索(归一化+l2距离~ 余弦距离) – 拼装prompt上下文 – 调用llm生成答案
def load_vectorstore():
print("加载 Embedding 模型...")
embeddings = HuggingFaceEmbeddings(
model_name=EMBED_MODEL,
model_kwargs={"device": "cpu"},
encode_kwargs={"normalize_embeddings": True},
)
vectorstore = Chroma(
persist_directory=CHROMA_DIR,
embedding_function=embeddings,
)
print(f"向量库加载完成,共 {vectorstore._collection.count()} 个 Chunk")
return vectorstore
def retrieve(vectorstore, query: str, top_k: int = TOP_K):
"""语义检索,返回最相关的 Chunk 列表"""
docs = vectorstore.similarity_search_with_score(query, k=top_k)
return docs
def format_context(docs) -> str:
"""将检索到的 Chunk 拼成 Prompt 里的参考资料"""
parts = []
for i, (doc, score) in enumerate(docs, 1):
page = doc.metadata.get("page", "?")
dtype = doc.metadata.get("type", "text")
parts.append(
f"【资料{i}】(第{page}页,{dtype},相似度={1-score:.3f})\n{doc.page_content}"
)
return "\n\n".join(parts)
def ask_llm(context: str, question: str) -> str:
"""调用 LLM API 生成答案"""
try:
from openai import OpenAI
client = OpenAI(api_key=LLM_API_KEY, base_url=LLM_BASE_URL)
response = client.chat.completions.create(
model="qwen3.6-plus",
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": f"【参考资料】\n{context}\n\n【问题】\n{question}"},
],
temperature=0.1,
max_tokens=1024,
)
return response.choices[0].message.content
except Exception as e:
return f"[LLM 调用失败: {e}]\n\n(检索结果已在上方显示,可先验证检索质量)"
总结
整体上,本方案将「解析—分块—向量化—检索—生成」拆成清晰的两段:入库侧重 PDF 表格与正文的分离与元数据保留,检索侧重与入库一致的嵌入模型与向量库上的相似检索,再通过系统提示约束模型依据资料作答。向量侧采用归一化嵌入,检索分数与 L2 距离相关,在归一化条件下与余弦排序一致;展示上的「相似度」为便于阅读的近似换算。当前实现以文本与表格为主,图片与扫描页未纳入解析,若需覆盖图表或影印件,可在后续引入 OCR 或多模态能力作为扩展方向。

1062

被折叠的 条评论
为什么被折叠?



