zyk-rag 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zyk_rag-0.1.0.dist-info/METADATA +83 -0
- zyk_rag-0.1.0.dist-info/RECORD +5 -0
- zyk_rag-0.1.0.dist-info/WHEEL +5 -0
- zyk_rag-0.1.0.dist-info/top_level.txt +1 -0
- zyk_rag.py +318 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zyk_rag
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A modular RAG retrieval class based on LangChain and Chroma
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: chromadb
|
|
8
|
+
Requires-Dist: langchain-chroma
|
|
9
|
+
Requires-Dist: langchain-community
|
|
10
|
+
Requires-Dist: langchain-core
|
|
11
|
+
Requires-Dist: langchain-openai
|
|
12
|
+
Requires-Dist: langchain-classic
|
|
13
|
+
Requires-Dist: jieba
|
|
14
|
+
Requires-Dist: rank-bm25
|
|
15
|
+
Requires-Dist: python-dotenv
|
|
16
|
+
Requires-Dist: dashscope
|
|
17
|
+
Provides-Extra: publish
|
|
18
|
+
Requires-Dist: build; extra == "publish"
|
|
19
|
+
Requires-Dist: twine; extra == "publish"
|
|
20
|
+
|
|
21
|
+
# langchain-modular-rag
|
|
22
|
+
|
|
23
|
+
提供一个 `ZykRAG` 类,用于连接已有的 Chroma 知识库并执行在线检索。
|
|
24
|
+
|
|
25
|
+
Python 3.11 及以上。
|
|
26
|
+
|
|
27
|
+
## 安装
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install zyk_rag
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## 使用
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from zyk_rag import ZykRAG
|
|
37
|
+
|
|
38
|
+
rag = ModularRAG({
|
|
39
|
+
"persist_directory": "./chroma_db",
|
|
40
|
+
"collection_name": "study",
|
|
41
|
+
"embedding_model": "text-embedding-v4",
|
|
42
|
+
"top_k": 5,
|
|
43
|
+
"recall_top_k": 20,
|
|
44
|
+
"pre_retrieval": {
|
|
45
|
+
"enabled": False,
|
|
46
|
+
"rewrite_query": False,
|
|
47
|
+
"hyde": False,
|
|
48
|
+
"model": "qwen-plus",
|
|
49
|
+
},
|
|
50
|
+
"hybrid_retrieval": {
|
|
51
|
+
"enabled": True,
|
|
52
|
+
"weights": [0.5, 0.5],
|
|
53
|
+
},
|
|
54
|
+
"post_retrieval": {
|
|
55
|
+
"enabled": True,
|
|
56
|
+
"deduplicate": True,
|
|
57
|
+
"rerank": False,
|
|
58
|
+
"rerank_model": "qwen3-rerank",
|
|
59
|
+
"similarity_filter": False,
|
|
60
|
+
"similarity_threshold": 0.4,
|
|
61
|
+
"compress_context": False,
|
|
62
|
+
"llm_model": "qwen-plus",
|
|
63
|
+
"reorder": False,
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
results = rag.search("公司打卡时间?")
|
|
68
|
+
for document in results:
|
|
69
|
+
print(document.metadata)
|
|
70
|
+
print(document.page_content)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## 环境变量
|
|
74
|
+
|
|
75
|
+
需要配置通义 API Key:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
export DASHSCOPE_API_KEY="你的 API Key"
|
|
79
|
+
或
|
|
80
|
+
setx DASHSCOPE_API_KEY="你的 API Key"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`persist_directory` 指向已经完成入库的 Chroma 数据库。这个包当前只提供在线检索,不包含你的个人知识库数据,也不会自动创建或导入知识库。
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
zyk_rag.py,sha256=6lOI84O1sezHMZWeGAhjB4ovYQCq6ZE_f5_aLQxrTt0,12757
|
|
2
|
+
zyk_rag-0.1.0.dist-info/METADATA,sha256=h1-2iXfyqj8lPIxtRikl3hgRpiy1CzMdwSInAegJz-0,2096
|
|
3
|
+
zyk_rag-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
4
|
+
zyk_rag-0.1.0.dist-info/top_level.txt,sha256=gxW03LggUjSL0D65cpDypMuIN1nQe2uNwFOds0hFDsk,8
|
|
5
|
+
zyk_rag-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
zyk_rag
|
zyk_rag.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
|
|
2
|
+
class ZykRAG:
|
|
3
|
+
def __init__(self, config: dict):
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from dotenv import load_dotenv
|
|
7
|
+
from langchain_chroma import Chroma
|
|
8
|
+
from langchain_community.embeddings import DashScopeEmbeddings
|
|
9
|
+
|
|
10
|
+
load_dotenv()
|
|
11
|
+
self.config = config
|
|
12
|
+
directory = config.get("persist_directory", "../chroma_db")
|
|
13
|
+
if not (Path(directory).expanduser() / "chroma.sqlite3").is_file():
|
|
14
|
+
raise FileNotFoundError(f"知识库不存在:{directory}")
|
|
15
|
+
self.embeddings = DashScopeEmbeddings(
|
|
16
|
+
model=config.get("embedding_model", "text-embedding-v4"),
|
|
17
|
+
dashscope_api_key=os.getenv("DASHSCOPE_API_KEY"),
|
|
18
|
+
)
|
|
19
|
+
self.db = Chroma(
|
|
20
|
+
collection_name=config.get("collection_name", "study"),
|
|
21
|
+
persist_directory=str(Path(directory).expanduser()),
|
|
22
|
+
embedding_function=self.embeddings,
|
|
23
|
+
create_collection_if_not_exists=False,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
def search(self, question: str, top_k=None, filters=None):
|
|
27
|
+
from langchain_core.documents import Document
|
|
28
|
+
|
|
29
|
+
if not question.strip():
|
|
30
|
+
raise ValueError("问题不能为空")
|
|
31
|
+
limit = self.config.get("top_k", 5) if top_k is None else top_k
|
|
32
|
+
if not isinstance(limit, int) or limit <= 0:
|
|
33
|
+
raise ValueError("top_k 必须是正整数")
|
|
34
|
+
count = max(self.config.get("recall_top_k", 20), limit)
|
|
35
|
+
pre = self.config.get("pre_retrieval", {})
|
|
36
|
+
hybrid = self.config.get("hybrid_retrieval", {})
|
|
37
|
+
post = self.config.get("post_retrieval", {})
|
|
38
|
+
|
|
39
|
+
query = question
|
|
40
|
+
if pre.get("enabled", False):
|
|
41
|
+
query = self.normalize_query(query)
|
|
42
|
+
if pre.get("rewrite_query", False):
|
|
43
|
+
query = self.rewrite_query(query, model=pre.get("model", "qwen-plus"))
|
|
44
|
+
keyword_query = query
|
|
45
|
+
dense_query = query
|
|
46
|
+
if pre.get("enabled", False) and pre.get("hyde", False):
|
|
47
|
+
dense_query = self.generate_hyde(query, model=pre.get("model", "qwen-plus"))
|
|
48
|
+
|
|
49
|
+
conditions = []
|
|
50
|
+
for condition in (self.config.get("filters"), filters):
|
|
51
|
+
if condition:
|
|
52
|
+
conditions.extend({key: value} for key, value in condition.items())
|
|
53
|
+
where = None
|
|
54
|
+
if len(conditions) == 1:
|
|
55
|
+
where = conditions[0]
|
|
56
|
+
elif len(conditions) > 1:
|
|
57
|
+
where = {"$and": conditions}
|
|
58
|
+
|
|
59
|
+
if hybrid.get("enabled", False):
|
|
60
|
+
import jieba
|
|
61
|
+
from langchain_community.retrievers import BM25Retriever
|
|
62
|
+
from langchain_core.runnables import RunnableLambda
|
|
63
|
+
|
|
64
|
+
stored = self.db.get(where=where, include=["documents", "metadatas"])
|
|
65
|
+
corpus = []
|
|
66
|
+
for index, text in enumerate(stored["documents"] or []):
|
|
67
|
+
corpus.append(Document(
|
|
68
|
+
id=stored["ids"][index],
|
|
69
|
+
page_content=text,
|
|
70
|
+
metadata=stored["metadatas"][index] or {},
|
|
71
|
+
))
|
|
72
|
+
if not corpus:
|
|
73
|
+
return []
|
|
74
|
+
vector_retriever = self.db.as_retriever(
|
|
75
|
+
search_type="similarity",
|
|
76
|
+
search_kwargs={"k": count, "filter": where},
|
|
77
|
+
)
|
|
78
|
+
if dense_query != keyword_query:
|
|
79
|
+
vector_retriever = (
|
|
80
|
+
RunnableLambda(lambda incoming_query: dense_query) | vector_retriever
|
|
81
|
+
)
|
|
82
|
+
bm25_retriever = BM25Retriever.from_documents(
|
|
83
|
+
corpus,
|
|
84
|
+
preprocess_func=lambda text: list(jieba.cut(text)),
|
|
85
|
+
k=count,
|
|
86
|
+
)
|
|
87
|
+
documents = self.rrf_fusion(
|
|
88
|
+
keyword_query, vector_retriever, bm25_retriever,
|
|
89
|
+
weights=hybrid.get("weights", [0.5, 0.5]),
|
|
90
|
+
)
|
|
91
|
+
else:
|
|
92
|
+
documents = self.vector_search(dense_query, self.db, count, where)
|
|
93
|
+
|
|
94
|
+
if post.get("enabled", False):
|
|
95
|
+
if post.get("deduplicate", True):
|
|
96
|
+
documents = self.deduplicate(documents)
|
|
97
|
+
if post.get("rerank", False):
|
|
98
|
+
documents = self.rerank_documents(
|
|
99
|
+
question, documents, top_k=len(documents),
|
|
100
|
+
model=post.get("rerank_model", "qwen3-rerank"),
|
|
101
|
+
)
|
|
102
|
+
if post.get("similarity_filter", False):
|
|
103
|
+
documents = self.similarity_filter(
|
|
104
|
+
question, documents, self.embeddings,
|
|
105
|
+
threshold=post.get("similarity_threshold", 0.4),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
documents = documents[:limit]
|
|
109
|
+
for rank, document in enumerate(documents, start=1):
|
|
110
|
+
document.metadata["retrieval_rank"] = rank
|
|
111
|
+
|
|
112
|
+
if post.get("enabled", False):
|
|
113
|
+
if post.get("compress_context", False):
|
|
114
|
+
documents = self.compress_context(
|
|
115
|
+
question, documents, model=post.get("llm_model", "qwen-plus")
|
|
116
|
+
)
|
|
117
|
+
if post.get("reorder", False):
|
|
118
|
+
documents = self.reorder_documents(documents)
|
|
119
|
+
for position, document in enumerate(documents, start=1):
|
|
120
|
+
document.metadata["context_position"] = position
|
|
121
|
+
return documents
|
|
122
|
+
@staticmethod
|
|
123
|
+
# ==================== 1. 归一化查询词:移除所有空格并首尾去空格 ====================
|
|
124
|
+
def normalize_query(question: str):
|
|
125
|
+
import re
|
|
126
|
+
return re.sub(r"\s+", " ", question).strip() # 移除所有空格并首尾去空格
|
|
127
|
+
|
|
128
|
+
# ==================== 2. 改写查询词:根据模型提示改写为适合知识库检索的独立查询 ====================
|
|
129
|
+
@staticmethod
|
|
130
|
+
def rewrite_query(question: str, model="qwen-plus"):
|
|
131
|
+
import os
|
|
132
|
+
from dotenv import load_dotenv
|
|
133
|
+
from langchain_openai import ChatOpenAI
|
|
134
|
+
|
|
135
|
+
load_dotenv()
|
|
136
|
+
llm = ChatOpenAI(
|
|
137
|
+
model=model,
|
|
138
|
+
api_key=os.getenv("DASHSCOPE_API_KEY"),
|
|
139
|
+
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
140
|
+
temperature=0,
|
|
141
|
+
)
|
|
142
|
+
response = llm.invoke([
|
|
143
|
+
("system", "把问题改写为适合知识库检索的独立查询。保留原意,不编造缺失上下文,只输出查询。"),
|
|
144
|
+
("human", question),
|
|
145
|
+
])
|
|
146
|
+
return response.content.strip()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ==================== 3. 生成假设性答案:根据模型提示生成约100字的假设性答案 ====================
|
|
150
|
+
@staticmethod
|
|
151
|
+
def generate_hyde(question: str, model="qwen-plus"):
|
|
152
|
+
import os
|
|
153
|
+
from dotenv import load_dotenv
|
|
154
|
+
from langchain_openai import ChatOpenAI
|
|
155
|
+
|
|
156
|
+
load_dotenv()
|
|
157
|
+
llm = ChatOpenAI(
|
|
158
|
+
model=model,
|
|
159
|
+
api_key=os.getenv("DASHSCOPE_API_KEY"),
|
|
160
|
+
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
161
|
+
temperature=0,
|
|
162
|
+
)
|
|
163
|
+
response = llm.invoke([
|
|
164
|
+
("system", "为问题生成约100字的假设性答案,用于语义检索。只输出假设答案,不将其视为事实证据。"),
|
|
165
|
+
("human", question),
|
|
166
|
+
])
|
|
167
|
+
return response.content.strip()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ==================== 4. 稠密:向量检索召回文档 ====================
|
|
171
|
+
@staticmethod
|
|
172
|
+
def vector_search(question: str, db, top_k=5, filters=None):
|
|
173
|
+
retriever = db.as_retriever(
|
|
174
|
+
search_type="similarity",
|
|
175
|
+
search_kwargs={"k": top_k, "filter": filters},
|
|
176
|
+
)
|
|
177
|
+
documents = retriever.invoke(question)
|
|
178
|
+
for document in documents:
|
|
179
|
+
document.metadata["chunk_id"] = document.id
|
|
180
|
+
return documents
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ==================== 5. 稀疏:BM25检索召回文档 ====================
|
|
184
|
+
@staticmethod
|
|
185
|
+
def bm25_search(question: str, documents, top_k=5):
|
|
186
|
+
import jieba
|
|
187
|
+
from langchain_community.retrievers import BM25Retriever
|
|
188
|
+
|
|
189
|
+
if not documents:
|
|
190
|
+
return []
|
|
191
|
+
bm25_retriever = BM25Retriever.from_documents(
|
|
192
|
+
documents,
|
|
193
|
+
preprocess_func=lambda text: list(jieba.cut(text)),
|
|
194
|
+
k=top_k,
|
|
195
|
+
)
|
|
196
|
+
return bm25_retriever.invoke(question)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ==================== 6. RRF融合:向量检索和BM25检索融合 ====================
|
|
200
|
+
@staticmethod
|
|
201
|
+
def rrf_fusion(question: str, vector_retriever, bm25_retriever, weights=None, rrf_k=60):
|
|
202
|
+
from langchain_classic.retrievers import EnsembleRetriever
|
|
203
|
+
|
|
204
|
+
retriever = EnsembleRetriever(
|
|
205
|
+
retrievers=[vector_retriever, bm25_retriever],
|
|
206
|
+
weights=weights or [0.5, 0.5],
|
|
207
|
+
c=rrf_k,
|
|
208
|
+
)
|
|
209
|
+
return retriever.invoke(question)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ==================== 7. 去重:去重 ====================
|
|
213
|
+
@staticmethod
|
|
214
|
+
def deduplicate(documents):
|
|
215
|
+
seen = set()
|
|
216
|
+
results = []
|
|
217
|
+
for document in documents:
|
|
218
|
+
if document.page_content not in seen:
|
|
219
|
+
seen.add(document.page_content)
|
|
220
|
+
results.append(document)
|
|
221
|
+
return results
|
|
222
|
+
|
|
223
|
+
# ==================== 8. 重排序:根据模型提示对文档进行重排序 ====================
|
|
224
|
+
|
|
225
|
+
@staticmethod
|
|
226
|
+
def rerank_documents(question: str, documents, top_k=5, model="qwen3-rerank"):
|
|
227
|
+
import os
|
|
228
|
+
from dotenv import load_dotenv
|
|
229
|
+
from langchain_community.document_compressors import DashScopeRerank
|
|
230
|
+
|
|
231
|
+
if not documents:
|
|
232
|
+
return []
|
|
233
|
+
load_dotenv()
|
|
234
|
+
reranker = DashScopeRerank(
|
|
235
|
+
model=model,
|
|
236
|
+
dashscope_api_key=os.getenv("DASHSCOPE_API_KEY"),
|
|
237
|
+
top_n=top_k,
|
|
238
|
+
)
|
|
239
|
+
return list(reranker.compress_documents(documents, question))
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ==================== 9. 相似度过滤:根据相似度阈值过滤文档 ====================
|
|
243
|
+
@staticmethod
|
|
244
|
+
def similarity_filter(question: str, documents, embeddings, threshold=0.4):
|
|
245
|
+
import math
|
|
246
|
+
|
|
247
|
+
if not documents:
|
|
248
|
+
return []
|
|
249
|
+
query_vector = embeddings.embed_query(question)
|
|
250
|
+
vectors = embeddings.embed_documents([
|
|
251
|
+
document.page_content for document in documents
|
|
252
|
+
])
|
|
253
|
+
query_norm = math.sqrt(sum(value * value for value in query_vector))
|
|
254
|
+
|
|
255
|
+
results = []
|
|
256
|
+
for document, vector in zip(documents, vectors):
|
|
257
|
+
vector_norm = math.sqrt(sum(value * value for value in vector))
|
|
258
|
+
if query_norm == 0 or vector_norm == 0:
|
|
259
|
+
continue
|
|
260
|
+
similarity = sum(
|
|
261
|
+
query_value * value for query_value, value in zip(query_vector, vector)
|
|
262
|
+
) / (query_norm * vector_norm)
|
|
263
|
+
if similarity >= threshold:
|
|
264
|
+
result = document.model_copy(deep=True)
|
|
265
|
+
result.metadata["cosine_similarity"] = similarity
|
|
266
|
+
results.append(result)
|
|
267
|
+
return results
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# ==================== 10. 上下文压缩:根据模型提示对文档进行压缩 ====================
|
|
272
|
+
@staticmethod
|
|
273
|
+
def compress_context(question: str, documents, model="qwen-plus"):
|
|
274
|
+
import os
|
|
275
|
+
from dotenv import load_dotenv
|
|
276
|
+
from langchain_openai import ChatOpenAI
|
|
277
|
+
from langchain_classic.retrievers.document_compressors import LLMChainExtractor
|
|
278
|
+
|
|
279
|
+
if not documents:
|
|
280
|
+
return []
|
|
281
|
+
load_dotenv()
|
|
282
|
+
llm = ChatOpenAI(
|
|
283
|
+
model=model,
|
|
284
|
+
api_key=os.getenv("DASHSCOPE_API_KEY"),
|
|
285
|
+
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
286
|
+
temperature=0,
|
|
287
|
+
)
|
|
288
|
+
compressor = LLMChainExtractor.from_llm(llm)
|
|
289
|
+
results = []
|
|
290
|
+
for document in documents:
|
|
291
|
+
is_table = (
|
|
292
|
+
document.metadata.get("content_type") == "table"
|
|
293
|
+
or "<table" in document.page_content.lower()
|
|
294
|
+
)
|
|
295
|
+
if is_table:
|
|
296
|
+
results.append(document)
|
|
297
|
+
else:
|
|
298
|
+
results.extend(compressor.compress_documents([document], question))
|
|
299
|
+
return results
|
|
300
|
+
|
|
301
|
+
# ==================== 11. 上下文重排:根据模型提示对文档进行重排 ====================
|
|
302
|
+
@staticmethod
|
|
303
|
+
def reorder_documents(documents):
|
|
304
|
+
from langchain_community.document_transformers import LongContextReorder
|
|
305
|
+
|
|
306
|
+
return list(LongContextReorder().transform_documents(documents))
|
|
307
|
+
|
|
308
|
+
from langchain_core.documents import Document
|
|
309
|
+
|
|
310
|
+
documents = [
|
|
311
|
+
Document(id=str(index), page_content=f"相关性排名第{index}的内容")
|
|
312
|
+
for index in range(1, 6)
|
|
313
|
+
]
|
|
314
|
+
for document in reorder_documents(documents):
|
|
315
|
+
print(document.page_content)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
|